v3.10.0: preset TAR table with per-TAR MSL; derive the SPI1 from the TAR

Presets drop the card-wide SPI1/SPI2 and the fixed tar/uiccTar/usimTar
fields: a preset now carries `tars` - the three mandatory role entries
(isd/uiccRfm/usimRfm, spec defaults pre-settled) plus free TARs with an
optional description, each with its MSL (Minimum SPI1, TS 102 226
8.2.1.3.2.4).  v3.9.1 presets convert on load/import (the spi1 seeds all
three MSLs); duplicate TARs and empty TAR/MSL values are refused; the
store schema version is 3.

Operations derive a packet's SPI1 from its TAR's MSL (_spi1_for_tar): the
RAM ops, the test runner (per-step overrides still win), the counter probe
and each TAR-probe row; the plain send keeps an explicit SPI1 with a
`warning` when it is below the MSL, and a TAR outside the preset without an
explicit SPI1 is refused with a hint.  The TAR probe resolves per checked
TAR before sending and reports the effective spi1 per row.  The secured
packet form's SPI2 default is 00 (the RAM/probe operations pin their own).

PWA: the Cards form edits the TAR table (role rows with MSL inputs plus
Add/Remove free rows with a description), the list shows a TARs (MSL)
column, the SCP80 TAR field suggests the preset's entries (datalist) and a
preset apply seeds the SPI1 from the operation TAR's MSL with an amber
warning below it; the preset-wide SPI1/SPI2 fields and the header marker's
SPI requirement are gone (completeness = a keyset + the three role entries
with their MSLs).  Help EN/RU, docs/api.md and AGENTS updated; sw cache
simple-v358.

Live: TAR probe 000000 at its MSL 16 (registered, por_ok, counter
advanced); a send without spi1 derived 16 (por_ok, counter advanced); a
send with spi1 0A returned the warning and the card refused it (6200, no
counter consumed); an unknown TAR without spi1 was refused with the hint.
This commit is contained in:
2026-10-02 08:28:46 +03:00
parent f059d05040
commit 6360dc5bae
19 changed files with 1187 additions and 269 deletions
+44 -11
View File
@@ -192,7 +192,7 @@ A blocked ADM (`6983`/`9804`) reports `{"ok": false, "sw": "9804",
### Card presets (server-side store) ### Card presets (server-side store)
Card presets (keysets, SPI, per-target TARs, PSK pair, ADM) live on the server Card presets (keysets, a TAR table with per-TAR MSLs, PSK pair, ADM) live on the server
in `~/.pysim-simple-server/card_presets.json` (`--card-presets PATH` overrides in `~/.pysim-simple-server/card_presets.json` (`--card-presets PATH` overrides
it; `Path.home()` resolves the same way on Linux, macOS and Windows). The file it; `Path.home()` resolves the same way on Linux, macOS and Windows). The file
is written atomically and every counter change is appended to is written atomically and every counter change is appended to
@@ -203,9 +203,14 @@ is written atomically and every counter change is appended to
the bytes, there is no separate field: the bytes, there is no separate field:
```json ```json
{"id": "…", "name": "Card A", "iccid": "…", "spi1": "16", "spi2": "01", {"id": "…", "name": "Card A", "iccid": "…",
"tar": "000000", "uiccTar": "B00000", "usimTar": "B00001",
"pskIdentity": "", "pskKey": "", "adm": "", "pskIdentity": "", "pskKey": "", "adm": "",
"tars": [
{"role": "isd", "tar": "000000", "msl": "16", "desc": ""},
{"role": "uiccRfm", "tar": "B00000", "msl": "16", "desc": ""},
{"role": "usimRfm", "tar": "B00001", "msl": "16", "desc": ""},
{"tar": "AF4D01", "msl": "1A", "desc": "my applet"}
],
"keysets": [ "keysets": [
{"kic": "15", "kid": "15", "kicKey": "…", "kidKey": "…", "cntr": "0000000001"}, {"kic": "15", "kid": "15", "kicKey": "…", "kidKey": "…", "cntr": "0000000001"},
{"kic": "29", "kid": "29", "kicKey": "…", "kidKey": "…", "cntr": "0000000005"} {"kic": "29", "kid": "29", "kicKey": "…", "kidKey": "…", "cntr": "0000000005"}
@@ -221,6 +226,19 @@ fixed-width 10-hex form. A v3.8.0 flat preset (`kic`/`kid`/`kicKey`/`kidKey`/
`cntr` at the top level) converts into a single keyset on load and on import, `cntr` at the top level) converts into a single keyset on load and on import,
so old files and exports keep working. so old files and exports keep working.
**TAR table.** Every Remote Management application is addressed by its TAR and
each entry carries its own **MSL** (Minimum SPI1, TS 102 226 §8.2.1.3.2.4): the
card checks it before the security processing and answers response status `0A`
"Insufficient security level" when a packet's SPI1 is below it. The three role
entries (`isd`, `uiccRfm`, `usimRfm` — GP ISD `000000`, TS 31.116 UICC RFM
`B00000`, TS 31.115 ADF RFM `B00001`) are **mandatory** with a non-empty TAR
and MSL; further TARs can be added with an optional `desc`. A TAR is three hex
bytes, an MSL one, and a TAR may appear once (a duplicate is refused). A
v3.9.1 preset's card-wide `spi1`/`tar`/`uiccTar`/`usimTar` convert into the
three role entries, the `spi1` as their MSL. Operations **derive a packet's
SPI1 from the MSL of its TAR**; a request naming a TAR outside the preset must
pass `spi1` itself, otherwise it is refused with `400` and a hint.
**Counters.** The store is the source of truth for the SCP80 counters, one per **Counters.** The store is the source of truth for the SCP80 counters, one per
keyset ("a dedicated counter shall be associated to each key version", keyset ("a dedicated counter shall be associated to each key version",
Annex A.1 — the spec's wording). The operations below accept a `preset_id` and **persist the Annex A.1 — the spec's wording). The operations below accept a `preset_id` and **persist the
@@ -232,7 +250,7 @@ is not defined in preset '…' - add it in the Cards tab"}`), and so is a
KIc/KID number mismatch. The Cards tab's export/import uses this API; the KIc/KID number mismatch. The Cards tab's export/import uses this API; the
import also accepts presets exported by the older localStorage-based builds. import also accepts presets exported by the older localStorage-based builds.
- `GET /api/presets` — `{"path": "…", "count": 2, "version": 2, "presets": [{…}]}` - `GET /api/presets` — `{"path": "…", "count": 2, "version": 3, "presets": [{…}]}`
(card-free: it answers while a long card operation runs). (card-free: it answers while a long card operation runs).
- `POST /api/presets` — body = the preset fields (`keysets` included); returns - `POST /api/presets` — body = the preset fields (`keysets` included); returns
`{"ok": true, "preset": {…}}`. A duplicate ICCID (digits, spaced or raw EF `{"ok": true, "preset": {…}}`. A duplicate ICCID (digits, spaced or raw EF
@@ -265,7 +283,8 @@ the packet's, TS 102 225 §5.2 Table 3), which is why this probe exists.
``` ```
All fields except `preset_id` are optional: `kvn` picks a keyset (the first All fields except `preset_id` are optional: `kvn` picks a keyset (the first
otherwise), `tar`/`spi1`/`spi2` default to the preset's values, `cntr` to the otherwise), `tar` defaults to the preset's ISD entry and `spi1` to that TAR's
MSL, `spi2` to `01`, `cntr` to the
keyset's counter, `ceiling` to `FFFFFFFF` (must stay below `FFFFFFFFFF`) and keyset's counter, `ceiling` to `FFFFFFFF` (must stay below `FFFFFFFFFF`) and
`max_attempts` to 40 (clamped to 1–64). The probe always sends the read-only `max_attempts` to 40 (clamped to 1–64). The probe always sends the read-only
`SELECT MF` - the counter advances on the packet's security acceptance whatever `SELECT MF` - the counter advances on the packet's security acceptance whatever
@@ -286,10 +305,13 @@ first attempt instead of hammering the card.
Probe which OTA applications the card has registered: one **secured packet per Probe which OTA applications the card has registered: one **secured packet per
TAR** (the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1 unless TAR** (the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1 unless
a `tars` list is given), each carrying a harmless C-APDU (`SELECT MF` by a `tars` list is given), each carrying a harmless C-APDU (`SELECT MF` by
default), with the preset's keyset and SPI `16/01` (counter check + PoR). default), with the preset's keyset. Each checked TAR uses **its own MSL** when
the preset carries the entry, otherwise the request's `spi1`; a TAR that
resolves to neither is refused with a hint **before any packet is sent**. The
resolved SPI1 must carry a counter check (b5b4 ≠ 00) and ask for a PoR.
Every accepted packet consumes a counter, which is persisted into the keyset Every accepted packet consumes a counter, which is persisted into the keyset
like any other operation, so the probe is refused when the SPI1 has no counter like any other operation, so the probe is refused when the SPI1 has no counter
check (b5b4 = 00) or the named preset does not define the keyset number. check or the named preset does not define the keyset number.
```json ```json
{"preset_id": "…", "kic": "25", "kid": "25", "kicKey": "…", "kidKey": "…", {"preset_id": "…", "kic": "25", "kid": "25", "kicKey": "…", "kidKey": "…",
@@ -299,7 +321,8 @@ check (b5b4 = 00) or the named preset does not define the keyset number.
**Response:** `{"success": true, "registered": N, "total": M, **Response:** `{"success": true, "registered": N, "total": M,
"final_cntr": "…", "kvn": 2, "spi1": "16", "spi2": "01", "final_cntr": "…", "kvn": 2, "spi1": "16", "spi2": "01",
"results": [{"tar": "000000", "label": "Issuer Security Domain (compact)", "results": [{"tar": "000000", "label": "Issuer Security Domain (compact)",
"verdict": "registered", "sw": "9000", "por_status": "por_ok", "spi1": "16", "verdict": "registered", "sw": "9000",
"por_status": "por_ok",
"por_sw": "6D00", "por_data": ""}], "steps": [...]}` "por_sw": "6D00", "por_data": ""}], "steps": [...]}`
Verdicts: `registered` (ENVELOPE 9000 + a PoR carrying the application's own Verdicts: `registered` (ENVELOPE 9000 + a PoR carrying the application's own
@@ -423,6 +446,13 @@ segments are sent in order. A packet that would need more than 5 segments
is refused (the card's concatenation buffer is the limit). With `sp` a is refused (the card's concatenation buffer is the limit). With `sp` a
pre-built packet is delivered the same way. pre-built packet is delivered the same way.
**SPI1 and the TAR's MSL.** With a `preset_id` the packet's SPI1 is derived
from the MSL of its `tar` (TS 102 226 §8.2.1.3.2.4) unless the request passes
`spi1` — an explicit value wins (a hand send may deliberately ask for more
security than the minimum), and a value below the TAR's MSL is reported as
`"warning"` in the response (numeric compare). A TAR the preset does not
carry, without an explicit `spi1`, is refused with `400` and a hint.
The card may answer with PoR status `actual_response_sms_submit` (`0x0B`): The card may answer with PoR status `actual_response_sms_submit` (`0x0B`):
the real response (a big GET STATUS listing, for example) then arrives as one the real response (a big GET STATUS listing, for example) then arrives as one
or more proactive SEND SHORT MESSAGE commands. The server captures those or more proactive SEND SHORT MESSAGE commands. The server captures those
@@ -590,9 +620,12 @@ suspended; only `/api/test/*`, `/api/status`, `/api/poll-status`,
"checks": [{"kind": "text", "value": "hello"}], "checks": [{"kind": "text", "value": "hello"}],
"respond": {"result": "ok"}} "respond": {"result": "ok"}}
]}, ]},
"preset": {"name": "lab card", "kic": "15", "kid": "15", "kicKey": "...", "preset": {"name": "lab card",
"kidKey": "...", "counter": "0000000A", "tar": "B00000", "keysets": [{"kic": "15", "kid": "15", "kicKey": "...", "kidKey": "...",
"spi1": "16", "spi2": "01"}} "cntr": "0000000A"}],
"tars": [{"role": "isd", "tar": "000000", "msl": "16"},
{"role": "uiccRfm", "tar": "B00000", "msl": "16"},
{"role": "usimRfm", "tar": "B00001", "msl": "16"}]}}
``` ```
**Action steps** (`type: "action"`): `kind` is `envelope` (`event`, `data`), **Action steps** (`type: "action"`): `kind` is `envelope` (`event`, `data`),
+8 -8
View File
@@ -42,7 +42,7 @@
<h3 id="interface" class="text-lg font-medium mb-2">1.1 Интерфейс</h3> <h3 id="interface" class="text-lg font-medium mb-2">1.1 Интерфейс</h3>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Шапка</strong> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>. Рядом с заголовком небольшой индикатор показывает состояние сервера/карты (серая точка = подключение, красная = нет сервера, значки карты = нет карты / инициализация / карта готова), ICCID подключённой карты, компактный значок <strong>ADM ✓</strong> (зелёный, администраторский PIN подтверждён) или <strong>ADM ✗</strong> (красный, не подтверждён) для текущей сессии карты — со значком ключа в конце (<strong>ADM ✓ ⚿</strong>), если в пресете карты с этим ICCID есть ключ ADM — и серые маркеры <strong>SCP80</strong> / <strong>SCP81</strong>, появляющиеся, когда в этом пресете заполнены все поля SCP80 (KIc/KID, SPI1/SPI2, счётчик и ключи) или SCP81 (PSK identity/ключ).</li> <li><strong>Шапка</strong> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>. Рядом с заголовком небольшой индикатор показывает состояние сервера/карты (серая точка = подключение, красная = нет сервера, значки карты = нет карты / инициализация / карта готова), ICCID подключённой карты, компактный значок <strong>ADM ✓</strong> (зелёный, администраторский PIN подтверждён) или <strong>ADM ✗</strong> (красный, не подтверждён) для текущей сессии карты — со значком ключа в конце (<strong>ADM ✓ ⚿</strong>), если в пресете карты с этим ICCID есть ключ ADM — и серые маркеры <strong>SCP80</strong> / <strong>SCP81</strong>, появляющиеся, когда в этом пресете заполнены SCP80 (keyset и три записи TAR с их MSL) или SCP81 (PSK identity/ключ).</li>
<li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li> <li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li>
<li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>RAM/GP</strong>, <strong>Expanded Script</strong>, <strong>&laquo;Пуш/триггер&raquo;</strong>, <strong>&laquo;Разбор&raquo;</strong> (<strong>«Разбор C-APDU»</strong>, <strong>«Разбор R-APDU»</strong>)), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>RAM</strong>, <strong>TAR-сканер</strong>), <strong>SCP81</strong> (<strong>&laquo;Слушатель&raquo;</strong>, <strong>&laquo;Скрипты&raquo;</strong>), <strong>&laquo;Карты&raquo;</strong>, <strong>&laquo;Профайлер&raquo;</strong> (вкладки <strong>&laquo;Профили&raquo;</strong>, <strong>&laquo;Снимки карт&raquo;</strong>, <strong>&laquo;Пользовательские файлы&raquo;</strong>), <strong>&laquo;Картридер&raquo;</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>&laquo;Симулятор&raquo;</strong>.</li> <li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>RAM/GP</strong>, <strong>Expanded Script</strong>, <strong>&laquo;Пуш/триггер&raquo;</strong>, <strong>&laquo;Разбор&raquo;</strong> (<strong>«Разбор C-APDU»</strong>, <strong>«Разбор R-APDU»</strong>)), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>RAM</strong>, <strong>TAR-сканер</strong>), <strong>SCP81</strong> (<strong>&laquo;Слушатель&raquo;</strong>, <strong>&laquo;Скрипты&raquo;</strong>), <strong>&laquo;Карты&raquo;</strong>, <strong>&laquo;Профайлер&raquo;</strong> (вкладки <strong>&laquo;Профили&raquo;</strong>, <strong>&laquo;Снимки карт&raquo;</strong>, <strong>&laquo;Пользовательские файлы&raquo;</strong>), <strong>&laquo;Картридер&raquo;</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>&laquo;Симулятор&raquo;</strong>.</li>
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка &laquo;Профайлер&raquo; открывает &sect;7).</li> <li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка &laquo;Профайлер&raquo; открывает &sect;7).</li>
@@ -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 немедленно останавливается. Карта не сообщает свой счётчик (поле CNTR в PoR — копия счётчика пакета), поэтому в уведомлении есть ограниченный пробник <strong>«Синхронизировать счётчик»</strong> — read-only команда с возрастающими значениями счётчика (шаг удваивается, потолок 32 бита, не более 40 пакетов), пока карта не примет одно; затем сохраняется принятое+1 — и кнопка <strong>К пресету</strong> для ручного повышения; больше ничего автоматически не записывается.</p> <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 немедленно останавливается. Карта не сообщает свой счётчик (поле CNTR в PoR — копия счётчика пакета), поэтому в уведомлении есть ограниченный пробник <strong>«Синхронизировать счётчик»</strong> — read-only команда с возрастающими значениями счётчика (шаг удваивается, потолок 32 бита, не более 40 пакетов), пока карта не примет одно; затем сохраняется принятое+1 — и кнопка <strong>К пресету</strong> для ручного повышения; больше ничего автоматически не записывается.</p>
<h3 id="secured-packet" class="text-lg font-medium mb-2">3.1 Secured Packet</h3> <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). Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets.</p> <p class="mb-2">Собирает защищённые пакеты SCP80 по ETSI TS 102 225. Селектор <strong>Keyset</strong> рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик; отправка отклоняется, если в пресете нет такого номера keyset или KIc/KID несут разные номера (TS 102 225 A.2). Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets. Поле TAR предлагает записи TAR пресета (с описанием и MSL); при применении пресета TAR операции подставляется, а SPI1 берётся из его <strong>MSL</strong> (минимальный SPI1, TS 102 226 &sect;8.2.1.3.2.4 &mdash; карта проверяет его до обработки защиты и отвечает <code class="font-mono text-sm">0A</code>, если SPI1 пакета ниже). Значение ниже MSL показывает жёлтое предупреждение под формой; введённый вручную TAR сохраняет SPI1 оператора.</p>
<h4 id="packet-structure" class="font-medium mb-1">Структура пакета</h4> <h4 id="packet-structure" class="font-medium mb-1">Структура пакета</h4>
<table class="w-full text-sm mb-3 border-collapse"> <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> <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> <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> <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 берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets. Селектор <strong>Формат</strong> фиксирует формат удалённой команды: <strong>Авто (определение)</strong> опрашивает read-only команду GET STATUS в компактном виде и в обоих expanded-кодированиях и оставляет первое, которое карта выполнила; <strong>Компактный</strong> отправляет голую C-APDU; <strong>Expanded (AA, определённая длина)</strong> / <strong>Expanded (AE 80, неопределённая длина)</strong> заворачивают её в шаблон Command Scripting из TS 102 226 &sect;5.2.1 (на живой карте выполняются обе; шаблон, у которого заявленная длина не совпадает с телом, получает Bad format TLV — операция показывает это как ошибку шага). Запросы листингов и реестра следуют этому формату: компактные листинги несут сцепленный GET RESPONSE, а expanded-прогон отправляет те же запросы P2=00 обёрнутыми и без цепочки (форма эталонного загрузчика TCA; Le=&rsquo;00&rsquo; возвращает листинг сразу), и запрос реестра, отклонённый картой в текущем формате, повторяется в остальных — отчёт называет тот, который сработал. Блок ресурсов карты также называет предполагаемый <strong>релиз ETSI</strong> карты (версия <code class="font-mono text-sm">uicc.toolkit</code> &rarr; REL-N, то же сопоставление, что и в анализе CAP; показывается, только если расширенный реестр вернул версии).</p> <p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Пресет карты со вкладки <strong>Карты</strong> обеспечивает ключи, таблицу TAR и счётчик (RAM использует <strong>ISD TAR</strong> пресета, по умолчанию <code class="font-mono text-sm">000000</code>; SPI1 берётся из MSL этого TAR). Селектор <strong>Keyset</strong> рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets. Селектор <strong>Формат</strong> фиксирует формат удалённой команды: <strong>Авто (определение)</strong> опрашивает read-only команду GET STATUS в компактном виде и в обоих expanded-кодированиях и оставляет первое, которое карта выполнила; <strong>Компактный</strong> отправляет голую C-APDU; <strong>Expanded (AA, определённая длина)</strong> / <strong>Expanded (AE 80, неопределённая длина)</strong> заворачивают её в шаблон Command Scripting из TS 102 226 &sect;5.2.1 (на живой карте выполняются обе; шаблон, у которого заявленная длина не совпадает с телом, получает Bad format TLV — операция показывает это как ошибку шага). Запросы листингов и реестра следуют этому формату: компактные листинги несут сцепленный GET RESPONSE, а expanded-прогон отправляет те же запросы P2=00 обёрнутыми и без цепочки (форма эталонного загрузчика TCA; Le=&rsquo;00&rsquo; возвращает листинг сразу), и запрос реестра, отклонённый картой в текущем формате, повторяется в остальных — отчёт называет тот, который сработал. Блок ресурсов карты также называет предполагаемый <strong>релиз ETSI</strong> карты (версия <code class="font-mono text-sm">uicc.toolkit</code> &rarr; REL-N, то же сопоставление, что и в анализе CAP; показывается, только если расширенный реестр вернул версии).</p>
<h4 id="ram-operations" class="font-medium mb-1">Операции</h4> <h4 id="ram-operations" class="font-medium mb-1">Операции</h4>
<table class="w-full text-sm mb-3 border-collapse"> <table class="w-full text-sm mb-3 border-collapse">
@@ -340,13 +340,13 @@
</section> </section>
<section class="mb-10"> <section class="mb-10">
<h3 id="tar-probe" class="text-lg font-medium mb-2">3.3 TAR-сканер</h3> <h3 id="tar-probe" class="text-lg font-medium mb-2">3.3 TAR-сканер</h3>
<p class="mb-2">Третья пилюля вкладки SCP80 показывает, <strong>какие OTA-приложения зарегистрированы на карте</strong>: отправляется <strong>по одному защищённому пакету на каждый выбранный TAR</strong> &mdash; стандартные значения из TS 101 220 V18.3.0 Annex D, Table D.1, каждая строка несёт документ-источник &mdash; с безобидной C-APDU (по умолчанию <code class="font-mono text-sm">SELECT MF</code>), пресетом и keyset этого представления, SPI <code class="font-mono text-sm">16/01</code> (проверка счётчика + PoR). Кнопка <strong>Execute</strong> недоступна, пока не выбран пресет карты и один из его keysets.</p> <p class="mb-2">Третья пилюля вкладки SCP80 показывает, <strong>какие OTA-приложения зарегистрированы на карте</strong>: отправляется <strong>по одному защищённому пакету на каждый выбранный TAR</strong> &mdash; стандартные значения из TS 101 220 V18.3.0 Annex D, Table D.1, каждая строка несёт документ-источник &mdash; с безобидной C-APDU (по умолчанию <code class="font-mono text-sm">SELECT MF</code>), пресетом и keyset этого представления &mdash; каждый проверяемый TAR использует свой <strong>MSL</strong>, если запись есть в пресете, иначе SPI1 из общей формы; TAR, для которого нет ни того, ни другого, отклоняется до отправки. Разрешённый SPI1 должен иметь проверку счётчика (b5b4 &ne; 00) и запрашивать PoR. Кнопка <strong>Execute</strong> недоступна, пока не выбран пресет карты и один из его keysets.</p>
<p class="mb-2">Список предзаполнен стандартными TAR (все отмечены; кнопки <strong>Выбрать все</strong> / <strong>Снять все</strong> переключают быстро), свои TAR добавляются кнопкой <strong>Добавить TAR</strong> (три hex-байта и необязательное описание). Пользовательские записи хранятся в <code class="font-mono text-sm">localStorage</code> по возможности &mdash; их потеря безвредна.</p> <p class="mb-2">Список предзаполнен стандартными TAR (все отмечены; кнопки <strong>Выбрать все</strong> / <strong>Снять все</strong> переключают быстро), свои TAR добавляются кнопкой <strong>Добавить TAR</strong> (три hex-байта и необязательное описание). Пользовательские записи хранятся в <code class="font-mono text-sm">localStorage</code> по возможности &mdash; их потеря безвредна.</p>
<p class="mb-2">Каждый TAR классифицируется по ответу карты: <strong>зарегистрировано</strong> (ENVELOPE 9000 + PoR со SW и данными приложения), <strong>принято, нет ответа</strong> (PoR без R-APDU), <strong>принято, нет PoR</strong> (пакет молча отброшен) и <strong>отклонено</strong> (ENVELOPE не 9000, обычно <code class="font-mono text-sm">6200</code> для TAR без зарегистрированного приложения). Таблица результата появляется в окне операции; строка под кнопкой Execute показывает <code class="font-mono text-sm">зарегистрировано N / M</code>.</p> <p class="mb-2">Каждый TAR классифицируется по ответу карты: <strong>зарегистрировано</strong> (ENVELOPE 9000 + PoR со SW и данными приложения), <strong>принято, нет ответа</strong> (PoR без R-APDU), <strong>принято, нет PoR</strong> (пакет молча отброшен) и <strong>отклонено</strong> (ENVELOPE не 9000, обычно <code class="font-mono text-sm">6200</code> для TAR без зарегистрированного приложения). Таблица результата появляется в окне операции; строка под кнопкой Execute показывает <code class="font-mono text-sm">зарегистрировано N / M</code>.</p>
<p class="mb-2">Каждый принятый картой пакет <strong>расходует счётчик</strong>, который сохраняется в keyset пресета &mdash; проба отклоняется, если в SPI1 нет проверки счётчика или в пресете нет такого номера keyset. На живой карте: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> и <code class="font-mono text-sm">B00001</code> зарегистрированы (приложения компактного формата), расширенный формат, USAT, мультиплексирование и CASD отклонены с <code class="font-mono text-sm">6200</code>.</p> <p class="mb-2">Каждый принятый картой пакет <strong>расходует счётчик</strong>, который сохраняется в keyset пресета &mdash; проба отклоняется, если в разрешённом SPI1 нет проверки счётчика или в пресете нет такого номера keyset. На живой карте: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> и <code class="font-mono text-sm">B00001</code> зарегистрированы (приложения компактного формата), расширенный формат, USAT, мультиплексирование и CASD отклонены с <code class="font-mono text-sm">6200</code>.</p>
<h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка &laquo;Карты&raquo;</h2> <h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка &laquo;Карты&raquo;</h2>
<p class="mb-2">Хранит пресеты карт на сервере (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; переопределяется <code class="font-mono text-sm">--card-presets</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, слушатель SCP81 HTTP OTA — находить PSK-ключи, а счётчики SCP80, израсходованные операцией, сохранялись самой операцией &mdash; закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Пресет содержит <strong>несколько keysets</strong> &mdash; номер keyset задаётся нибблом <code class="font-mono text-sm">b8..b5</code> его KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (01&ndash;0F; A.2 отвергает несовпадение), и у каждого keyset на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по keysets. В форме keysets редактируются строками &mdash; по одной на номер, с кнопками <strong>Добавить keyset</strong> / <strong>Удалить keyset</strong>; номер в строке вычисляется из байта KIc/KID, а <code class="font-mono text-sm">00</code> («без защиты») не принимается как keyset. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и пресеты старых сборок — плоские и с keysets). &laquo;Карты&raquo; — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх представлениях SCP80; если подключённая карта не совпадает <strong>ни с одним</strong> пресетом &mdash; или выбранный пресет принадлежит другой карте &mdash; все три представления SCP80 показывают жёлтое предупреждение над выбором пресета. В списке подсвечивается пресет подключённой карты. Поля формы сгруппированы в два блока с рамкой: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, TAR-ы ISD/UICC RFM/ADF RFM, счётчик и ключи) и <strong>SCP81 (HTTP OTA)</strong> (пара PSK identity/ключ с пояснением о выборе ключа). Необязательное поле <strong>ADM</strong> находится в верхней строке и сохраняется для последующего использования файловым менеджером. В шапке отображаются серые маркеры <strong>SCP80</strong>/<strong>SCP81</strong> и значок ключа на бейдже ADM, когда в пресете с ICCID подключённой карты эти поля заполнены.</p> <p class="mb-2">Хранит пресеты карт на сервере (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; переопределяется <code class="font-mono text-sm">--card-presets</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, слушатель SCP81 HTTP OTA — находить PSK-ключи, а счётчики SCP80, израсходованные операцией, сохранялись самой операцией &mdash; закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Пресет содержит <strong>несколько keysets</strong> &mdash; номер keyset задаётся нибблом <code class="font-mono text-sm">b8..b5</code> его KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (01&ndash;0F; A.2 отвергает несовпадение), и у каждого keyset на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по keysets. В форме keysets редактируются строками &mdash; по одной на номер, с кнопками <strong>Добавить keyset</strong> / <strong>Удалить keyset</strong>; номер в строке вычисляется из байта KIc/KID, а <code class="font-mono text-sm">00</code> («без защиты») не принимается как keyset. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и пресеты старых сборок — плоские и с keysets). &laquo;Карты&raquo; — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх представлениях SCP80; если подключённая карта не совпадает <strong>ни с одним</strong> пресетом &mdash; или выбранный пресет принадлежит другой карте &mdash; все три представления SCP80 показывают жёлтое предупреждение над выбором пресета. В списке подсвечивается пресет подключённой карты. Поля формы сгруппированы в два блока с рамкой: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (keysets, таблица TAR — три обязательные роли ISD / UICC RFM / ADF RFM, у каждой свой MSL, плюс дополнительные TAR с необязательным описанием — и счётчики) и <strong>SCP81 (HTTP OTA)</strong> (пара PSK identity/ключ с пояснением о выборе ключа). Необязательное поле <strong>ADM</strong> находится в верхней строке и сохраняется для последующего использования файловым менеджером. В шапке отображаются серые маркеры <strong>SCP80</strong>/<strong>SCP81</strong> и значок ключа на бейдже ADM, когда в пресете с ICCID подключённой карты эти поля заполнены.</p>
<table class="w-full text-sm mb-3 border-collapse"> <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></tr></thead> <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></tr></thead>
<tbody> <tbody>
@@ -354,7 +354,7 @@
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Опциональный идентификатор карты. Кнопка <strong>С карты</strong> подставляет EF.ICCID подключённой карты (активна, только когда ICCID читается); при сохранении ICCID, уже присутствующий в списке, отклоняется (сравнение без пробелов и с учётом сырой hex-формы, конфликтующий пресет называется)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Опциональный идентификатор карты. Кнопка <strong>С карты</strong> подставляет EF.ICCID подключённой карты (активна, только когда ICCID читается); при сохранении ICCID, уже присутствующий в списке, отклоняется (сравнение без пробелов и с учётом сырой hex-формы, конфликтующий пресет называется)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADM</td><td class="py-1 px-2">Необязательный администраторский PIN (hex или до 8 ASCII-цифр), сохраняется в пресете для последующего использования файловым менеджером; представлениями SCP80/SCP81 не используется</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADM</td><td class="py-1 px-2">Необязательный администраторский PIN (hex или до 8 ASCII-цифр), сохраняется в пресете для последующего использования файловым менеджером; представлениями SCP80/SCP81 не используется</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Индикаторы ключа и алгоритма (например, 15 = индекс 1, 3DES-CBC2; x2 = AES); задаются вместе с ключами</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Индикаторы ключа и алгоритма (например, 15 = индекс 1, 3DES-CBC2; x2 = AES); задаются вместе с ключами</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TARs / MSL</td><td class="py-1 px-2">Таблица TAR: три обязательные роли (ISD / UICC RFM / ADF RFM &mdash; предзаполнены) и дополнительные TAR, у каждого свой MSL (минимальный SPI1, TS 102 226 &sect;8.2.1.3.2.4). SPI1 пакета берётся из MSL его TAR; у свободных записей есть необязательное описание</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ISD TAR</td><td class="py-1 px-2">TAR Issuer Security Domain (TS 101 220, приложение D); используется для операций RAM/GP (представление SCP80 RAM, цепочки RAM/GP); по умолчанию по спецификации <code class="font-mono text-sm">000000</code>, подставляется при пустом значении</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ISD TAR</td><td class="py-1 px-2">TAR Issuer Security Domain (TS 101 220, приложение D); используется для операций RAM/GP (представление SCP80 RAM, цепочки RAM/GP); по умолчанию по спецификации <code class="font-mono text-sm">000000</code>, подставляется при пустом значении</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">UICC RFM TAR</td><td class="py-1 px-2">TAR UICC Shared File System RFM (TS 102 226 &sect;7.2, TS 101 220 приложение D): MF выбирается неявно и доступны только файлы под MF (ADF недоступны); используется представлением SIM RFM; по умолчанию <code class="font-mono text-sm">B00000</code></td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">UICC RFM TAR</td><td class="py-1 px-2">TAR UICC Shared File System RFM (TS 102 226 &sect;7.2, TS 101 220 приложение D): MF выбирается неявно и доступны только файлы под MF (ADF недоступны); используется представлением SIM RFM; по умолчанию <code class="font-mono text-sm">B00000</code></td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADF RFM TAR</td><td class="py-1 px-2">TAR ADF RFM (TS 102 226 &sect;7.3, TS 101 220 приложение D): ADF выбирается неявно, TAR привязан к AID приложения &mdash; ADF.USIM в представлении USIM RFM; по умолчанию <code class="font-mono text-sm">B00001</code></td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADF RFM TAR</td><td class="py-1 px-2">TAR ADF RFM (TS 102 226 &sect;7.3, TS 101 220 приложение D): ADF выбирается неявно, TAR привязан к AID приложения &mdash; ADF.USIM в представлении USIM RFM; по умолчанию <code class="font-mono text-sm">B00001</code></td></tr>
@@ -364,7 +364,7 @@
<tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex-символа (16 байт) для PSK TLS-слушателя; ключ выбирается по идентификатору, который предъявляет карта</td></tr> <tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex-символа (16 байт) для PSK TLS-слушателя; ключ выбирается по идентификатору, который предъявляет карта</td></tr>
</tbody> </tbody>
</table> </table>
<p class="text-sm mb-3">Столбец <strong>SCP81</strong> показывает, задана ли в пресете рабочая пара PSK: <strong>&#10003;</strong> (зелёный) — идентификатор и ключ заданы; <strong>&#9888;</strong> (жёлтый) — задано только одно из двух, и слушатель такой пресет игнорирует; <strong>&mdash;</strong> — PSK нет. Идентификатор и ключ PSK задаются вместе. Кнопка <strong>Редактировать</strong> загружает пресет в форму (кнопка Add становится <strong>Сохранить</strong>; <strong>Отмена</strong> очищает форму), поэтому поля можно менять без повторного ввода карты; <strong>Удалить</strong> удаляет строку. Обмен пресетами: <strong>Экспорт в файл</strong> для выгрузки и <strong>Импорт из файла</strong> для загрузки. Выбранный пресет автоматически заполняет форму Secured Packet и устанавливает TAR для операции (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; ADF RFM TAR); TAR, введённый в форму Secured Packet вручную, сохраняется при отправке - счётчик, ключи и SPI по-прежнему берутся из пресета; изменения сразу передаются работающему слушателю SCP81.</p> <p class="text-sm mb-3">Столбец <strong>SCP81</strong> показывает, задана ли в пресете рабочая пара PSK: <strong>&#10003;</strong> (зелёный) — идентификатор и ключ заданы; <strong>&#9888;</strong> (жёлтый) — задано только одно из двух, и слушатель такой пресет игнорирует; <strong>&mdash;</strong> — PSK нет. Идентификатор и ключ PSK задаются вместе. Кнопка <strong>Редактировать</strong> загружает пресет в форму (кнопка Add становится <strong>Сохранить</strong>; <strong>Отмена</strong> очищает форму), поэтому поля можно менять без повторного ввода карты; <strong>Удалить</strong> удаляет строку. Обмен пресетами: <strong>Экспорт в файл</strong> для выгрузки и <strong>Импорт из файла</strong> для загрузки. Выбранный пресет автоматически заполняет форму Secured Packet и устанавливает TAR для операции (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; ADF RFM TAR); TAR, введённый в форму Secured Packet вручную, сохраняется при отправке - счётчик, ключи и MSL этого TAR по-прежнему берутся из пресета; SPI1 берётся из MSL, а меньшее значение даёт предупреждение (сервер тоже возвращает его как <code class="font-mono text-sm">warning</code>); изменения сразу передаются работающему слушателю SCP81.</p>
</section> </section>
<section class="mb-10"> <section class="mb-10">
+8 -8
View File
@@ -42,7 +42,7 @@
<h3 id="interface" class="text-lg font-medium mb-2">1.1 Interface</h3> <h3 id="interface" class="text-lg font-medium mb-2">1.1 Interface</h3>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle. Next to the title a small status indicator shows the server/card state (gray dot = connecting, red dot = no server connection, card icons = no card / equipping / equipped), the equipped card's <strong>ICCID</strong> digits, a compact <strong>ADM ✓</strong> (green, administrator PIN verified) or <strong>ADM ✗</strong> (red, not verified) badge for the current card session — with a trailing key glyph (<strong>ADM ✓ ⚿</strong>) when the card preset matching that ICCID carries an ADM key — and gray <strong>SCP80</strong> / <strong>SCP81</strong> markers that appear when that preset has complete SCP80 (KIc/KID, SPI1/SPI2, counter and keys) or SCP81 (PSK identity/key) settings.</li> <li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle. Next to the title a small status indicator shows the server/card state (gray dot = connecting, red dot = no server connection, card icons = no card / equipping / equipped), the equipped card's <strong>ICCID</strong> digits, a compact <strong>ADM ✓</strong> (green, administrator PIN verified) or <strong>ADM ✗</strong> (red, not verified) badge for the current card session — with a trailing key glyph (<strong>ADM ✓ ⚿</strong>) when the card preset matching that ICCID carries an ADM key — and gray <strong>SCP80</strong> / <strong>SCP81</strong> markers that appear when that preset has complete SCP80 (a keyset plus the three TAR entries with their MSLs) or SCP81 (PSK identity/key) settings.</li>
<li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li> <li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li>
<li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>RAM/GP</strong>, <strong>Expanded Script</strong>, <strong>Push commands</strong>, <strong>Parser</strong> (<strong>C-APDU Parser</strong>, <strong>R-APDU parser</strong>)), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>RAM</strong>, <strong>TAR probe</strong>), <strong>SCP81</strong> (<strong>Listener</strong>, <strong>Scripts</strong>), <strong>Cards</strong>, <strong>Profiler</strong> (list tabs <strong>Profiles</strong>, <strong>Card snapshots</strong>, <strong>Custom files</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), and <strong>Simulator</strong>.</li> <li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>RAM/GP</strong>, <strong>Expanded Script</strong>, <strong>Push commands</strong>, <strong>Parser</strong> (<strong>C-APDU Parser</strong>, <strong>R-APDU parser</strong>)), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>RAM</strong>, <strong>TAR probe</strong>), <strong>SCP81</strong> (<strong>Listener</strong>, <strong>Scripts</strong>), <strong>Cards</strong>, <strong>Profiler</strong> (list tabs <strong>Profiles</strong>, <strong>Card snapshots</strong>, <strong>Custom files</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), and <strong>Simulator</strong>.</li>
<li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler tab opens &sect;7).</li> <li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler tab opens &sect;7).</li>
@@ -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> <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> <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). It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected.</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. The start button stays disabled until a card preset and one of its keysets are selected. The packet's TAR field suggests the preset's TAR entries (each with its description and MSL); applying a preset sets the operation's TAR and seeds the SPI1 from that TAR's <strong>MSL</strong> (Minimum SPI1, TS 102 226 &sect;8.2.1.3.2.4 &mdash; the card checks it before the security processing and answers <code class="font-mono text-sm">0A</code> when the packet's SPI1 is below it). A value below the MSL shows an amber warning under the form; a hand-typed TAR keeps the operator's SPI1.</p>
<h4 id="packet-structure" class="font-medium mb-1">Packet structure</h4> <h4 id="packet-structure" class="font-medium mb-1">Packet structure</h4>
<table class="w-full text-sm mb-3 border-collapse"> <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> <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. The card never reports its counter (the PoR&rsquo;s CNTR field is a copy of the packet&rsquo;s), so the notice offers a bounded <strong>Sync counter</strong> probe &mdash; a read-only command with increasing counter values (doubling steps, a 32-bit ceiling, at most 40 packets) until the card accepts one, then accepted+1 is stored &mdash; and a <strong>Go to preset</strong> button for raising the counter by hand; nothing else is written automatically.</p> <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. The card never reports its counter (the PoR&rsquo;s CNTR field is a copy of the packet&rsquo;s), so the notice offers a bounded <strong>Sync counter</strong> probe &mdash; a read-only command with increasing counter values (doubling steps, a 32-bit ceiling, at most 40 packets) until the card accepts one, then accepted+1 is stored &mdash; and a <strong>Go to preset</strong> button for raising the counter by hand; nothing else is written automatically.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3> <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. It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected. The <strong>Format</strong> selector pins the remote command format: <strong>Auto (detect)</strong> probes the read-only GET STATUS command compact and in both expanded codings and keeps the first that the card executes, <strong>Compact</strong> sends the bare C-APDU, and <strong>Expanded (AA, definite length)</strong> / <strong>Expanded (AE 80, indefinite length)</strong> wrap it in the TS 102 226 &sect;5.2.1 scripting template (both execute on the live ISD; a template whose declared lengths do not match its body draws a Bad format TLV, which the operation shows as the step failure). The listing and registry queries follow that format: compact listings keep the chained GET RESPONSE, while an expanded run sends the same P2=00 queries wrapped and unchained (the reference TCA loader&rsquo;s form, Le=&rsquo;00&rsquo; returns the listing directly), and a registry query the card refuses in the current format is retried in the other formats &mdash; the report names the one that served it. The card-resources block also names the card&rsquo;s inferred <strong>ETSI release</strong> (<code class="font-mono text-sm">uicc.toolkit</code> version &rarr; REL-N, the same mapping as the CAP analysis; shown only when the expanded registry returned the versions).</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 keys, the TAR table and the counter (RAM uses the preset's <strong>ISD TAR</strong>, <code class="font-mono text-sm">000000</code> by default; the SPI1 is derived from that TAR's MSL). 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. The start button stays disabled until a card preset and one of its keysets are selected. The <strong>Format</strong> selector pins the remote command format: <strong>Auto (detect)</strong> probes the read-only GET STATUS command compact and in both expanded codings and keeps the first that the card executes, <strong>Compact</strong> sends the bare C-APDU, and <strong>Expanded (AA, definite length)</strong> / <strong>Expanded (AE 80, indefinite length)</strong> wrap it in the TS 102 226 &sect;5.2.1 scripting template (both execute on the live ISD; a template whose declared lengths do not match its body draws a Bad format TLV, which the operation shows as the step failure). The listing and registry queries follow that format: compact listings keep the chained GET RESPONSE, while an expanded run sends the same P2=00 queries wrapped and unchained (the reference TCA loader&rsquo;s form, Le=&rsquo;00&rsquo; returns the listing directly), and a registry query the card refuses in the current format is retried in the other formats &mdash; the report names the one that served it. The card-resources block also names the card&rsquo;s inferred <strong>ETSI release</strong> (<code class="font-mono text-sm">uicc.toolkit</code> version &rarr; REL-N, the same mapping as the CAP analysis; shown only when the expanded registry returned the versions).</p>
<h4 id="ram-operations" class="font-medium mb-1">Operations</h4> <h4 id="ram-operations" class="font-medium mb-1">Operations</h4>
<table class="w-full text-sm mb-3 border-collapse"> <table class="w-full text-sm mb-3 border-collapse">
@@ -339,13 +339,13 @@
</section> </section>
<section class="mb-10"> <section class="mb-10">
<h3 id="tar-probe" class="text-lg font-medium mb-2">3.3 TAR probe</h3> <h3 id="tar-probe" class="text-lg font-medium mb-2">3.3 TAR probe</h3>
<p class="mb-2">The third SCP80 pill probes <strong>which OTA applications the card has registered</strong>: it sends <strong>one secured packet per selected TAR</strong> &mdash; the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1, each row carrying the document it comes from &mdash; with a harmless C-APDU (default <code class="font-mono text-sm">SELECT MF</code>) using the view's preset and keyset, SPI <code class="font-mono text-sm">16/01</code> (counter check + PoR). The <strong>Execute</strong> button stays disabled until a card preset and one of its keysets are selected.</p> <p class="mb-2">The third SCP80 pill probes <strong>which OTA applications the card has registered</strong>: it sends <strong>one secured packet per selected TAR</strong> &mdash; the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1, each row carrying the document it comes from &mdash; with a harmless C-APDU (default <code class="font-mono text-sm">SELECT MF</code>) using the view's preset and keyset &mdash; each checked TAR uses its own <strong>MSL</strong> when the preset carries the entry, otherwise the SPI1 from the shared form; a TAR resolving to neither is refused before anything is sent. The resolved SPI1 must carry a counter check (b5b4 &ne; 00) and request a PoR. The <strong>Execute</strong> button stays disabled until a card preset and one of its keysets are selected.</p>
<p class="mb-2">The list is pre-filled with the standard TARs (all checked; <strong>Select all</strong> / <strong>Select none</strong> switch them quickly) and custom TARs can be added with <strong>Add TAR</strong> (three hex bytes plus an optional description). Custom entries are kept in <code class="font-mono text-sm">localStorage</code> on a best-effort basis &mdash; losing them is harmless.</p> <p class="mb-2">The list is pre-filled with the standard TARs (all checked; <strong>Select all</strong> / <strong>Select none</strong> switch them quickly) and custom TARs can be added with <strong>Add TAR</strong> (three hex bytes plus an optional description). Custom entries are kept in <code class="font-mono text-sm">localStorage</code> on a best-effort basis &mdash; losing them is harmless.</p>
<p class="mb-2">Every TAR is classified from the card's answer: <strong>registered</strong> (ENVELOPE 9000 + a PoR carrying the application's own status word and data), <strong>accepted, no answer</strong> (a PoR without an R-APDU), <strong>accepted, no PoR</strong> (the packet was silently dropped) and <strong>refused</strong> (a non-9000 ENVELOPE, typically <code class="font-mono text-sm">6200</code> for a TAR without a registered application). The result table appears in the operation popup; the line under Execute summarises <code class="font-mono text-sm">registered N / M</code>.</p> <p class="mb-2">Every TAR is classified from the card's answer: <strong>registered</strong> (ENVELOPE 9000 + a PoR carrying the application's own status word and data), <strong>accepted, no answer</strong> (a PoR without an R-APDU), <strong>accepted, no PoR</strong> (the packet was silently dropped) and <strong>refused</strong> (a non-9000 ENVELOPE, typically <code class="font-mono text-sm">6200</code> for a TAR without a registered application). The result table appears in the operation popup; the line under Execute summarises <code class="font-mono text-sm">registered N / M</code>.</p>
<p class="mb-2">Each packet the card accepts <strong>consumes a counter</strong>, which is saved into the preset's keyset &mdash; the probe is refused when SPI1 has no counter check or the named preset does not define the keyset number. Observed on a live card: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> and <code class="font-mono text-sm">B00001</code> registered (compact-format applications), the expanded-format TARs, USAT, multiplexing and CASD refused with <code class="font-mono text-sm">6200</code>.</p> <p class="mb-2">Each packet the card accepts <strong>consumes a counter</strong>, which is saved into the preset's keyset &mdash; the probe is refused when the resolved SPI1 has no counter check or the named preset does not define the keyset number. Observed on a live card: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> and <code class="font-mono text-sm">B00001</code> registered (compact-format applications), the expanded-format TARs, USAT, multiplexing and CASD refused with <code class="font-mono text-sm">6200</code>.</p>
<h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Cards tab</h2> <h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Cards tab</h2>
<p class="mb-2">Stores card presets on the server (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; <code class="font-mono text-sm">--card-presets</code> 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 &mdash; a closed tab or a second browser window can no longer lose an increment. A preset carries <strong>several keysets</strong> &mdash; a keyset's number is the <code class="font-mono text-sm">b8..b5</code> nibble of its KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc and KID of a keyset must carry the same number (01&ndash;0F; A.2 rejects a mismatch), and each keyset has its own counter on the card (Annex A.1), so counters are tracked per keyset. In the form the keysets are edited as rows &mdash; one per number, with <strong>Add keyset</strong> / <strong>Remove keyset</strong>; each row shows the number derived from its KIc/KID byte, and <code class="font-mono text-sm">00</code> (no security) is not accepted as a 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 all three SCP80 views; when the equipped card matches <strong>no</strong> preset &mdash; or the selected preset belongs to another card &mdash; all three SCP80 views show an amber warning above the preset selector. The list highlights the equipped card's preset. The form groups the fields into two bordered blocks: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, the ISD/UICC RFM/ADF RFM TARs, counter and keys) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The optional <strong>ADM</strong> field sits in the top row and is stored for later use by the file manager. The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p> <p class="mb-2">Stores card presets on the server (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; <code class="font-mono text-sm">--card-presets</code> 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 &mdash; a closed tab or a second browser window can no longer lose an increment. A preset carries <strong>several keysets</strong> &mdash; a keyset's number is the <code class="font-mono text-sm">b8..b5</code> nibble of its KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc and KID of a keyset must carry the same number (01&ndash;0F; A.2 rejects a mismatch), and each keyset has its own counter on the card (Annex A.1), so counters are tracked per keyset. In the form the keysets are edited as rows &mdash; one per number, with <strong>Add keyset</strong> / <strong>Remove keyset</strong>; each row shows the number derived from its KIc/KID byte, and <code class="font-mono text-sm">00</code> (no security) is not accepted as a 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 all three SCP80 views; when the equipped card matches <strong>no</strong> preset &mdash; or the selected preset belongs to another card &mdash; all three SCP80 views show an amber warning above the preset selector. The list highlights the equipped card's preset. The form groups the fields into two bordered blocks: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (the keysets, the TAR table &mdash; the three mandatory role entries ISD / UICC RFM / ADF RFM, each with its MSL, plus further TARs with an optional description &mdash; and the counters) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The optional <strong>ADM</strong> field sits in the top row and is stored for later use by the file manager. The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p>
<table class="w-full text-sm mb-3 border-collapse"> <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">Description</th></tr></thead> <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">Description</th></tr></thead>
<tbody> <tbody>
@@ -353,7 +353,7 @@
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Optional card identifier. <strong>From card</strong> 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)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Optional card identifier. <strong>From card</strong> 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)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADM</td><td class="py-1 px-2">Optional administrator PIN (hex, or up to 8 ASCII digits), stored in the preset for later use by the file manager; not used by the SCP80/SCP81 views</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADM</td><td class="py-1 px-2">Optional administrator PIN (hex, or up to 8 ASCII digits), stored in the preset for later use by the file manager; not used by the SCP80/SCP81 views</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Key and algorithm indicators (e.g. 15 = index 1, 3DES-CBC2; x2 = AES), required together with the keys</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Key and algorithm indicators (e.g. 15 = index 1, 3DES-CBC2; x2 = AES), required together with the keys</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TARs / MSL</td><td class="py-1 px-2">The TAR table: the three role entries (ISD / UICC RFM / ADF RFM &mdash; mandatory, pre-settled) plus further TARs, each with its MSL (Minimum SPI1, TS 102 226 &sect;8.2.1.3.2.4). A packet's SPI1 is derived from its TAR's MSL; free entries carry an optional description</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ISD TAR</td><td class="py-1 px-2">Issuer Security Domain TAR (TS 101 220 Annex D); used for RAM/GP operations (SCP80 RAM view, RAM/GP chains); spec default <code class="font-mono text-sm">000000</code>, prefilled when empty</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ISD TAR</td><td class="py-1 px-2">Issuer Security Domain TAR (TS 101 220 Annex D); used for RAM/GP operations (SCP80 RAM view, RAM/GP chains); spec default <code class="font-mono text-sm">000000</code>, prefilled when empty</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">UICC RFM TAR</td><td class="py-1 px-2">UICC Shared File System RFM TAR (TS 102 226 &sect;7.2, TS 101 220 Annex D): the MF is implicitly selected and only files under the MF are accessible (no ADFs); used by the SIM RFM view; spec default <code class="font-mono text-sm">B00000</code></td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">UICC RFM TAR</td><td class="py-1 px-2">UICC Shared File System RFM TAR (TS 102 226 &sect;7.2, TS 101 220 Annex D): the MF is implicitly selected and only files under the MF are accessible (no ADFs); used by the SIM RFM view; spec default <code class="font-mono text-sm">B00000</code></td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADF RFM TAR</td><td class="py-1 px-2">ADF RFM TAR (TS 102 226 &sect;7.3, TS 101 220 Annex D): the ADF is implicitly selected and the TAR is linked to the application's AID &mdash; ADF.USIM in the USIM RFM view; spec default <code class="font-mono text-sm">B00001</code></td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADF RFM TAR</td><td class="py-1 px-2">ADF RFM TAR (TS 102 226 &sect;7.3, TS 101 220 Annex D): the ADF is implicitly selected and the TAR is linked to the application's AID &mdash; ADF.USIM in the USIM RFM view; spec default <code class="font-mono text-sm">B00001</code></td></tr>
@@ -363,7 +363,7 @@
<tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex chars (16 bytes) for the PSK TLS listener, picked by the identity the card presents</td></tr> <tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex chars (16 bytes) for the PSK TLS listener, picked by the identity the card presents</td></tr>
</tbody> </tbody>
</table> </table>
<p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair: <strong>&#10003;</strong> (green) — identity and key set; <strong>&#9888;</strong> (amber) — only one of the two, so the listener ignores the preset; <strong>&mdash;</strong> — no PSK. PSK identity and key must be set together. <strong>Edit</strong> loads a preset into the form (the Add button becomes <strong>Save</strong>; <strong>Cancel</strong> clears it) so fields can be changed without re-entering the card; <strong>Remove</strong> deletes the row. Presets can be shared with <strong>Export to file</strong> and restored with <strong>Import from file</strong>. The selected card preset auto-fills the Secured Packet form and sets the TAR for the operation (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; ADF RFM TAR); a TAR typed into the Secured Packet form by hand is kept when sending - the counter, keys and SPI are still re-read from the preset; edits are pushed into a running SCP81 listener automatically.</p> <p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair: <strong>&#10003;</strong> (green) — identity and key set; <strong>&#9888;</strong> (amber) — only one of the two, so the listener ignores the preset; <strong>&mdash;</strong> — no PSK. PSK identity and key must be set together. <strong>Edit</strong> loads a preset into the form (the Add button becomes <strong>Save</strong>; <strong>Cancel</strong> clears it) so fields can be changed without re-entering the card; <strong>Remove</strong> deletes the row. Presets can be shared with <strong>Export to file</strong> and restored with <strong>Import from file</strong>. The selected card preset auto-fills the Secured Packet form and sets the TAR for the operation (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; ADF RFM TAR); a TAR typed into the Secured Packet form by hand is kept when sending - the counter, keys and the TAR's MSL are still re-read from the preset; the SPI1 is seeded from the TAR's MSL and a lower value warns (the server returns it as a <code class="font-mono text-sm">warning</code> too); edits are pushed into a running SCP81 listener automatically.</p>
</section> </section>
<section class="mb-10"> <section class="mb-10">
+300 -81
View File
@@ -651,7 +651,8 @@
<div class="flex gap-2 mb-3"> <div class="flex gap-2 mb-3">
<div class="flex-1"> <div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">TAR (3 bytes)</label> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">TAR (3 bytes)</label>
<input id="sp-tar" oninput="spInvalidate()" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="B00001" maxlength="6" value="B00001"> <input id="sp-tar" oninput="spInvalidate()" list="sp-tar-list" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="B00001" maxlength="6" value="B00001">
<datalist id="sp-tar-list"></datalist>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Counter (5 bytes)</label> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Counter (5 bytes)</label>
@@ -669,6 +670,8 @@
</div> </div>
</div> </div>
<div id="sp-msl-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-xs text-amber-700 dark:text-amber-300"></div>
<div class="mb-3"> <div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">APDU (hex)</label> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">APDU (hex)</label>
<textarea id="sp-apdu" rows="3" oninput="spInvalidate()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"></textarea> <textarea id="sp-apdu" rows="3" oninput="spInvalidate()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"></textarea>
@@ -1590,7 +1593,7 @@
<div id="tab-cards" class="tab-content hidden"> <div id="tab-cards" class="tab-content hidden">
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries several keysets, each with its own number and counter.">Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries several keysets, each with its own number and counter.</p> <p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries several keysets, each with its own number and counter, and a TAR table — every TAR with its MSL (Minimum SPI1).">Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries several keysets, each with its own number and counter, and a TAR table — every TAR with its MSL (Minimum SPI1).</p>
<div class="flex gap-2 mb-3"> <div class="flex gap-2 mb-3">
<input id="cards-name" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Name (e.g. Foobar SIM)"> <input id="cards-name" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Name (e.g. Foobar SIM)">
<input id="cards-iccid" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ICCID (optional)"> <input id="cards-iccid" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ICCID (optional)">
@@ -1601,24 +1604,38 @@
</div> </div>
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3"> <fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="SCP80 (GSM 03.48, ETSI TS 102 225)">SCP80 (GSM 03.48, ETSI TS 102 225)</legend> <legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="SCP80 (GSM 03.48, ETSI TS 102 225)">SCP80 (GSM 03.48, ETSI TS 102 225)</legend>
<div class="flex gap-2 mb-2 flex-wrap items-end"> <div class="flex gap-3 mb-2 flex-wrap items-end">
<input id="cards-spi1" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI1" maxlength="2"> <div>
<input id="cards-spi2" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI2" maxlength="2"> <label for="cards-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400" data-l10n="ISD TAR">ISD TAR</label>
<div class="flex gap-2"> <input id="cards-tar" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" maxlength="6" value="000000">
<div> </div>
<label for="cards-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">ISD TAR</label> <div>
<input id="cards-tar" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" maxlength="6" value="000000"> <label for="cards-tar-msl" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400" data-l10n="MSL">MSL</label>
</div> <input id="cards-tar-msl" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" maxlength="2" value="16" data-l10n-title="MSL (Minimum SPI1, one hex byte)">
<div> </div>
<label for="cards-uicc-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">UICC RFM TAR</label> <div>
<input id="cards-uicc-tar" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" maxlength="6" value="B00000"> <label for="cards-uicc-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400" data-l10n="UICC RFM TAR">UICC RFM TAR</label>
</div> <input id="cards-uicc-tar" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" maxlength="6" value="B00000">
<div> </div>
<label for="cards-usim-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">ADF RFM TAR</label> <div>
<input id="cards-usim-tar" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" maxlength="6" value="B00001"> <label for="cards-uicc-tar-msl" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400" data-l10n="MSL">MSL</label>
</div> <input id="cards-uicc-tar-msl" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" maxlength="2" value="16" data-l10n-title="MSL (Minimum SPI1, one hex byte)">
</div>
<div>
<label for="cards-usim-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400" data-l10n="ADF RFM TAR">ADF RFM TAR</label>
<input id="cards-usim-tar" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" maxlength="6" value="B00001">
</div>
<div>
<label for="cards-usim-tar-msl" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400" data-l10n="MSL">MSL</label>
<input id="cards-usim-tar-msl" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" maxlength="2" value="16" data-l10n-title="MSL (Minimum SPI1, one hex byte)">
</div> </div>
</div> </div>
<p class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="Each TAR carries its MSL (Minimum SPI1) — the card checks it before the security processing (TS 102 226 8.2.1.3.2.4); the packet SPI1 is derived from it.">Each TAR carries its MSL (Minimum SPI1) — the card checks it before the security processing (TS 102 226 8.2.1.3.2.4); the packet SPI1 is derived from it.</p>
<div class="mb-1 flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-slate-400" data-l10n="More TARs — each with its MSL and an optional description">More TARs — each with its MSL and an optional description</span>
<button type="button" onclick="cardsTarAdd()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" data-l10n="Add TAR">Add TAR</button>
</div>
<div id="cards-tars"></div>
<div class="mb-1 flex items-center justify-between"> <div class="mb-1 flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-slate-400" data-l10n="OTA keysets — each with its own number and counter">OTA keysets — each with its own number and counter</span> <span class="text-xs text-gray-600 dark:text-slate-400" data-l10n="OTA keysets — each with its own number and counter">OTA keysets — each with its own number and counter</span>
<button type="button" onclick="cardsKeysetAdd()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" data-l10n="Add keyset">Add keyset</button> <button type="button" onclick="cardsKeysetAdd()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" data-l10n="Add keyset">Add keyset</button>
@@ -1640,9 +1657,7 @@
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Name">Name</th> <th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Name">Name</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="ICCID">ICCID</th> <th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="ICCID">ICCID</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Keysets (number: KIc/KID — counter)">Keysets (number: KIc/KID \u2014 counter)</th> <th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Keysets (number: KIc/KID — counter)">Keysets (number: KIc/KID \u2014 counter)</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI1</th> <th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="TARs (MSL)">TARs (MSL)</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI2</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">TAR (ISD/UICC RFM/ADF RFM)</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="SCP81">SCP81</th> <th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="SCP81">SCP81</th>
<th class="border-b border-gray-200 dark:border-slate-700"></th> <th class="border-b border-gray-200 dark:border-slate-700"></th>
</tr></thead> </tr></thead>
@@ -1801,7 +1816,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.9.1'; const SIMPLE_VERSION = '3.10.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -4693,9 +4708,10 @@ async function ramQueueScp81() {
} }
} }
// Which TAR of the selected preset feeds the SCP80 form: RAM/GP -> ISD, // Which TAR of the selected preset feeds the SCP80 form: RAM/GP -> the ISD
// SIM RFM -> UICC, USIM RFM -> USIM (spec defaults when the value is empty). // entry, SIM RFM -> the UICC RFM entry, USIM RFM -> the ADF RFM entry. Each
let _spTarKey = 'tar'; // entry carries its MSL (Minimum SPI1): the SPI1 default for that TAR.
let _spTarKey = 'isd';
// RAM/GP chains packed into the SCP80 form opt in to the per-operation // RAM/GP chains packed into the SCP80 form opt in to the per-operation
// command-format detection ('auto'); other packs (HOTA, §9 push, expanded // command-format detection ('auto'); other packs (HOTA, §9 push, expanded
// script) keep their payload byte-exact. // script) keep their payload byte-exact.
@@ -4703,17 +4719,31 @@ let _spRamFormat = null;
function spTarKeyForPack(sourceId) { function spTarKeyForPack(sourceId) {
return { return {
'chain-ram-preview': 'tar', 'chain-ram-preview': 'isd',
'chain-sim-preview': 'uiccTar', 'chain-sim-preview': 'uiccRfm',
'chain-usim-preview': 'usimTar', 'chain-usim-preview': 'usimRfm',
}[sourceId] || null; }[sourceId] || null;
} }
function spPresetTar(key) { function spPresetTar(role) {
const sel = document.getElementById('sp-card-sel'); const sel = document.getElementById('sp-card-sel');
const idx = sel ? parseInt(sel.value, 10) : NaN; const idx = sel ? parseInt(sel.value, 10) : NaN;
const c = (!isNaN(idx) && cards[idx]) ? cards[idx] : null; const c = (!isNaN(idx) && cards[idx]) ? cards[idx] : null;
return cardsTarValue(key, c ? c[key] : ''); return cardsRoleTar(c, role);
}
// The TAR field's suggestions: the preset's TAR table, each entry labelled
// with its description (or its role) and MSL - free text stays possible.
function spTarListRender(preset) {
const list = document.getElementById('sp-tar-list');
if (!list) return;
const roleLabel = { isd: 'ISD', uiccRfm: 'UICC RFM', usimRfm: 'ADF RFM' };
list.innerHTML = cardsTarList(preset).map(e => {
const label = e.desc || roleLabel[e.role] || '';
const text = label + (e.msl ? (label ? ' ' : '') + '(MSL ' + e.msl + ')' : '');
return '<option value="' + esc(e.tar || '') + '"' +
(text ? ' label="' + esc(text) + '"' : '') + '></option>';
}).join('');
} }
function packToSp(sourceId) { function packToSp(sourceId) {
@@ -4738,6 +4768,7 @@ function spCntrAdjust(delta) {
function spInvalidate() { function spInvalidate() {
document.getElementById('sp-result').value = ''; document.getElementById('sp-result').value = '';
spShowSizeInfo(); spShowSizeInfo();
spMslWarningRender();
} }
function updateSp() { function updateSp() {
@@ -8331,6 +8362,7 @@ async function pysimSendOta() {
if (data.bytes && data.segments) { if (data.bytes && data.segments) {
msg += ' | ' + data.bytes + ' ' + t('bytes') + ' · ' + data.segments + ' SMS'; msg += ' | ' + data.bytes + ' ' + t('bytes') + ' · ' + data.segments + ' SMS';
} }
if (data.warning) msg += ' | ' + t('Warning') + ': ' + data.warning;
const por = data.por; const por = data.por;
if (por && por.response_status) { if (por && por.response_status) {
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')'; msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
@@ -8385,6 +8417,7 @@ async function pysimSendOta() {
let msg = 'OTA failed'; let msg = 'OTA failed';
if (data.error) msg += ': ' + data.error; if (data.error) msg += ': ' + data.error;
if (data.sw) msg += (data.error ? ' | ' : ': ') + 'SW ' + data.sw; if (data.sw) msg += (data.error ? ' | ' : ': ') + 'SW ' + data.sw;
if (data.warning) msg += ' | ' + t('Warning') + ': ' + data.warning;
sendResultEl.textContent = msg; sendResultEl.textContent = msg;
sendResultEl.classList.remove('hidden', 'text-green-600', 'text-gray-500'); sendResultEl.classList.remove('hidden', 'text-green-600', 'text-gray-500');
sendResultEl.classList.add('text-red-600'); sendResultEl.classList.add('text-red-600');
@@ -8452,7 +8485,7 @@ function ramApplyCard(idx) {
// getRamSpParams() picks up the right SPI/KIc/KID/TAR/CNTR/keys // getRamSpParams() picks up the right SPI/KIc/KID/TAR/CNTR/keys
const i = parseInt(idx, 10); const i = parseInt(idx, 10);
if (!isNaN(i) && cards[i]) _ramCardIdx = i; if (!isNaN(i) && cards[i]) _ramCardIdx = i;
_spTarKey = 'tar'; // RAM/GP operations target the ISD _spTarKey = 'isd'; // RAM/GP operations target the ISD
cardsApply(idx); cardsApply(idx);
spPresetWarningRender(); spPresetWarningRender();
} }
@@ -10845,7 +10878,7 @@ function tarApplyCard(idx) {
// kic/kid/keys/counter from there, like the RAM view does) // kic/kid/keys/counter from there, like the RAM view does)
const i = parseInt(idx, 10); const i = parseInt(idx, 10);
if (!isNaN(i) && cards[i]) _ramCardIdx = i; if (!isNaN(i) && cards[i]) _ramCardIdx = i;
_spTarKey = 'tar'; _spTarKey = 'isd'; // the probe's checklist TARs are per-row; the ISD is the form default
cardsApply(idx); cardsApply(idx);
spPresetWarningRender(); spPresetWarningRender();
} }
@@ -11488,10 +11521,83 @@ function cardsPskName(identity) {
} }
// Spec-default TARs (GP ISD 000000, TS 31.116 UICC B00000, TS 31.115 USIM // Spec-default TARs (GP ISD 000000, TS 31.116 UICC B00000, TS 31.115 USIM
// B00001): an empty preset value means the default for that target. // B00001) and the default MSL (Minimum SPI1, TS 102 226 8.2.1.3.2.4). The
function cardsTarValue(key, value) { // three role entries of a preset are mandatory; the defaults pre-settle the
const defaults = { tar: '000000', uiccTar: 'B00000', usimTar: 'B00001' }; // Cards form.
return value || defaults[key] || ''; const CARDS_TAR_DEFAULTS = { isd: '000000', uiccRfm: 'B00000', usimRfm: 'B00001' };
const CARDS_TAR_ROLES = ['isd', 'uiccRfm', 'usimRfm'];
const CARDS_MSL_DEFAULT = '16';
function cardsRoleDefault(role) {
return CARDS_TAR_DEFAULTS[role] || '';
}
// The preset's TAR table: the three role entries plus the free entries.
function cardsTarList(preset) {
return (preset && Array.isArray(preset.tars)) ? preset.tars : [];
}
// The TAR of a role entry; an empty stored value means the role's spec default.
function cardsRoleTar(preset, role) {
const e = cardsTarList(preset).find(x => x.role === role);
return (e && e.tar) || cardsRoleDefault(role);
}
// The MSL of a role entry, '' when the preset carries none.
function cardsRoleMsl(preset, role) {
const e = cardsTarList(preset).find(x => x.role === role);
return (e && e.msl) || '';
}
// The TAR entry matching a value (case-insensitive), or null.
function cardsTarEntry(preset, tarHex) {
const want = String(tarHex || '').trim().toUpperCase();
if (!want) return null;
return cardsTarList(preset).find(e => String(e.tar || '').toUpperCase() === want) || null;
}
// The MSL of a TAR value, '' when the preset has no such entry.
function cardsTarMsl(preset, tarHex) {
const e = cardsTarEntry(preset, tarHex);
return e ? (e.msl || '') : '';
}
// The preset list's TAR cell: `ISD 000000·16 UICC B00000·16 … AF4D02·0A (app)`
function cardsTarText(preset) {
const list = cardsTarList(preset);
if (!list.length) return '\u2014';
const roleLabel = { isd: 'ISD', uiccRfm: 'UICC', usimRfm: 'ADF' };
return list.map(e => {
const label = roleLabel[e.role];
return (label ? label + ' ' : '') + (e.tar || '??') + '\u00b7' + (e.msl || '??') +
(e.desc ? ' (' + e.desc + ')' : '');
}).join(' ');
}
// The SPI1-vs-MSL warning of the SCP80 form (pure): '' when the form's TAR has
// no MSL in the selected preset or the SPI1 is at/above it (numeric compare).
// The card checks the MSL before the security processing and answers 0A
// "Insufficient security level" when the packet's SPI1 is below it
// (TS 102 226 6.1/8.2.1.3.2.4).
function spMslWarningText(preset, tarHex, spi1Hex) {
const msl = cardsTarMsl(preset, tarHex);
if (!msl) return '';
if (!/^[0-9A-F]{1,2}$/i.test(String(spi1Hex || '').trim())) return '';
if (parseInt(spi1Hex, 16) >= parseInt(msl, 16)) return '';
return t('SPI1 is below the MSL of this TAR') + ': ' + String(spi1Hex).toUpperCase() +
' < ' + msl + ' (' + t('TAR') + ' ' + String(tarHex).toUpperCase() + ')';
}
function spMslWarningRender() {
const el = document.getElementById('sp-msl-warning');
if (!el) return;
const idx = spPresetIdx();
const preset = idx >= 0 ? cards[idx] : null;
const tar = (document.getElementById('sp-tar') || {}).value || '';
const spi1 = (document.getElementById('sp-spi1') || {}).value || '';
const text = spMslWarningText(preset, tar, spi1);
el.textContent = text;
el.classList.toggle('hidden', !text);
} }
// ===== OTA keysets (one per keyset number) ===== // ===== OTA keysets (one per keyset number) =====
@@ -11649,6 +11755,85 @@ function cardsKeysetsFromForm() {
return out; return out;
} }
// ===== TAR table editor (Cards form) =====
// The three role entries have fixed inputs (their labels are the immutable
// descriptions); further TARs are free rows with an optional description.
// Every entry carries the TAR and its MSL (Minimum SPI1, one hex byte).
let _cardsTarCount = 0;
function cardsTarRowHtml(e, i) {
const cls = 'border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono';
return '<div class="flex gap-2 items-center mb-1">' +
'<input id="cards-ft-' + i + '-tar" class="w-20 ' + cls + ' text-center" maxlength="6" placeholder="TAR" value="' + esc(e.tar || '') + '">' +
'<input id="cards-ft-' + i + '-msl" class="w-12 ' + cls + ' text-center" maxlength="2" placeholder="MSL" value="' + esc(e.msl || '') + '" title="' + esc(t('MSL (Minimum SPI1, one hex byte)')) + '">' +
'<input id="cards-ft-' + i + '-desc" class="flex-1 ' + cls + '" placeholder="' + esc(t('Description (optional)')) + '" value="' + esc(e.desc || '') + '">' +
'<button type="button" onclick="cardsTarRemove(' + i + ')" class="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700" title="' + esc(t('Remove TAR')) + '">\u2715</button>' +
'</div>';
}
function cardsTarRowsRender(entries) {
const box = document.getElementById('cards-tars');
if (!box) return;
const list = entries || [];
_cardsTarCount = list.length;
box.innerHTML = list.map((e, i) => cardsTarRowHtml(e, i)).join('');
}
function cardsTarAdd() {
cardsTarRowsRender(cardsFreeTarsFromForm().concat([{ tar: '', msl: '', desc: '' }]));
}
function cardsTarRemove(i) {
const rows = cardsFreeTarsFromForm();
rows.splice(i, 1);
cardsTarRowsRender(rows);
}
// The free editor rows (fully empty rows are dropped).
function cardsFreeTarsFromForm() {
const out = [];
for (let i = 0; i < _cardsTarCount; i++) {
const get = id => {
const el = document.getElementById('cards-ft-' + i + '-' + id);
return el ? String(el.value).trim() : '';
};
const e = { tar: get('tar').replace(/\s/g, '').toUpperCase(),
msl: get('msl').replace(/\s/g, '').toUpperCase(), desc: get('desc') };
if (!e.tar && !e.msl && !e.desc) continue;
out.push(e);
}
return out;
}
// The TAR table as preset entries: the three role entries from their fixed
// inputs (the labels are the role descriptions) plus the free rows.
function cardsTarsFromForm() {
const roleInputs = { isd: 'cards-tar', uiccRfm: 'cards-uicc-tar', usimRfm: 'cards-usim-tar' };
const out = CARDS_TAR_ROLES.map(role => {
const tarEl = document.getElementById(roleInputs[role]);
const mslEl = document.getElementById(roleInputs[role] + '-msl');
const tar = (tarEl ? String(tarEl.value).trim() : '').replace(/\s/g, '').toUpperCase();
const msl = (mslEl ? String(mslEl.value).trim() : '').replace(/\s/g, '').toUpperCase();
// An emptied role field keeps the pre-settled default: the three role
// entries are mandatory with a non-empty TAR and MSL.
return { role: role, tar: tar || cardsRoleDefault(role),
msl: msl || CARDS_MSL_DEFAULT, desc: '' };
});
return out.concat(cardsFreeTarsFromForm());
}
// The role inputs back to their pre-settled defaults (TAR + MSL 16).
function cardsTarsReset() {
const roleInputs = { isd: 'cards-tar', uiccRfm: 'cards-uicc-tar', usimRfm: 'cards-usim-tar' };
CARDS_TAR_ROLES.forEach(role => {
const tarEl = document.getElementById(roleInputs[role]);
if (tarEl) tarEl.value = cardsRoleDefault(role);
const mslEl = document.getElementById(roleInputs[role] + '-msl');
if (mslEl) mslEl.value = CARDS_MSL_DEFAULT;
});
cardsTarRowsRender([]);
}
// ===== Keyset selectors (SCP80 / RAM) ===== // ===== Keyset selectors (SCP80 / RAM) =====
// The selector follows the KIc/KID bytes: a keyset of the selected preset that // The selector follows the KIc/KID bytes: a keyset of the selected preset that
// matches their keyset number is selected; a number the preset does not define // matches their keyset number is selected; a number the preset does not define
@@ -11807,11 +11992,7 @@ function cardsFormValues() {
name: val('name'), name: val('name'),
iccid: val('iccid'), iccid: val('iccid'),
adm: val('adm').replace(/\s/g, '').toUpperCase(), adm: val('adm').replace(/\s/g, '').toUpperCase(),
spi1: val('spi1'), tars: cardsTarsFromForm(),
spi2: val('spi2'),
tar: val('tar'),
uiccTar: val('uicc-tar'),
usimTar: val('usim-tar'),
pskIdentity: val('psk-id'), pskIdentity: val('psk-id'),
pskKey: val('psk-key').replace(/\s/g, '').toUpperCase(), pskKey: val('psk-key').replace(/\s/g, '').toUpperCase(),
keysets: cardsKeysetsFromForm(), keysets: cardsKeysetsFromForm(),
@@ -11826,24 +12007,17 @@ function cardsSetFormMode() {
} }
function cardsClearForm() { function cardsClearForm() {
['name','iccid','adm','spi1','spi2','psk-id','psk-key'].forEach(id => { ['name','iccid','adm','psk-id','psk-key'].forEach(id => {
const el = document.getElementById('cards-' + id); const el = document.getElementById('cards-' + id);
if (el) el.value = ''; if (el) el.value = '';
}); });
// TARs keep their per-target spec defaults instead of being emptied // The three role entries keep their pre-settled defaults (TAR + MSL 16)
[['tar','tar'],['uicc-tar','uiccTar'],['usim-tar','usimTar']].forEach(([id, key]) => { cardsTarsReset();
const el = document.getElementById('cards-' + id);
if (el) el.value = cardsTarValue(key, '');
});
cardsKeysetRowsRender([]); cardsKeysetRowsRender([]);
} }
async function cardsAdd() { async function cardsAdd() {
const v = cardsFormValues(); const v = cardsFormValues();
// empty TAR fields fall back to the per-target spec defaults
v.tar = cardsTarValue('tar', v.tar);
v.uiccTar = cardsTarValue('uiccTar', v.uiccTar);
v.usimTar = cardsTarValue('usimTar', v.usimTar);
if (!v.name) { alert(t('Name is required')); return; } if (!v.name) { alert(t('Name is required')); return; }
if (!v.keysets.length) { if (!v.keysets.length) {
alert(t('Define at least one keyset (KIc/KID and their keys)')); alert(t('Define at least one keyset (KIc/KID and their keys)'));
@@ -11866,6 +12040,22 @@ async function cardsAdd() {
alert(t('Duplicate keyset number in the keysets')); alert(t('Duplicate keyset number in the keysets'));
return; return;
} }
// The TAR table: every entry needs a TAR and its MSL, and a TAR appears once
for (const e of v.tars) {
if (!/^[0-9A-F]{6}$/.test(e.tar)) {
alert(t('Every TAR needs three hex bytes') + ': ' + (e.tar || t('(empty)')));
return;
}
if (!/^[0-9A-F]{2}$/.test(e.msl)) {
alert(t('Every TAR needs its MSL (one hex byte)') + ': ' + e.tar);
return;
}
}
const tarSeen = new Set();
for (const e of v.tars) {
if (tarSeen.has(e.tar)) { alert(t('Duplicate TAR in the preset') + ': ' + e.tar); return; }
tarSeen.add(e.tar);
}
if ((v.pskKey && !v.pskIdentity) || (!v.pskKey && v.pskIdentity)) { if ((v.pskKey && !v.pskIdentity) || (!v.pskKey && v.pskIdentity)) {
alert(t('PSK identity and PSK key must be set together')); alert(t('PSK identity and PSK key must be set together'));
return; return;
@@ -11902,12 +12092,15 @@ function cardsEdit(i) {
if (el) el.value = value == null ? '' : value; if (el) el.value = value == null ? '' : value;
}; };
set('name', c.name); set('iccid', c.iccid); set('adm', c.adm); set('name', c.name); set('iccid', c.iccid); set('adm', c.adm);
set('spi1', c.spi1); set('spi2', c.spi2); set('tar', cardsRoleTar(c, 'isd'));
set('tar', cardsTarValue('tar', c.tar)); set('tar-msl', cardsRoleMsl(c, 'isd'));
set('uicc-tar', cardsTarValue('uiccTar', c.uiccTar)); set('uicc-tar', cardsRoleTar(c, 'uiccRfm'));
set('usim-tar', cardsTarValue('usimTar', c.usimTar)); set('uicc-tar-msl', cardsRoleMsl(c, 'uiccRfm'));
set('usim-tar', cardsRoleTar(c, 'usimRfm'));
set('usim-tar-msl', cardsRoleMsl(c, 'usimRfm'));
set('psk-id', c.pskIdentity); set('psk-key', c.pskKey); set('psk-id', c.pskIdentity); set('psk-key', c.pskKey);
cardsKeysetRowsRender(spKeysetList(c)); cardsKeysetRowsRender(spKeysetList(c));
cardsTarRowsRender(cardsTarList(c).filter(e => !e.role));
cardsSetFormMode(); cardsSetFormMode();
document.getElementById('cards-name').focus(); document.getElementById('cards-name').focus();
} }
@@ -11943,7 +12136,7 @@ function cardsRender() {
const tbody = document.getElementById('cards-tbody'); const tbody = document.getElementById('cards-tbody');
if (!tbody) return; if (!tbody) return;
if (!cards.length) { if (!cards.length) {
tbody.innerHTML = '<tr><td colspan="8" class="py-2 text-gray-400">' + t('No cards defined.') + '</td></tr>'; tbody.innerHTML = '<tr><td colspan="6" class="py-2 text-gray-400">' + t('No cards defined.') + '</td></tr>';
return; return;
} }
// The preset of the equipped card (normalised ICCID match): its row is // The preset of the equipped card (normalised ICCID match): its row is
@@ -11973,9 +12166,7 @@ function cardsRender() {
' \u2014 ' + esc(ks.cntr || ''); ' \u2014 ' + esc(ks.cntr || '');
}).join(' \u00b7 ') || '\u2014'; }).join(' \u00b7 ') || '\u2014';
html += '<td class="py-1 px-2 font-mono text-xs">' + ksText + '</td>'; html += '<td class="py-1 px-2 font-mono text-xs">' + ksText + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi1) + '</td>'; html += '<td class="py-1 px-2 font-mono text-xs" title="' + esc(t('TARs (MSL)')) + '">' + esc(cardsTarText(c)) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi2) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs" title="ISD / UICC RFM / ADF RFM">' + esc(cardsTarValue('tar', c.tar)) + ' ' + esc(cardsTarValue('uiccTar', c.uiccTar)) + ' ' + esc(cardsTarValue('usimTar', c.usimTar)) + '</td>';
html += '<td class="py-1 px-2 text-center">' + ota + '</td>'; html += '<td class="py-1 px-2 text-center">' + ota + '</td>';
html += '<td class="py-1 px-2 whitespace-nowrap"><button onclick="cardsEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + t('Edit') + '</button> ' html += '<td class="py-1 px-2 whitespace-nowrap"><button onclick="cardsEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + t('Edit') + '</button> '
+ '<button onclick="cardsRemove(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + t('Remove') + '</button></td>'; + '<button onclick="cardsRemove(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + t('Remove') + '</button></td>';
@@ -12017,12 +12208,19 @@ function cardsFindByIccid(iccid) {
return cards.findIndex(c => cardsNormIccid(c.iccid) === norm); return cards.findIndex(c => cardsNormIccid(c.iccid) === norm);
} }
// Completeness predicates used by the header indicators: SCP80 needs all // Completeness predicates used by the header indicators: SCP80 needs a
// SPI/keys/counter fields, SCP81 the PSK pair; the ADM key is optional. // complete keyset plus the three role TAR entries with their MSLs, SCP81 the
// PSK pair; the ADM key is optional.
function cardsScp80Complete(c) { function cardsScp80Complete(c) {
return !!(c && String(c.kic || '').trim() && String(c.kid || '').trim() if (!c) return false;
&& String(c.spi1 || '').trim() && String(c.spi2 || '').trim() const ks = spKeysetList(c).find(k => String(k.kic || '').trim() && String(k.kid || '').trim()
&& String(c.cntr || '').trim() && String(c.kicKey || '').trim() && String(c.kidKey || '').trim()); && String(k.kicKey || '').trim() && String(k.kidKey || '').trim()
&& String(k.cntr || '').trim());
if (!ks) return false;
return CARDS_TAR_ROLES.every(role => {
const e = cardsTarList(c).find(x => x.role === role);
return !!(e && String(e.tar || '').trim() && String(e.msl || '').trim());
});
} }
function cardsScp81Complete(c) { function cardsScp81Complete(c) {
@@ -12087,23 +12285,24 @@ function cardsAutoSelectByIccid(iccid) {
function cardsApplyFields(idx, keepTar) { function cardsApplyFields(idx, keepTar) {
const c = cards[parseInt(idx)]; const c = cards[parseInt(idx)];
if (!c) return false; if (!c) return false;
document.getElementById('sp-spi1').value = c.spi1; // The TAR of the current operation (ISD for RAM/GP, the RFM TAR for the RFM
// Sync the SPI2 selects with the preset byte (low 5 bits = PoR/security, // chains) and its MSL: a preset apply seeds the SPI1 from the MSL - the
// bit 0x20 = PoR via SMS-SUBMIT): updateSp() recomputes sp-spi2-hex from // card's Minimum SPI1 (TS 102 226 8.2.1.3.2.4). keepTar leaves the packet
// them, so without this the preset value was clobbered on every refresh. // TAR alone (the plain send may target a manually entered TAR): its SPI1 is
const spi2Val = parseInt(c.spi2, 16) || 0; // the operator's value too.
const baseHex = (spi2Val & 0x1F).toString(16).padStart(2, '0').toUpperCase(); if (!keepTar) {
const baseSel = document.getElementById('sp-spi2'); const roleTar = cardsRoleTar(c, _spTarKey);
if (baseSel) { document.getElementById('sp-tar').value = roleTar;
if (baseSel.options && typeof Option === 'function' && const msl = cardsTarMsl(c, roleTar) || CARDS_MSL_DEFAULT;
!Array.prototype.some.call(baseSel.options, o => o.value === baseHex)) { const spi1Sel = document.getElementById('sp-spi1');
baseSel.appendChild(new Option(baseHex + ' — preset', baseHex)); if (spi1Sel) {
if (spi1Sel.options && typeof Option === 'function' &&
!Array.prototype.some.call(spi1Sel.options, o => o.value === msl)) {
spi1Sel.appendChild(new Option(msl + ' \u2014 MSL', msl));
}
spi1Sel.value = msl;
} }
baseSel.value = baseHex;
} }
const smSel = document.getElementById('sp-spi2-sm');
if (smSel) smSel.value = String((spi2Val >> 5) & 1);
document.getElementById('sp-spi2-hex').value = c.spi2;
// The keyset: keep the keyset number the form carries when this preset // The keyset: keep the keyset number the form carries when this preset
// defines it, otherwise start from the preset's first keyset. // defines it, otherwise start from the preset's first keyset.
const kicNow = (document.getElementById('sp-kic-hex') || {}).value; const kicNow = (document.getElementById('sp-kic-hex') || {}).value;
@@ -12123,11 +12322,13 @@ function cardsApplyFields(idx, keepTar) {
document.getElementById('sp-kic-key').value = ks.kicKey || ''; document.getElementById('sp-kic-key').value = ks.kicKey || '';
document.getElementById('sp-kid-key').value = ks.kidKey || ''; document.getElementById('sp-kid-key').value = ks.kidKey || '';
} }
if (!keepTar) document.getElementById('sp-tar').value = cardsTarValue(_spTarKey, c[_spTarKey]); if (!keepTar) document.getElementById('sp-tar').value = cardsRoleTar(c, _spTarKey);
spTarListRender(c);
spInvalidate(); spInvalidate();
updateSp(); updateSp();
spKeysetSync(); spKeysetSync();
spPresetWarningRender(); spPresetWarningRender();
spMslWarningRender();
return true; return true;
} }
@@ -12137,6 +12338,7 @@ function cardsApply(idx, keepTar) {
// warning must not outlive the selection it described. // warning must not outlive the selection it described.
spKeysetSync(); spKeysetSync();
spPresetWarningRender(); spPresetWarningRender();
spTarListRender(null);
return; return;
} }
_genSpBuild(); _genSpBuild();
@@ -12152,7 +12354,7 @@ function spRefreshFromPreset(selId, keepTar) {
const sel = document.getElementById(selId || 'sp-card-sel'); const sel = document.getElementById(selId || 'sp-card-sel');
const idx = sel ? parseInt(sel.value, 10) : NaN; const idx = sel ? parseInt(sel.value, 10) : NaN;
if (isNaN(idx) || !cards[idx]) return ''; if (isNaN(idx) || !cards[idx]) return '';
if (selId === 'ram-card-sel') _spTarKey = 'tar'; if (selId === 'ram-card-sel') _spTarKey = 'isd';
cardsApply(idx, keepTar); cardsApply(idx, keepTar);
return cards[idx].cntr; return cards[idx].cntr;
} }
@@ -20492,6 +20694,22 @@ const LANG_RU = {
'Remove keyset': 'Удалить keyset', 'Remove keyset': 'Удалить keyset',
'Keyset': 'Keyset', 'Keyset': 'Keyset',
'Keyset number (b8..b5 of KIc/KID)': 'Номер keyset (b8..b5 KIc/KID)', 'Keyset number (b8..b5 of KIc/KID)': 'Номер keyset (b8..b5 KIc/KID)',
'ISD TAR': 'TAR ISD',
'UICC RFM TAR': 'TAR UICC RFM',
'ADF RFM TAR': 'TAR ADF RFM',
'MSL': 'MSL',
'MSL (Minimum SPI1, one hex byte)': 'MSL (минимальный SPI1, один hex-байт)',
'TARs (MSL)': 'TAR (MSL)',
'Each TAR carries its MSL (Minimum SPI1) — the card checks it before the security processing (TS 102 226 8.2.1.3.2.4); the packet SPI1 is derived from it.': 'У каждого TAR есть MSL (минимальный SPI1) — карта проверяет его до обработки защиты (TS 102 226 8.2.1.3.2.4); из него выводится SPI1 пакета.',
'More TARs — each with its MSL and an optional description': 'Дополнительные TAR — у каждого свой MSL и необязательное описание',
'Add TAR': 'Добавить TAR',
'Remove TAR': 'Удалить TAR',
'Description (optional)': 'Описание (необязательно)',
'Every TAR needs three hex bytes': 'Каждый TAR должен состоять из трёх hex-байт',
'Every TAR needs its MSL (one hex byte)': 'Каждому TAR нужен MSL (один hex-байт)',
'Duplicate TAR in the preset': 'Повторяющийся TAR в пресете',
'SPI1 is below the MSL of this TAR': 'SPI1 ниже MSL этого TAR',
'(empty)': '(пусто)',
'Keysets (number: KIc/KID — counter)': 'Keysets (номер: KIc/KID — счётчик)', 'Keysets (number: KIc/KID — counter)': 'Keysets (номер: KIc/KID — счётчик)',
'must be one hex byte': 'должен быть одним hex-байтом', 'must be one hex byte': 'должен быть одним hex-байтом',
'KIc and KID must carry the same keyset number (TS 102 225 A.2)': 'KIc и KID должны содержать один и тот же номер keyset (TS 102 225 A.2)', 'KIc and KID must carry the same keyset number (TS 102 225 A.2)': 'KIc и KID должны содержать один и тот же номер keyset (TS 102 225 A.2)',
@@ -20540,6 +20758,7 @@ const LANG_RU = {
'Keysets (number: KIc/KID — counter)': 'Keysets (номер: KIc/KID — счётчик)', 'Keysets (number: KIc/KID — counter)': 'Keysets (номер: KIc/KID — счётчик)',
'KIc and KID must carry the same keyset number (their high nibble) in the range 01-0F — TS 102 225 A.2; each keyset has its own counter on the card (Annex A.1).': 'KIc и KID должны содержать один и тот же номер keyset (старший ниббл) в диапазоне 01-0F — TS 102 225 A.2; у каждого keyset свой счётчик на карте (Annex A.1).', 'KIc and KID must carry the same keyset number (their high nibble) in the range 01-0F — TS 102 225 A.2; each keyset has its own counter on the card (Annex A.1).': 'KIc и KID должны содержать один и тот же номер keyset (старший ниббл) в диапазоне 01-0F — TS 102 225 A.2; у каждого keyset свой счётчик на карте (Annex A.1).',
'Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries several keysets, each with its own number and counter.': 'Пресеты карт хранятся на сервере. Кнопки экспорта/импорта JSON — под списком карт; пресет содержит несколько keysets, у каждого свой номер и счётчик.', 'Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries several keysets, each with its own number and counter.': 'Пресеты карт хранятся на сервере. Кнопки экспорта/импорта JSON — под списком карт; пресет содержит несколько keysets, у каждого свой номер и счётчик.',
'Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries several keysets, each with its own number and counter, and a TAR table — every TAR with its MSL (Minimum SPI1).': 'Пресеты карт хранятся на сервере. Кнопки экспорта/импорта JSON — под списком карт; пресет содержит несколько keysets (у каждого свой номер и счётчик) и таблицу TAR — у каждого TAR свой MSL (минимальный SPI1).',
'Save error': 'Ошибка сохранения', 'Save error': 'Ошибка сохранения',
'Delete error': 'Ошибка удаления', 'Delete error': 'Ошибка удаления',
'Counter not saved:': 'Счётчик не сохранён:', 'Counter not saved:': 'Счётчик не сохранён:',
+25 -6
View File
@@ -672,8 +672,8 @@ video {
z-index: 50; z-index: 50;
} }
.col-span-2 { .col-span-3 {
grid-column: span 2 / span 2; grid-column: span 3 / span 3;
} }
.col-span-4 { .col-span-4 {
@@ -843,6 +843,10 @@ video {
max-height: 14rem; max-height: 14rem;
} }
.max-h-64 {
max-height: 16rem;
}
.max-h-\[30vh\] { .max-h-\[30vh\] {
max-height: 30vh; max-height: 30vh;
} }
@@ -996,10 +1000,6 @@ video {
max-width: 80rem; max-width: 80rem;
} }
.max-w-lg {
max-width: 32rem;
}
.max-w-md { .max-w-md {
max-width: 28rem; max-width: 28rem;
} }
@@ -1386,6 +1386,11 @@ video {
background-color: rgb(209 250 229 / var(--tw-bg-opacity, 1)); background-color: rgb(209 250 229 / var(--tw-bg-opacity, 1));
} }
.bg-emerald-50 {
--tw-bg-opacity: 1;
background-color: rgb(236 253 245 / var(--tw-bg-opacity, 1));
}
.bg-emerald-600 { .bg-emerald-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(5 150 105 / var(--tw-bg-opacity, 1)); background-color: rgb(5 150 105 / var(--tw-bg-opacity, 1));
@@ -1900,6 +1905,11 @@ video {
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1)); background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
} }
.hover\:bg-red-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1));
}
.hover\:bg-red-200:hover { .hover\:bg-red-200:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(254 202 202 / var(--tw-bg-opacity, 1)); background-color: rgb(254 202 202 / var(--tw-bg-opacity, 1));
@@ -1981,6 +1991,11 @@ video {
border-color: rgb(30 64 175 / var(--tw-border-opacity, 1)); border-color: rgb(30 64 175 / var(--tw-border-opacity, 1));
} }
.dark\:border-red-700:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(185 28 28 / var(--tw-border-opacity, 1));
}
.dark\:border-red-800:is(.dark *) { .dark\:border-red-800:is(.dark *) {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(153 27 27 / var(--tw-border-opacity, 1)); border-color: rgb(153 27 27 / var(--tw-border-opacity, 1));
@@ -2162,6 +2177,10 @@ video {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
} }
.dark\:hover\:bg-red-900\/40:hover:is(.dark *) {
background-color: rgb(127 29 29 / 0.4);
}
.dark\:hover\:bg-red-900\/50:hover:is(.dark *) { .dark\:hover\:bg-red-900\/50:hover:is(.dark *) {
background-color: rgb(127 29 29 / 0.5); background-color: rgb(127 29 29 / 0.5);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v357'; const CACHE = 'simple-v358';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+20 -4
View File
@@ -5,6 +5,12 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
function extractFunc(src, name, asyncFn) { function extractFunc(src, name, asyncFn) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src); const m = re.exec(src);
@@ -46,6 +52,10 @@ code += extractFunc(html, 'pysimUpdatePresetIndicator') + '\n';
code += extractFunc(html, 'pysimUpdatePresetIndicators') + '\n'; code += extractFunc(html, 'pysimUpdatePresetIndicators') + '\n';
code += extractFunc(html, 'cardsMatchedPreset') + '\n'; code += extractFunc(html, 'cardsMatchedPreset') + '\n';
code += extractFunc(html, 'cardsAdmPresent') + '\n'; code += extractFunc(html, 'cardsAdmPresent') + '\n';
code += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES') + extractConst(html, 'CARDS_MSL_DEFAULT');
code += extractFunc(html, 'cardsTarList') + '\n';
code += extractFunc(html, 'spKeysetList') + '\n';
code += extractFunc(html, 'spKeysetKvnOf') + '\n';
code += extractFunc(html, 'cardsScp80Complete') + '\n'; code += extractFunc(html, 'cardsScp80Complete') + '\n';
code += extractFunc(html, 'cardsScp81Complete') + '\n'; code += extractFunc(html, 'cardsScp81Complete') + '\n';
code += extractFunc(html, 'pysimSetServerAvailable') + '\n'; code += extractFunc(html, 'pysimSetServerAvailable') + '\n';
@@ -339,8 +349,11 @@ test('the ADM badge marks an ADM key in the matching preset', () => {
test('the header SCP80/SCP81 markers follow the matching preset', () => { test('the header SCP80/SCP81 markers follow the matching preset', () => {
const { scp80El, scp81El } = setup(); const { scp80El, scp81El } = setup();
globalThis.cards = [{ name: 'C', kic: '15', kid: '15', spi1: '16', spi2: '01', globalThis.cards = [{ name: 'C', kic: '15', kid: '15',
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY' }]; cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY',
tars: [{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
globalThis.cardsFindByIccid = () => 0; globalThis.cardsFindByIccid = () => 0;
pysimCardStateUpdate(status({ connected: true, card_session: 2, iccid: '89701450001700031958' })); pysimCardStateUpdate(status({ connected: true, card_session: 2, iccid: '89701450001700031958' }));
assert.strictEqual(scp80El.textContent, 'SCP80'); assert.strictEqual(scp80El.textContent, 'SCP80');
@@ -356,8 +369,11 @@ test('the header SCP80/SCP81 markers follow the matching preset', () => {
test('losing the server hides the ADM badge and the preset markers', () => { test('losing the server hides the ADM badge and the preset markers', () => {
const { adm, scp80El, scp81El } = setup(); const { adm, scp80El, scp81El } = setup();
globalThis.cards = [{ name: 'C', adm: '0011', kic: '15', kid: '15', spi1: '16', spi2: '01', globalThis.cards = [{ name: 'C', adm: '0011', kic: '15', kid: '15',
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY' }]; cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY',
tars: [{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
globalThis.cardsFindByIccid = () => 0; globalThis.cardsFindByIccid = () => 0;
pysimCardStateUpdate(status({ connected: true, adm_verified: true, iccid: '89701450001700031958' })); pysimCardStateUpdate(status({ connected: true, adm_verified: true, iccid: '89701450001700031958' }));
assert.ok(!adm.classes.has('hidden')); assert.ok(!adm.classes.has('hidden'));
+19 -6
View File
@@ -5,6 +5,12 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
function extractFunc(src, name) { function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src); const m = re.exec(src);
@@ -22,18 +28,23 @@ function extractFunc(src, name) {
} }
let code = ''; let code = '';
for (const fn of ['cardsTarValue', 'cardsApplyFields', 'cardsApply', for (const fn of ['cardsTarList', 'cardsRoleDefault', 'cardsRoleTar', 'cardsRoleMsl',
'cardsTarEntry', 'cardsTarMsl', 'cardsApplyFields', 'cardsApply',
'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr', 'spPresetIdx', 'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr', 'spPresetIdx',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck', 'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender', 'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate', 'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard', 'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard',
'spTarListRender',
'ramKeysetGuard', 'ramKeysetGuard',
]) { ]) {
code += extractFunc(html, fn) + '\n'; code += extractFunc(html, fn) + '\n';
} }
code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
+ extractConst(html, 'CARDS_MSL_DEFAULT') + code;
code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n' code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n'
+ 'globalThis.spMslWarningRender = function() {};\n'
+ 'globalThis.pysimApplyAvailability = function() {};\n' + code; + 'globalThis.pysimApplyAvailability = function() {};\n' + code;
code = 'var _genSpBuildStub = function() {};\n' + code; code = 'var _genSpBuildStub = function() {};\n' + code;
eval(code); eval(code);
@@ -52,10 +63,12 @@ function fakeEnv(selValue, selId, presetCntr) {
} }
els[selId || 'sp-card-sel'] = { value: selValue }; els[selId || 'sp-card-sel'] = { value: selValue };
globalThis.document = { getElementById: id => els[id] || null }; globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [{ name: 'C', cntr: presetCntr, spi1: '16', spi2: '01', globalThis.cards = [{ name: 'C', cntr: presetCntr,
kic: '15', kid: '15', tar: '000000', uiccTar: 'B00000', kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB',
kicKey: 'AA', kidKey: 'BB' }]; tars: [{ role: 'isd', tar: '000000', msl: '16' },
globalThis._spTarKey = 'uiccTar'; { role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
globalThis._spTarKey = 'uiccRfm';
globalThis.updateSpKic = () => {}; globalThis.updateSpKic = () => {};
globalThis.updateSpKid = () => {}; globalThis.updateSpKid = () => {};
globalThis.spInvalidate = () => {}; globalThis.spInvalidate = () => {};
@@ -83,7 +96,7 @@ test('spRefreshFromPreset re-reads the edited preset before an operation', () =>
test('spRefreshFromPreset for the RAM selector targets the ISD TAR', () => { test('spRefreshFromPreset for the RAM selector targets the ISD TAR', () => {
const env = fakeEnv('0', 'ram-card-sel', '0000000010'); const env = fakeEnv('0', 'ram-card-sel', '0000000010');
assert.strictEqual(spRefreshFromPreset('ram-card-sel'), '0000000010'); assert.strictEqual(spRefreshFromPreset('ram-card-sel'), '0000000010');
assert.strictEqual(_spTarKey, 'tar'); assert.strictEqual(_spTarKey, 'isd');
assert.strictEqual(env.els['sp-tar'].value, '000000'); assert.strictEqual(env.els['sp-tar'].value, '000000');
}); });
+222 -67
View File
@@ -21,38 +21,57 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1); return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
} }
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
let code = ''; let code = '';
const ASYNC_FNS = ['cardsAdd', 'cardsImport', 'cardsRemove']; const ASYNC_FNS = ['cardsAdd', 'cardsImport', 'cardsRemove'];
for (const fn of ['cardsTarValue', 'cardsFormValues', 'cardsClearForm', 'cardsEdit', for (const fn of ['cardsFormValues', 'cardsClearForm', 'cardsEdit',
'cardsAdd', 'cardsImport', 'cardsRemove', 'ramCardIdxAfterRemove', 'cardsAdd', 'cardsImport', 'cardsRemove', 'ramCardIdxAfterRemove',
'spPresetIdx', 'spCntrSyncPreset', 'spCntrLocalSync', 'ramSaveCntr', 'spPresetIdx', 'spCntrSyncPreset', 'spCntrLocalSync', 'ramSaveCntr',
'cardsApplyFields', 'cardsApply', 'ramApplyCard', 'cardsApplyFields', 'cardsApply', 'ramApplyCard',
'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent', 'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent',
'spTarKeyForPack', 'spPresetTar', 'packToSp', 'spTarKeyForPack', 'spPresetTar', 'spTarListRender', 'packToSp',
'esc', 'escHtml', 'esc', 'escHtml',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck', 'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender', 'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate', 'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged', 'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard', 'ramKeysetGuard']) { 'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard', 'ramKeysetGuard',
'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar', 'cardsRoleMsl',
'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'spMslWarningText',
'cardsTarRowHtml', 'cardsTarRowsRender', 'cardsTarAdd', 'cardsTarRemove',
'cardsFreeTarsFromForm', 'cardsTarsFromForm', 'cardsTarsReset']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n'; code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
} }
code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n' code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
+ extractConst(html, 'CARDS_MSL_DEFAULT')
+ 'var _cardsKeysetCount = 0;\nvar _cardsTarCount = 0;\n'
+ 'globalThis.spPresetWarningRender = function() {};\n'
+ 'globalThis.spMslWarningRender = function() {};\n'
+ 'globalThis.pysimApplyAvailability = function() {};\n' + code; + 'globalThis.pysimApplyAvailability = function() {};\n' + code;
eval(code); eval(code);
globalThis.t = s => s;
const CARD_IDS = ['cards-name','cards-iccid','cards-adm', const CARD_IDS = ['cards-name','cards-iccid','cards-adm',
'cards-spi1','cards-spi2','cards-tar','cards-uicc-tar','cards-usim-tar', 'cards-tar','cards-tar-msl','cards-uicc-tar','cards-uicc-tar-msl',
'cards-usim-tar','cards-usim-tar-msl','cards-tars',
'cards-psk-id','cards-psk-key','cards-keysets', 'cards-psk-id','cards-psk-key','cards-keysets',
'cards-add-btn','cards-cancel-btn', 'cards-add-btn','cards-cancel-btn',
// the keyset editor rows (two rows is enough for the tests) // the keyset editor rows (two rows is enough for the tests)
'cards-ks-0-kvn','cards-ks-0-kic','cards-ks-0-kid','cards-ks-0-kic-key', 'cards-ks-0-kvn','cards-ks-0-kic','cards-ks-0-kid','cards-ks-0-kic-key',
'cards-ks-0-kid-key','cards-ks-0-cntr', 'cards-ks-0-kid-key','cards-ks-0-cntr',
'cards-ks-1-kvn','cards-ks-1-kic','cards-ks-1-kid','cards-ks-1-kic-key', 'cards-ks-1-kvn','cards-ks-1-kic','cards-ks-1-kid','cards-ks-1-kic-key',
'cards-ks-1-kid-key','cards-ks-1-cntr']; 'cards-ks-1-kid-key','cards-ks-1-cntr',
// the free TAR editor rows
'cards-ft-0-tar','cards-ft-0-msl','cards-ft-0-desc',
'cards-ft-1-tar','cards-ft-1-msl','cards-ft-1-desc'];
const SP_IDS = ['sp-card-sel','sp-tar','sp-spi1','sp-spi2','sp-spi2-sm','sp-spi2-hex', const SP_IDS = ['sp-card-sel','sp-tar','sp-spi1','sp-spi2','sp-spi2-sm','sp-spi2-hex',
'sp-kic-idx','sp-kic-alg','sp-kic-hex','sp-kid-idx','sp-kid-alg','sp-kid-hex', 'sp-kic-idx','sp-kic-alg','sp-kic-hex','sp-kid-idx','sp-kid-alg','sp-kid-hex',
'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex', 'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex','sp-tar-list',
'sp-keyset-sel','ram-keyset-sel']; 'sp-keyset-sel','ram-keyset-sel'];
const CHAIN_IDS = ['chain-sim-preview','chain-usim-preview','chain-ram-preview', const CHAIN_IDS = ['chain-sim-preview','chain-usim-preview','chain-ram-preview',
'ber-result','hota-preview']; 'ber-result','hota-preview'];
@@ -65,6 +84,24 @@ function ksRow(els, i, ks) {
} }
} }
function tarRow(els, i, e) {
for (const field of ['tar', 'msl', 'desc']) {
const id = 'cards-ft-' + i + '-' + field;
if (!els[id]) els[id] = fakeEl();
els[id].value = e[field] || '';
}
}
function presetFixture(extra) {
return Object.assign({
name: 'C',
keysets: [{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }],
tars: [{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }],
}, extra || {});
}
function fakeEl() { function fakeEl() {
return { return {
value: '', value: '',
@@ -86,7 +123,8 @@ function setup() {
globalThis._cardsEditIdx = null; globalThis._cardsEditIdx = null;
globalThis._ramCardIdx = null; globalThis._ramCardIdx = null;
globalThis._cardsKeysetCount = 1; globalThis._cardsKeysetCount = 1;
globalThis._spTarKey = 'tar'; _cardsTarCount = 0;
globalThis._spTarKey = 'isd';
globalThis.t = s => s; globalThis.t = s => s;
globalThis.esc = s => s; globalThis.esc = s => s;
globalThis.escHtml = s => s; globalThis.escHtml = s => s;
@@ -125,25 +163,58 @@ function setup() {
return els; return els;
} }
test('cardsTarValue falls back to the per-target spec defaults', () => { test('the preset TAR table helpers read the role entries and the MSLs', () => {
assert.strictEqual(cardsTarValue('tar', '123456'), '123456'); const p = presetFixture({ tars: [
assert.strictEqual(cardsTarValue('tar', ''), '000000'); { role: 'isd', tar: 'AF4D01', msl: '1A' },
assert.strictEqual(cardsTarValue('uiccTar', ''), 'B00000'); { role: 'uiccRfm', tar: 'B00000', msl: '10' },
assert.strictEqual(cardsTarValue('usimTar', null), 'B00001'); { role: 'usimRfm', tar: 'B00001', msl: '16' },
assert.strictEqual(cardsTarValue('nope', ''), ''); { tar: 'AF4D02', msl: '0A', desc: 'applet' }] });
assert.strictEqual(cardsRoleTar(p, 'isd'), 'AF4D01');
assert.strictEqual(cardsRoleMsl(p, 'isd'), '1A');
assert.strictEqual(cardsTarMsl(p, 'af4d02'), '0A');
assert.strictEqual(cardsTarMsl(p, '123456'), '');
assert.strictEqual(cardsTarEntry(p, 'AF4D02').desc, 'applet');
// a missing role entry falls back to the spec default
assert.strictEqual(cardsRoleTar({}, 'uiccRfm'), 'B00000');
assert.strictEqual(cardsRoleTar(null, 'usimRfm'), 'B00001');
assert.strictEqual(cardsRoleDefault('nope'), '');
assert.strictEqual(cardsTarText(p),
'ISD AF4D01\u00b71A UICC B00000\u00b710 ADF B00001\u00b716 AF4D02\u00b70A (applet)');
assert.strictEqual(cardsTarText({}), '\u2014');
});
test('spMslWarningText compares the SPI1 with the TAR MSL numerically', () => {
const p = presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '10' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] });
assert.strictEqual(spMslWarningText(p, '000000', '10'), '');
assert.strictEqual(spMslWarningText(p, '000000', '1A'), '');
assert.match(spMslWarningText(p, '000000', '0A'), /MSL/);
// numeric, not lexicographic: '9' < '10'
assert.match(spMslWarningText(p, '000000', '9'), /MSL/);
// a TAR the preset does not carry (or a missing SPI1) is not a warning
assert.strictEqual(spMslWarningText(p, 'AF4D01', '00'), '');
assert.strictEqual(spMslWarningText(p, '000000', ''), '');
}); });
test('preset completeness predicates used by the header markers', () => { test('preset completeness predicates used by the header markers', () => {
const full = { kic: '15', kid: '15', spi1: '16', spi2: '01', cntr: '0000000001', const full = presetFixture({ pskIdentity: 'id', pskKey: 'KEY', adm: '0011' });
kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY', adm: '0011' };
assert.ok(cardsScp80Complete(full)); assert.ok(cardsScp80Complete(full));
assert.ok(cardsScp81Complete(full)); assert.ok(cardsScp81Complete(full));
assert.ok(cardsAdmPresent(full)); assert.ok(cardsAdmPresent(full));
// every SCP80 field is required; whitespace counts as empty // a complete keyset is required
for (const field of ['kic', 'kid', 'spi1', 'spi2', 'cntr', 'kicKey', 'kidKey']) { assert.ok(!cardsScp80Complete(Object.assign({}, full, { keysets: [] })));
const c = Object.assign({}, full); assert.ok(!cardsScp80Complete(Object.assign({}, full, {
c[field] = ' '; keysets: [{ kic: '15', kid: '15', kicKey: '', kidKey: 'BB', cntr: '0000000001' }] })));
assert.ok(!cardsScp80Complete(c), 'SCP80 marked complete with empty ' + field); // every role entry needs its TAR and MSL
for (const role of ['isd', 'uiccRfm', 'usimRfm']) {
const noMsl = presetFixture({ tars: full.tars.map(e =>
e.role === role ? Object.assign({}, e, { msl: '' }) : e) });
assert.ok(!cardsScp80Complete(noMsl), 'SCP80 complete with an empty MSL for ' + role);
const noTar = presetFixture({ tars: full.tars.map(e =>
e.role === role ? Object.assign({}, e, { tar: '' }) : e) });
assert.ok(!cardsScp80Complete(noTar), 'SCP80 complete with an empty TAR for ' + role);
} }
assert.ok(!cardsScp81Complete({ pskIdentity: 'id' })); assert.ok(!cardsScp81Complete({ pskIdentity: 'id' }));
assert.ok(!cardsScp81Complete({ pskKey: 'KEY' })); assert.ok(!cardsScp81Complete({ pskKey: 'KEY' }));
@@ -153,60 +224,82 @@ test('preset completeness predicates used by the header markers', () => {
assert.ok(!cardsAdmPresent({ adm: ' ' })); assert.ok(!cardsAdmPresent({ adm: ' ' }));
}); });
test('clearing the card form keeps the TAR spec defaults', () => { test('clearing the card form keeps the role TAR/MSL defaults', () => {
const els = setup(); const els = setup();
els['cards-tar'].value = 'AABBCC'; els['cards-tar'].value = 'AABBCC';
els['cards-tar-msl'].value = 'FF';
els['cards-uicc-tar'].value = 'AABBCC'; els['cards-uicc-tar'].value = 'AABBCC';
els['cards-usim-tar'].value = 'AABBCC'; els['cards-usim-tar'].value = 'AABBCC';
els['cards-adm'].value = 'DEAD'; els['cards-adm'].value = 'DEAD';
els['cards-name'].value = 'X'; els['cards-name'].value = 'X';
tarRow(els, 0, { tar: 'AF4D01', msl: '0A', desc: 'applet' });
_cardsTarCount = 1;
cardsClearForm(); cardsClearForm();
assert.strictEqual(els['cards-tar'].value, '000000'); assert.strictEqual(els['cards-tar'].value, '000000');
assert.strictEqual(els['cards-tar-msl'].value, '16');
assert.strictEqual(els['cards-uicc-tar'].value, 'B00000'); assert.strictEqual(els['cards-uicc-tar'].value, 'B00000');
assert.strictEqual(els['cards-uicc-tar-msl'].value, '16');
assert.strictEqual(els['cards-usim-tar'].value, 'B00001'); assert.strictEqual(els['cards-usim-tar'].value, 'B00001');
assert.strictEqual(els['cards-adm'].value, ''); assert.strictEqual(els['cards-adm'].value, '');
assert.strictEqual(els['cards-name'].value, ''); assert.strictEqual(els['cards-name'].value, '');
assert.strictEqual(_cardsTarCount, 0, 'the free TAR rows are cleared');
}); });
test('editing prefills empty stored TARs with the spec defaults', () => { test('editing prefills the role TARs/MSLs and renders the free TAR rows', () => {
const els = setup(); const els = setup();
globalThis.cards = [ globalThis.cards = [
{ name: 'Legacy', kic: '15', kid: '15', tar: '' }, { name: 'Legacy', keysets: [] },
{ name: 'Full', kic: '15', kid: '15', tar: '000001', uiccTar: 'B0000C', usimTar: 'B0000D', adm: '0011' }, presetFixture({ name: 'Full', adm: '0011', tars: [
{ role: 'isd', tar: '000001', msl: '0A' },
{ role: 'uiccRfm', tar: 'B0000C', msl: '16' },
{ role: 'usimRfm', tar: 'B0000D', msl: '16' },
{ tar: 'AF4D02', msl: '1A', desc: 'applet' }] }),
]; ];
cardsEdit(0); cardsEdit(0);
assert.strictEqual(els['cards-tar'].value, '000000'); assert.strictEqual(els['cards-tar'].value, '000000');
assert.strictEqual(els['cards-tar-msl'].value, '');
assert.strictEqual(els['cards-uicc-tar'].value, 'B00000'); assert.strictEqual(els['cards-uicc-tar'].value, 'B00000');
assert.strictEqual(els['cards-usim-tar'].value, 'B00001');
assert.strictEqual(els['cards-adm'].value, ''); assert.strictEqual(els['cards-adm'].value, '');
cardsEdit(1); cardsEdit(1);
assert.strictEqual(els['cards-tar'].value, '000001'); assert.strictEqual(els['cards-tar'].value, '000001');
assert.strictEqual(els['cards-tar-msl'].value, '0A');
assert.strictEqual(els['cards-uicc-tar'].value, 'B0000C'); assert.strictEqual(els['cards-uicc-tar'].value, 'B0000C');
assert.strictEqual(els['cards-usim-tar'].value, 'B0000D'); assert.strictEqual(els['cards-usim-tar'].value, 'B0000D');
assert.strictEqual(els['cards-usim-tar-msl'].value, '16');
assert.strictEqual(els['cards-adm'].value, '0011'); assert.strictEqual(els['cards-adm'].value, '0011');
assert.strictEqual(_cardsTarCount, 1);
assert.match(els['cards-tars'].innerHTML, /cards-ft-0-tar"[^>]*value="AF4D02"/);
assert.match(els['cards-tars'].innerHTML, /cards-ft-0-desc"[^>]*value="applet"/);
}); });
test('the form values carry the ADM and per-target TAR fields', () => { test('the form values carry the ADM and the TAR table', () => {
const els = setup(); const els = setup();
els['cards-tar'].value = ' 000001 '; els['cards-tar'].value = ' 000001 ';
els['cards-uicc-tar'].value = 'B00002'; els['cards-tar-msl'].value = '0a';
els['cards-uicc-tar'].value = '';
els['cards-uicc-tar-msl'].value = '';
els['cards-usim-tar'].value = 'B00003'; els['cards-usim-tar'].value = 'B00003';
els['cards-usim-tar-msl'].value = '1A';
els['cards-adm'].value = ' 00 11 22 33 '; els['cards-adm'].value = ' 00 11 22 33 ';
tarRow(els, 0, { tar: 'af4d02', msl: '0A', desc: 'My applet' });
_cardsTarCount = 1;
const v = cardsFormValues(); const v = cardsFormValues();
assert.strictEqual(v.tar, '000001'); assert.deepStrictEqual(v.tars, [
assert.strictEqual(v.uiccTar, 'B00002'); { role: 'isd', tar: '000001', msl: '0A', desc: '' },
assert.strictEqual(v.usimTar, 'B00003'); { role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '' },
{ role: 'usimRfm', tar: 'B00003', msl: '1A', desc: '' },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' },
]);
assert.strictEqual(v.adm, '00112233'); assert.strictEqual(v.adm, '00112233');
}); });
test('saving a preset posts the form with spec-default TARs', async () => { test('saving a preset posts the form with the pre-settled TAR table', async () => {
const els = setup(); const els = setup();
els['cards-name'].value = 'New card'; els['cards-name'].value = 'New card';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' }); ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
globalThis._cardsKeysetCount = 1; globalThis._cardsKeysetCount = 1;
els['cards-tar'].value = ''; // the role fields are pre-settled by the markup; the test's fake elements
els['cards-uicc-tar'].value = ''; // start empty - the defaults fill in
els['cards-usim-tar'].value = '';
els['cards-adm'].value = 'aa bb'; els['cards-adm'].value = 'aa bb';
await cardsAdd(); await cardsAdd();
assert.strictEqual(apiCalls.length, 1, JSON.stringify(apiCalls)); assert.strictEqual(apiCalls.length, 1, JSON.stringify(apiCalls));
@@ -214,16 +307,37 @@ test('saving a preset posts the form with spec-default TARs', async () => {
assert.strictEqual(apiCalls[0].body.name, 'New card'); assert.strictEqual(apiCalls[0].body.name, 'New card');
assert.deepStrictEqual(apiCalls[0].body.keysets, [ assert.deepStrictEqual(apiCalls[0].body.keysets, [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '' }]); { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '' }]);
assert.strictEqual(apiCalls[0].body.tar, '000000'); assert.deepStrictEqual(apiCalls[0].body.tars, [
assert.strictEqual(apiCalls[0].body.uiccTar, 'B00000'); { role: 'isd', tar: '000000', msl: '16', desc: '' },
assert.strictEqual(apiCalls[0].body.usimTar, 'B00001'); { role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '' },
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '' },
]);
assert.strictEqual(apiCalls[0].body.adm, 'AABB'); assert.strictEqual(apiCalls[0].body.adm, 'AABB');
}); });
test('saving refuses a free TAR row without its MSL or with a duplicate TAR', async () => {
const els = setup();
els['cards-name'].value = 'New card';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
globalThis._cardsKeysetCount = 1;
const seen = [];
globalThis.alert = msg => seen.push(msg);
tarRow(els, 0, { tar: 'AF4D02', msl: '', desc: '' });
_cardsTarCount = 1;
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /MSL/);
// a TAR may appear once
seen.length = 0;
tarRow(els, 0, { tar: '000000', msl: '16', desc: '' });
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /Duplicate TAR/);
});
test('editing a preset updates it by its store id', async () => { test('editing a preset updates it by its store id', async () => {
const els = setup(); const els = setup();
globalThis.cards = [{ id: 'abc123', name: 'Old', keysets: [ globalThis.cards = [presetFixture({ id: 'abc123', name: 'Old' })];
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
globalThis._cardsEditIdx = 0; globalThis._cardsEditIdx = 0;
els['cards-name'].value = 'Renamed'; els['cards-name'].value = 'Renamed';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' }); ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
@@ -232,6 +346,7 @@ test('editing a preset updates it by its store id', async () => {
assert.strictEqual(apiCalls[0].path, '/api/presets/update'); assert.strictEqual(apiCalls[0].path, '/api/presets/update');
assert.strictEqual(apiCalls[0].body.id, 'abc123'); assert.strictEqual(apiCalls[0].body.id, 'abc123');
assert.strictEqual(apiCalls[0].body.fields.name, 'Renamed'); assert.strictEqual(apiCalls[0].body.fields.name, 'Renamed');
assert.strictEqual(apiCalls[0].body.fields.tars.length, 3);
}); });
test('removing a preset deletes it in the store', async () => { test('removing a preset deletes it in the store', async () => {
@@ -284,72 +399,91 @@ test('the SCP80 counter edit is written to the store, a reported counter only lo
assert.strictEqual(apiCalls.length, 0); assert.strictEqual(apiCalls.length, 0);
}); });
test('applying a preset uses the TAR of the current operation', () => { test('applying a preset uses the TAR of the current operation and seeds the SPI1 from its MSL', () => {
const els = setup(); const els = setup();
globalThis.cards = [{ globalThis.cards = [presetFixture({ tars: [
name: 'C', kic: '15', kid: '15', spi1: '16', spi2: '01', { role: 'isd', tar: '000000', msl: '16' },
tar: '000000', uiccTar: 'B0000C', usimTar: 'B0000D', { role: 'uiccRfm', tar: 'B0000C', msl: '0A' },
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', { role: 'usimRfm', tar: 'B0000D', msl: '1A' }] })];
}]; globalThis._spTarKey = 'isd';
globalThis._spTarKey = 'tar';
cardsApply(0); cardsApply(0);
assert.strictEqual(els['sp-tar'].value, '000000'); assert.strictEqual(els['sp-tar'].value, '000000');
globalThis._spTarKey = 'uiccTar'; assert.strictEqual(els['sp-spi1'].value, '16');
globalThis._spTarKey = 'uiccRfm';
cardsApply(0); cardsApply(0);
assert.strictEqual(els['sp-tar'].value, 'B0000C'); assert.strictEqual(els['sp-tar'].value, 'B0000C');
globalThis._spTarKey = 'usimTar'; assert.strictEqual(els['sp-spi1'].value, '0A');
globalThis._spTarKey = 'usimRfm';
cardsApply(0); cardsApply(0);
assert.strictEqual(els['sp-tar'].value, 'B0000D'); assert.strictEqual(els['sp-tar'].value, 'B0000D');
// empty stored TAR -> spec default assert.strictEqual(els['sp-spi1'].value, '1A');
globalThis.cards = [{ name: 'D', kic: '15', kid: '15', tar: '' }]; // an empty stored TAR -> spec default
globalThis._spTarKey = 'tar'; globalThis.cards = [{ name: 'D', keysets: [], tars: [] }];
globalThis._spTarKey = 'isd';
cardsApply(0); cardsApply(0);
assert.strictEqual(els['sp-tar'].value, '000000'); assert.strictEqual(els['sp-tar'].value, '000000');
assert.strictEqual(els['sp-spi1'].value, '16');
}); });
test('RAM card selection forces the ISD TAR', () => { test('RAM card selection forces the ISD TAR', () => {
const els = setup(); const els = setup();
globalThis.cards = [{ name: 'C', kic: '15', kid: '15', tar: '000000', uiccTar: 'B0000C', usimTar: 'B0000D' }]; globalThis.cards = [presetFixture()];
globalThis._spTarKey = 'usimTar'; globalThis._spTarKey = 'usimRfm';
ramApplyCard('0'); ramApplyCard('0');
assert.strictEqual(globalThis._spTarKey, 'tar'); assert.strictEqual(globalThis._spTarKey, 'isd');
assert.strictEqual(els['sp-tar'].value, '000000'); assert.strictEqual(els['sp-tar'].value, '000000');
}); });
test('spTarKeyForPack maps each chain to its preset TAR field', () => { test('spTarKeyForPack maps each chain to its preset TAR role', () => {
assert.strictEqual(spTarKeyForPack('chain-ram-preview'), 'tar'); assert.strictEqual(spTarKeyForPack('chain-ram-preview'), 'isd');
assert.strictEqual(spTarKeyForPack('chain-sim-preview'), 'uiccTar'); assert.strictEqual(spTarKeyForPack('chain-sim-preview'), 'uiccRfm');
assert.strictEqual(spTarKeyForPack('chain-usim-preview'), 'usimTar'); assert.strictEqual(spTarKeyForPack('chain-usim-preview'), 'usimRfm');
assert.strictEqual(spTarKeyForPack('ber-result'), null); assert.strictEqual(spTarKeyForPack('ber-result'), null);
assert.strictEqual(spTarKeyForPack('hota-preview'), null); assert.strictEqual(spTarKeyForPack('hota-preview'), null);
}); });
test('spPresetTar reads the selected preset and falls back to the spec defaults', () => { test('spPresetTar reads the selected preset and falls back to the spec defaults', () => {
const els = setup(); const els = setup();
globalThis.cards = [{ name: 'A', tar: '000000', uiccTar: 'B0000A' }]; globalThis.cards = [presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B0000A', msl: '16' }] })];
els['sp-card-sel'].value = '0'; els['sp-card-sel'].value = '0';
assert.strictEqual(spPresetTar('uiccTar'), 'B0000A'); assert.strictEqual(spPresetTar('uiccRfm'), 'B0000A');
assert.strictEqual(spPresetTar('usimTar'), 'B00001'); assert.strictEqual(spPresetTar('usimRfm'), 'B00001');
els['sp-card-sel'].value = ''; els['sp-card-sel'].value = '';
assert.strictEqual(spPresetTar('uiccTar'), 'B00000'); assert.strictEqual(spPresetTar('uiccRfm'), 'B00000');
});
test('the TAR field suggestions come from the preset table', () => {
const els = setup();
spTarListRender(presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16' },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' }] }));
assert.match(els['sp-tar-list'].innerHTML, /value="000000"[^>]*label="ISD \(MSL 16\)"/);
assert.match(els['sp-tar-list'].innerHTML, /value="AF4D02"[^>]*label="My applet \(MSL 0A\)"/);
spTarListRender(null);
assert.strictEqual(els['sp-tar-list'].innerHTML, '');
}); });
test('packing a chain selects the TAR of its target', () => { test('packing a chain selects the TAR of its target', () => {
const els = setup(); const els = setup();
globalThis.cards = [{ name: 'C', tar: '000000', uiccTar: 'B0000C', usimTar: 'B0000D' }]; globalThis.cards = [presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B0000C', msl: '16' },
{ role: 'usimRfm', tar: 'B0000D', msl: '16' }] })];
els['sp-card-sel'].value = '0'; els['sp-card-sel'].value = '0';
els['chain-sim-preview'].value = 'A0A4000002'; els['chain-sim-preview'].value = 'A0A4000002';
packToSp('chain-sim-preview'); packToSp('chain-sim-preview');
assert.strictEqual(globalThis._spTarKey, 'uiccTar'); assert.strictEqual(globalThis._spTarKey, 'uiccRfm');
assert.strictEqual(els['sp-tar'].value, 'B0000C'); assert.strictEqual(els['sp-tar'].value, 'B0000C');
assert.strictEqual(els['sp-apdu'].value, 'A0A4000002'); assert.strictEqual(els['sp-apdu'].value, 'A0A4000002');
els['chain-usim-preview'].value = '00A4000002'; els['chain-usim-preview'].value = '00A4000002';
packToSp('chain-usim-preview'); packToSp('chain-usim-preview');
assert.strictEqual(globalThis._spTarKey, 'usimTar'); assert.strictEqual(globalThis._spTarKey, 'usimRfm');
assert.strictEqual(els['sp-tar'].value, 'B0000D'); assert.strictEqual(els['sp-tar'].value, 'B0000D');
els['chain-ram-preview'].value = '80E29000'; els['chain-ram-preview'].value = '80E29000';
packToSp('chain-ram-preview'); packToSp('chain-ram-preview');
assert.strictEqual(globalThis._spTarKey, 'tar'); assert.strictEqual(globalThis._spTarKey, 'isd');
assert.strictEqual(els['sp-tar'].value, '000000'); assert.strictEqual(els['sp-tar'].value, '000000');
// BER / HTTP OTA packs leave the TAR alone // BER / HTTP OTA packs leave the TAR alone
els['sp-tar'].value = 'ABCDEF'; els['sp-tar'].value = 'ABCDEF';
@@ -371,3 +505,24 @@ test('packing without a selected preset uses the spec default TAR', () => {
packToSp('chain-ram-preview'); packToSp('chain-ram-preview');
assert.strictEqual(els['sp-tar'].value, '000000'); assert.strictEqual(els['sp-tar'].value, '000000');
}); });
test('the TAR editor renders, adds and removes free rows', () => {
const els = setup();
cardsTarRowsRender([{ tar: 'AF4D01', msl: '0A', desc: 'one' },
{ tar: 'AF4D02', msl: '0A', desc: 'two' }]);
assert.strictEqual(_cardsTarCount, 2);
assert.match(els['cards-tars'].innerHTML, /cards-ft-1-tar"[^>]*value="AF4D02"/);
// simulate the DOM the innerHTML produced
tarRow(els, 0, { tar: 'AF4D01', msl: '0A', desc: 'one' });
tarRow(els, 1, { tar: 'AF4D02', msl: '0A', desc: 'two' });
cardsTarRemove(0);
assert.strictEqual(_cardsTarCount, 1);
assert.match(els['cards-tars'].innerHTML, /value="AF4D02"/);
assert.doesNotMatch(els['cards-tars'].innerHTML, /value="AF4D01"/);
tarRow(els, 0, { tar: 'AF4D02', msl: '0A', desc: 'two' });
cardsTarAdd();
assert.strictEqual(_cardsTarCount, 2);
// the new row renders empty
tarRow(els, 1, { tar: '', msl: '', desc: '' });
assert.deepStrictEqual(cardsFreeTarsFromForm(), [{ tar: 'AF4D02', msl: '0A', desc: 'two' }]);
});
+13 -3
View File
@@ -5,6 +5,12 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
function extractFunc(src, name) { function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src); const m = re.exec(src);
@@ -25,9 +31,13 @@ let code = '';
for (const fn of ['swapNibbles', 'encIccid', 'decIccid', for (const fn of ['swapNibbles', 'encIccid', 'decIccid',
'cardsNormIccid', 'cardsFindByIccid', 'cardsFindDuplicateIccid', 'cardsNormIccid', 'cardsFindByIccid', 'cardsFindDuplicateIccid',
'cardsIccidFromCard', 'cardsAutoSelectByIccid', 'cardsRender', 'cardsIccidFromCard', 'cardsAutoSelectByIccid', 'cardsRender',
'esc', 'spKeysetList', 'spKeysetKvnOf', 'cardsTarValue', 'cardsScp81Complete']) { 'esc', 'spKeysetList', 'spKeysetKvnOf',
'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar', 'cardsRoleMsl',
'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'cardsScp81Complete']) {
code += extractFunc(html, fn) + '\n'; code += extractFunc(html, fn) + '\n';
} }
code += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
+ extractConst(html, 'CARDS_MSL_DEFAULT');
eval(code); eval(code);
// live-card style raw EF.ICCID content (nibble-swapped digits + 'F' pad) // live-card style raw EF.ICCID content (nibble-swapped digits + 'F' pad)
@@ -176,8 +186,8 @@ test('cardsRender highlights the equipped card preset', () => {
const els = { 'cards-tbody': { innerHTML: '' } }; const els = { 'cards-tbody': { innerHTML: '' } };
globalThis.document = { getElementById: id => els[id] || null }; globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [ globalThis.cards = [
{ name: 'other', iccid: '1111111111111111111', spi1: '16', spi2: '01', keysets: [] }, { name: 'other', iccid: '1111111111111111111', keysets: [] },
{ name: 'live', iccid: DIGITS, spi1: '16', spi2: '01', keysets: [] }, { name: 'live', iccid: DIGITS, keysets: [] },
]; ];
globalThis._pysimCardIccid = DIGITS; globalThis._pysimCardIccid = DIGITS;
cardsRender(); cardsRender();
+7 -2
View File
@@ -144,8 +144,13 @@ test('the card form groups the SCP80 and SCP81 fields into labelled fieldsets',
assert.match(scp80, />ISD TAR<\/label>/); assert.match(scp80, />ISD TAR<\/label>/);
assert.match(scp80, />UICC RFM TAR<\/label>/); assert.match(scp80, />UICC RFM TAR<\/label>/);
assert.match(scp80, />ADF RFM TAR<\/label>/); assert.match(scp80, />ADF RFM TAR<\/label>/);
assert.ok(scp80.indexOf('id="cards-spi1"') < scp80.indexOf('id="cards-tar"'), // the preset-wide SPI1/SPI2 inputs are gone: each TAR carries its MSL
'TAR fields must follow the SPI fields'); assert.ok(!scp80.includes('id="cards-spi1"'), 'the preset-wide SPI1 input is gone');
assert.ok(!scp80.includes('id="cards-spi2"'), 'the preset-wide SPI2 input is gone');
assert.ok(scp80.indexOf('id="cards-tar"') < scp80.indexOf('id="cards-tar-msl"'),
'the ISD MSL input follows its TAR input');
assert.match(scp80, /id="cards-tars"/);
assert.match(scp80, /onclick="cardsTarAdd\(\)"/);
assert.match(scp81, /id="cards-psk-id"/); assert.match(scp81, /id="cards-psk-id"/);
assert.match(scp81, /id="cards-psk-key"/); assert.match(scp81, /id="cards-psk-key"/);
// the PSK explanation lives inside the SCP81 group, not outside it // the PSK explanation lives inside the SCP81 group, not outside it
+13 -2
View File
@@ -5,6 +5,12 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
function extractFunc(src, name) { function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src); const m = re.exec(src);
@@ -31,6 +37,8 @@ code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n';
code += extractFunc(html, 'pysimUpdateIccidIndicator') + '\n'; code += extractFunc(html, 'pysimUpdateIccidIndicator') + '\n';
code += extractFunc(html, 'pysimUpdatePresetIndicator') + '\n'; code += extractFunc(html, 'pysimUpdatePresetIndicator') + '\n';
code += extractFunc(html, 'pysimUpdatePresetIndicators') + '\n'; code += extractFunc(html, 'pysimUpdatePresetIndicators') + '\n';
code += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES') + extractConst(html, 'CARDS_MSL_DEFAULT');
code += extractFunc(html, 'cardsTarList') + '\n';
code += extractFunc(html, 'cardsScp80Complete') + '\n'; code += extractFunc(html, 'cardsScp80Complete') + '\n';
code += extractFunc(html, 'cardsScp81Complete') + '\n'; code += extractFunc(html, 'cardsScp81Complete') + '\n';
code += extractFunc(html, 'cardsMatchedPreset') + '\n'; code += extractFunc(html, 'cardsMatchedPreset') + '\n';
@@ -176,8 +184,11 @@ test('header prints the equipped card ICCID next to the card image', () => {
test('SCP80/SCP81 markers follow the matching preset completeness', () => { test('SCP80/SCP81 markers follow the matching preset completeness', () => {
const els = setup(); const els = setup();
globalThis.cards = [{ name: 'C', kic: '15', kid: '15', spi1: '16', spi2: '01', globalThis.cards = [{ name: 'C', kic: '15', kid: '15',
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY' }]; cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY',
tars: [{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
globalThis.cardsFindByIccid = () => 0; globalThis.cardsFindByIccid = () => 0;
_pysimCardIccid = '89701450001700031958'; _pysimCardIccid = '89701450001700031958';
pysimUpdatePresetIndicators(); pysimUpdatePresetIndicators();
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.9.1" version = "3.10.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+115 -13
View File
@@ -9,6 +9,17 @@ owns the store: an accepted counter is persisted in the same operation, and a
closed tab, a lost response or a second browser window can no longer lose an closed tab, a lost response or a second browser window can no longer lose an
increment. increment.
A preset holds a **TAR table**: every Remote Management application the card
exposes is addressed by its TAR, and each carries its own **Minimum Security
Level** (MSL) - the "Minimum SPI1" the card checks before it processes a
secured packet (TS 102 226 6.1/8.2.1.3.2.4; a too-low SPI1 is answered with
response status 0A "Insufficient security level"). The three role entries
(`isd`, `uiccRfm`, `usimRfm`) are mandatory - the Cards form pre-settles their
values - and further TARs can be added with an optional description. A packet
for a TAR derives its SPI1 from that TAR's MSL; the preset no longer carries a
card-wide SPI1/SPI2 (SPI2 is an operation property: whether and how a Proof of
Receipt is requested).
A preset holds **several keysets**; the b8..b5 nibble of KIc/KID numbers them A preset holds **several keysets**; the b8..b5 nibble of KIc/KID numbers them
(TS 102 225 5.1.2/A.2). Each keyset has its own KIc/KID keys and its own (TS 102 225 5.1.2/A.2). Each keyset has its own KIc/KID keys and its own
counter - "a dedicated counter shall be associated to each key version" counter - "a dedicated counter shall be associated to each key version"
@@ -30,7 +41,9 @@ deliberately human-readable: hand-editing a counter or moving the file between
machines is part of the workbench workflow, and the Cards tab's export/import machines is part of the workbench workflow, and the Cards tab's export/import
uses the same shape (which is also how old localStorage presets move over - uses the same shape (which is also how old localStorage presets move over -
there is no automatic migration). v3.8.0's flat ``kic/kid/kicKey/kidKey/cntr`` there is no automatic migration). v3.8.0's flat ``kic/kid/kicKey/kidKey/cntr``
shape is converted into a single keyset on load and on import. shape is converted into a single keyset, and a v3.9.1 preset's card-wide
``spi1``/``tar``/``uiccTar``/``usimTar`` become the three role entries (the
spi1 as their MSL) - on load and on import alike.
""" """
import copy import copy
@@ -46,21 +59,28 @@ from pathlib import Path
DEFAULT_DIR = '.pysim-simple-server' DEFAULT_DIR = '.pysim-simple-server'
DEFAULT_FILENAME = 'card_presets.json' DEFAULT_FILENAME = 'card_presets.json'
SCHEMA_VERSION = 2 SCHEMA_VERSION = 3
# The fields a preset carries: the localStorage shape plus the id. Defaults # The fields a preset carries: the localStorage shape plus the id. The OTA key
# mirror the Cards form (spi1/spi2 and the per-target TARs). The OTA key
# material lives in `keysets` - the b8..b5 nibble of KIc/KID numbers them, # material lives in `keysets` - the b8..b5 nibble of KIc/KID numbers them,
# each with its own counter (TS 102 225 Annex A.1: "a dedicated counter shall # each with its own counter (TS 102 225 Annex A.1: "a dedicated counter shall
# be associated to each key version"). # be associated to each key version"); the TAR table lives in `tars` (see
PRESET_FIELDS = ('name', 'iccid', 'adm', 'spi1', 'spi2', # TAR_ROLES).
'tar', 'uiccTar', 'usimTar', 'pskIdentity', 'pskKey') PRESET_FIELDS = ('name', 'iccid', 'adm', 'pskIdentity', 'pskKey')
# The v3.8.0 flat key fields: converted into a single keyset on load/import. # The v3.8.0 flat key fields: converted into a single keyset on load/import.
LEGACY_KEY_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr') LEGACY_KEY_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
KEYSET_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr') KEYSET_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
HEX_FIELDS = ('adm', 'spi1', 'spi2', 'tar', 'uiccTar', 'usimTar', 'pskKey') HEX_FIELDS = ('adm', 'pskKey')
KEYSET_HEX_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr') KEYSET_HEX_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
TAR_DEFAULTS = {'tar': '000000', 'uiccTar': 'B00000', 'usimTar': 'B00001'} # The TAR table: the three mandatory role entries (their values are pre-settled
# in the Cards form) and free entries with an optional description. Every
# entry carries the TAR and its MSL (Minimum SPI1, one hex byte).
TAR_ROLES = ('isd', 'uiccRfm', 'usimRfm')
TAR_ROLE_DEFAULTS = {
'isd': {'tar': '000000', 'msl': '16'},
'uiccRfm': {'tar': 'B00000', 'msl': '16'},
'usimRfm': {'tar': 'B00001', 'msl': '16'},
}
COUNTER_BITS = 40 # the SCP80 counter is 5 bytes (TS 31.115) COUNTER_BITS = 40 # the SCP80 counter is 5 bytes (TS 31.115)
COUNTER_WIDTH = 10 # ... shown as 10 hex digits COUNTER_WIDTH = 10 # ... shown as 10 hex digits
KVN_MAX = 0x0F # keyset number '00' is reserved: no key set, no counter KVN_MAX = 0x0F # keyset number '00' is reserved: no key set, no counter
@@ -134,6 +154,32 @@ def counter_ahead(old, new):
return (o - n) > (1 << (COUNTER_BITS - 1)) return (o - n) > (1 << (COUNTER_BITS - 1))
def tar_entry(preset, tar_hex):
"""The preset's TAR entry whose TAR matches `tar_hex` (case-insensitive),
or None."""
want = re.sub(r'\s', '', str(tar_hex or '')).upper()
if not want:
return None
for t in (preset or {}).get('tars') or []:
if str(t.get('tar') or '').upper() == want:
return dict(t)
return None
def role_tar(preset, role):
"""The TAR of a role entry ('isd' | 'uiccRfm' | 'usimRfm'), or ''."""
for t in (preset or {}).get('tars') or []:
if t.get('role') == role:
return str(t.get('tar') or '')
return ''
def tar_msl(preset, tar_hex):
"""The MSL (Minimum SPI1) of a TAR, or '' when the preset has no such
entry or no MSL for it."""
return str((tar_entry(preset, tar_hex) or {}).get('msl') or '')
class PresetError(Exception): class PresetError(Exception):
"""A refused mutation (validation, duplicate ICCID, ...).""" """A refused mutation (validation, duplicate ICCID, ...)."""
@@ -234,10 +280,7 @@ class PresetStore:
if k in HEX_FIELDS: if k in HEX_FIELDS:
v = re.sub(r'\s', '', v).upper() v = re.sub(r'\s', '', v).upper()
out[k] = v out[k] = v
out['spi1'] = out['spi1'] or '16' out['tars'] = self._normalise_tars(fields)
out['spi2'] = out['spi2'] or '01'
for k, d in TAR_DEFAULTS.items():
out[k] = out[k] or d
# Keysets: a v3.8.0 preset (flat kic/kid/kicKey/kidKey/cntr) converts # Keysets: a v3.8.0 preset (flat kic/kid/kicKey/kidKey/cntr) converts
# into a single keyset, so old files and exports keep working. # into a single keyset, so old files and exports keep working.
raw = fields.get('keysets') raw = fields.get('keysets')
@@ -251,6 +294,51 @@ class PresetStore:
out['id'] = pid or uuid.uuid4().hex out['id'] = pid or uuid.uuid4().hex
return out return out
def _normalise_tars(self, fields):
"""The preset's TAR table: the three mandatory role entries (values
filled from the defaults when empty, so a partial API call and the
pre-settled form both work) followed by the free entries in input
order. A v3.9.1 preset (tar/uiccTar/usimTar + the card-wide spi1)
converts: the spi1 becomes the MSL of all three roles."""
raw = fields.get('tars')
entries = []
if isinstance(raw, list):
for t in raw:
if not isinstance(t, dict):
continue
role = str(t.get('role') or '').strip()
entries.append({
'role': role if role in TAR_ROLES else '',
'tar': re.sub(r'\s', '', str(t.get('tar') or '')).upper(),
'msl': re.sub(r'\s', '', str(t.get('msl') or '')).upper(),
'desc': str(t.get('desc') or '').strip(),
})
else:
msl = re.sub(r'\s', '', str(fields.get('spi1') or '')).upper()
for role, src in (('isd', 'tar'), ('uiccRfm', 'uiccTar'),
('usimRfm', 'usimTar')):
entries.append({
'role': role,
'tar': re.sub(r'\s', '', str(fields.get(src) or '')).upper(),
'msl': msl,
'desc': '',
})
out = []
for role in TAR_ROLES:
cur = next((e for e in entries if e['role'] == role), None)
d = TAR_ROLE_DEFAULTS[role]
out.append({'role': role,
'tar': (cur or {}).get('tar') or d['tar'],
'msl': (cur or {}).get('msl') or d['msl'],
'desc': ''})
for e in entries:
if e['role']:
continue
if not (e['tar'] or e['msl'] or e['desc']):
continue # a blank editor row
out.append(e)
return out
def _normalise_keyset(self, ks): def _normalise_keyset(self, ks):
"""One keyset, cleaned. The counter is kept as typed here and """One keyset, cleaned. The counter is kept as typed here and
validated + padded in _validate_keyset() - a bad value must be validated + padded in _validate_keyset() - a bad value must be
@@ -281,6 +369,18 @@ class PresetStore:
if kvn in seen: if kvn in seen:
raise PresetError('duplicate keyset number %02X in this preset' % kvn) raise PresetError('duplicate keyset number %02X in this preset' % kvn)
seen.add(kvn) seen.add(kvn)
seen_tars = set()
for t in p['tars']:
if not re.fullmatch(r'[0-9A-F]{6}', t['tar']):
raise PresetError('TAR must be three hex bytes: %r' % t['tar'])
if not t['msl']:
raise PresetError('TAR %s needs its MSL (Minimum SPI1, one hex '
'byte, TS 102 226 8.2.1.3.2.4)' % t['tar'])
if not re.fullmatch(r'[0-9A-F]{2}', t['msl']):
raise PresetError('MSL must be one hex byte: %r' % t['msl'])
if t['tar'] in seen_tars:
raise PresetError('duplicate TAR %s in this preset' % t['tar'])
seen_tars.add(t['tar'])
norm = normalize_iccid(p['iccid']) norm = normalize_iccid(p['iccid'])
if norm: if norm:
for other in self._presets: for other in self._presets:
@@ -372,6 +472,8 @@ class PresetStore:
merged[k] = fields[k] merged[k] = fields[k]
if isinstance(fields, dict) and 'keysets' in fields: if isinstance(fields, dict) and 'keysets' in fields:
merged['keysets'] = fields['keysets'] merged['keysets'] = fields['keysets']
if isinstance(fields, dict) and isinstance(fields.get('tars'), list):
merged['tars'] = fields['tars']
new = self._normalise(merged) new = self._normalise(merged)
new['id'] = cur['id'] new['id'] = cur['id']
self._validate(new, skip_id=cur['id']) self._validate(new, skip_id=cur['id'])
+153 -40
View File
@@ -32,7 +32,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.9.1' VERSION = '3.10.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -1503,6 +1503,57 @@ def _preset_counter_persist(server, preset_id, cntr, source, kvn=None):
sys.stderr.write('PRESETS: counter persist failed (%s): %s\n' % (source, e)) sys.stderr.write('PRESETS: counter persist failed (%s): %s\n' % (source, e))
def _preset_by_id(server, preset_id):
"""The named card preset, or None (no store configured / unknown id)."""
store = getattr(server, 'card_presets', None)
if store is None or not preset_id:
return None
return store.get(preset_id)
def _hex_lt(a, b):
"""Numeric ``a < b`` for two hex byte strings; invalid input is never a
comparison (the packet builder's own validation reports it)."""
try:
return int(a, 16) < int(b, 16)
except (TypeError, ValueError):
return False
def _spi1_for_tar(preset, tar_hex, explicit='', prefer_msl=False):
"""Resolve the SPI1 of a packet addressed to `tar_hex` from the preset's
TAR table.
Each TAR carries its **MSL** (Minimum SPI1, TS 102 226 8.2.1.3.2.4): the
card checks it before the security processing and answers response status
0A "Insufficient security level" when the packet's SPI1 is below it, so the
MSL is the natural value for packets to that TAR.
``prefer_msl=False`` (a hand send): the caller's explicit SPI1 wins - the
operator may deliberately send more security than the minimum - and a value
below the TAR's MSL only produces a warning. ``prefer_msl=True`` (an
operation): the TAR's MSL wins; the explicit value is the fallback for a
TAR the preset does not carry (e.g. the TAR probe's checklist). Raises
ValueError when neither exists - guessing would either be refused by the
card or send more security than the application expects.
Returns ``(spi1, warning)``."""
explicit = str(explicit or '').strip().upper()
msl = presets.tar_msl(preset, tar_hex)
if prefer_msl and msl:
return msl, ''
if explicit:
warning = ''
if msl and _hex_lt(explicit, msl):
warning = ('SPI1 %s is below the MSL %s of TAR %s - the card may '
'answer 0A (insufficient security level)'
% (explicit, msl, tar_hex))
return explicit, warning
if msl:
return msl, ''
raise ValueError('no MSL for TAR %s in the card preset - set it in the '
'Cards tab or pass spi1' % (tar_hex or '?'))
# RAM command formats (TS 102 226 5.2.1): the bare C-APDU (compact) or the # RAM command formats (TS 102 226 5.2.1): the bare C-APDU (compact) or the
# Command TLV '22' inside the 'AA' scripting template (expanded) - the form # Command TLV '22' inside the 'AA' scripting template (expanded) - the form
# the reference terminal traces use. The format is detected per operation # the reference terminal traces use. The format is detected per operation
@@ -1656,7 +1707,8 @@ def _counter_probe_params(preset, body):
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValueError('invalid attempt budget') raise ValueError('invalid attempt budget')
max_attempts = max(1, min(64, max_attempts)) max_attempts = max(1, min(64, max_attempts))
spi1 = str(body.get('spi1') or preset.get('spi1') or '16').strip().upper() tar = str(body.get('tar') or presets.role_tar(preset, 'isd') or '').strip().upper()
spi1, _ = _spi1_for_tar(preset, tar, body.get('spi1'), prefer_msl=True)
if not _counter_tracked(spi1): if not _counter_tracked(spi1):
raise ValueError('the probe needs a counter check - SPI1 %s has none ' raise ValueError('the probe needs a counter check - SPI1 %s has none '
'(b5b4 = 00)' % spi1) '(b5b4 = 00)' % spi1)
@@ -1666,8 +1718,8 @@ def _counter_probe_params(preset, body):
'start': start, 'ceiling': ceiling, 'start': start, 'ceiling': ceiling,
'max_attempts': max_attempts, 'max_attempts': max_attempts,
'spi1': spi1, 'spi1': spi1,
'spi2': str(body.get('spi2') or preset.get('spi2') or '21').strip().upper(), 'spi2': str(body.get('spi2') or '01').strip().upper(),
'tar': str(body.get('tar') or preset.get('tar') or '000000').strip().upper(), 'tar': tar,
# the probe command is fixed: read-only SELECT MF, which every applet # the probe command is fixed: read-only SELECT MF, which every applet
# answers (an error SW is fine - the counter advances on the packet's # answers (an error SW is fine - the counter advances on the packet's
# security acceptance, not on the command's own result) # security acceptance, not on the command's own result)
@@ -5594,10 +5646,12 @@ def _test_preset_error(script, preset):
if not _keyset_complete(ks): if not _keyset_complete(ks):
return ('step %d: keyset %d is incomplete (KIc, KID, both ' return ('step %d: keyset %d is incomplete (KIc, KID, both '
'keys and a counter)' % (i + 1, want)) 'keys and a counter)' % (i + 1, want))
if not (p.get('tar') or preset.get('tar')): tar = p.get('tar') or presets.role_tar(preset, 'isd')
if not tar:
return 'step %d: no TAR (neither in the step nor in the preset)' % (i + 1) return 'step %d: no TAR (neither in the step nor in the preset)' % (i + 1)
if not (p.get('spi1') or preset.get('spi1')) or not (p.get('spi2') or preset.get('spi2')): if not (p.get('spi1') or presets.tar_msl(preset, tar)):
return 'step %d: no SPI1/SPI2 (neither in the step nor in the preset)' % (i + 1) return ('step %d: no SPI1 for TAR %s (neither in the step nor as '
'its MSL in the preset)' % (i + 1, tar))
return None return None
@@ -5742,9 +5796,14 @@ def _test_run_scp80(server, step, ctx):
kvn, kvn_err = _kvn_of(kic, kid) kvn, kvn_err = _kvn_of(kic, kid)
if kvn_err: if kvn_err:
raise testscript.ScriptError(kvn_err) raise testscript.ScriptError(kvn_err)
spi1 = p.get('spi1') or preset.get('spi1') or '16' tar = p.get('tar') or presets.role_tar(preset, 'isd') or ''
spi2 = p.get('spi2') or preset.get('spi2') or '01' try:
tar = p.get('tar') or preset.get('tar') or '' # a step may override the SPI1 deliberately (the test runner sends what
# the operator asked for); the TAR's MSL is the default when absent
spi1, _ = _spi1_for_tar(preset, tar, p.get('spi1'))
except ValueError as e:
raise testscript.ScriptError(str(e))
spi2 = p.get('spi2') or '01'
counters = ctx.setdefault('counters', {}) counters = ctx.setdefault('counters', {})
if kvn and kvn not in counters: if kvn and kvn not in counters:
counters[kvn] = (keyset or {}).get('cntr') or '00000000' counters[kvn] = (keyset or {}).get('cntr') or '00000000'
@@ -7210,14 +7269,29 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 400) self._send_json(resp, 400)
self._log_resp(resp) self._log_resp(resp)
return return
# SPI1 for the packet: the caller's value wins (a hand send may
# deliberately ask for more security than the TAR's minimum), with
# a warning below the TAR's MSL; without one it is derived from the
# MSL of the packet's TAR (TS 102 226 8.2.1.3.2.4 - the card checks
# the MSL before the security processing).
try:
spi1, spi_warn = _spi1_for_tar(
_preset_by_id(self.server, body.get('preset_id')),
str(body.get('tar') or '').strip().upper(), body.get('spi1'))
except ValueError as e:
resp = {'error': str(e)}
self._send_json(resp, 400)
self._log_resp(resp)
return
spi2 = str(body.get('spi2') or '01').strip().upper()
# Only a packet whose SPI1 asks the card to check the counter has a # Only a packet whose SPI1 asks the card to check the counter has a
# counter to track (TS 102 225 5.1.1 b5b4): with b5b4 = 00 (the # counter to track (TS 102 225 5.1.1 b5b4): with b5b4 = 00 (the
# keyless SPI1 00 case) the field is ignored and never updated, so # keyless SPI1 00 case) the field is ignored and never updated, so
# no counter is advanced or persisted. # no counter is advanced or persisted.
counter_tracked = _counter_tracked(body.get('spi1', '')) counter_tracked = _counter_tracked(spi1)
if body.get('cntr') and not counter_tracked: if body.get('cntr') and not counter_tracked:
sys.stderr.write('OTA SEND: counter not tracked (SPI1 %s: ' sys.stderr.write('OTA SEND: counter not tracked (SPI1 %s: '
'no counter check)\n' % body.get('spi1', '')) 'no counter check)\n' % spi1)
include_cpi = body.get('includeCpi', True) include_cpi = body.get('includeCpi', True)
ram_format = None ram_format = None
try: try:
@@ -7225,8 +7299,6 @@ class PysimHandler(BaseHTTPRequestHandler):
# RAM operation: SCP80-wrap the raw GP command. The packet # RAM operation: SCP80-wrap the raw GP command. The packet
# may exceed one SMS (pySim refuses that), so use our own # may exceed one SMS (pySim refuses that), so use our own
# encoder and let _send_secured_packet segment it. # encoder and let _send_secured_packet segment it.
spi1 = body.get('spi1', '16')
spi2 = body.get('spi2', '01')
kic = body.get('kic', '25') kic = body.get('kic', '25')
kid = body.get('kid', '25') kid = body.get('kid', '25')
tar = body.get('tar', '000000') tar = body.get('tar', '000000')
@@ -7256,7 +7328,7 @@ class PysimHandler(BaseHTTPRequestHandler):
# Regular SCP80: use pre-built secured packet # Regular SCP80: use pre-built secured packet
sp_hex = sp sp_hex = sp
cntr = body.get('cntr', '') cntr = body.get('cntr', '')
spi2_val = int(body.get('spi2', '00'), 16) spi2_val = int(spi2, 16)
# Capture the PoR from either transport: inline in the # Capture the PoR from either transport: inline in the
# ENVELOPE response or as a proactive SEND SHORT MESSAGE (some # ENVELOPE response or as a proactive SEND SHORT MESSAGE (some
# cards always submit it, whatever the SPI2 request bit says; # cards always submit it, whatever the SPI2 request bit says;
@@ -7269,7 +7341,7 @@ class PysimHandler(BaseHTTPRequestHandler):
scc._tp.proactive_handler = submit_handler scc._tp.proactive_handler = submit_handler
try: try:
sys.stderr.write('OTA SEND: SPI %s %s KIc %s KID %s TAR %s CNTR %s LEN %dB\n' % ( sys.stderr.write('OTA SEND: SPI %s %s KIc %s KID %s TAR %s CNTR %s LEN %dB\n' % (
body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''), spi1, spi2, body.get('kic', ''),
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''), body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''),
len(sp_hex) // 2)) len(sp_hex) // 2))
sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp) sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp)
@@ -7288,7 +7360,7 @@ class PysimHandler(BaseHTTPRequestHandler):
failed_por_hex = result.get('response_data') or '' failed_por_hex = result.get('response_data') or ''
if not failed_por_hex and submit_handler: if not failed_por_hex and submit_handler:
failed_por_hex = _sms_submit_por(submit_handler) failed_por_hex = _sms_submit_por(submit_handler)
failed_por = _decode_por(body.get('spi1', ''), body.get('spi2', ''), failed_por = _decode_por(spi1, spi2,
body.get('kic', ''), body.get('kid', ''), body.get('kic', ''), body.get('kid', ''),
body.get('cntr', ''), body.get('kicKey', ''), body.get('cntr', ''), body.get('kicKey', ''),
body.get('kidKey', ''), failed_por_hex) body.get('kidKey', ''), failed_por_hex)
@@ -7326,7 +7398,7 @@ class PysimHandler(BaseHTTPRequestHandler):
if idx >= 0: if idx >= 0:
por_hex = tpdu_b[idx:].hex() por_hex = tpdu_b[idx:].hex()
por_src = 'sms-submit' por_src = 'sms-submit'
por = _decode_por(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''), por = _decode_por(spi1, spi2, body.get('kic', ''),
body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''), body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''),
body.get('kidKey', ''), por_hex) body.get('kidKey', ''), por_hex)
# Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR # Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR
@@ -7384,6 +7456,8 @@ class PysimHandler(BaseHTTPRequestHandler):
if counter_tracked: if counter_tracked:
_preset_counter_persist(self.server, body.get('preset_id'), _preset_counter_persist(self.server, body.get('preset_id'),
resp.get('final_cntr'), 'send-ota', kvn) resp.get('final_cntr'), 'send-ota', kvn)
if spi_warn:
resp['warning'] = spi_warn
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
except Exception as e: except Exception as e:
@@ -7434,12 +7508,23 @@ class PysimHandler(BaseHTTPRequestHandler):
# Parse .cap file # Parse .cap file
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex) loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
# SCP80 params # SCP80 params: the SPI1 comes from the MSL of the packet's
spi1 = body.get('spi1', '16') # TAR (the operation uses the preset's table); a caller-supplied
# value is only the fallback for a TAR the preset does not carry.
preset = _preset_by_id(self.server, body.get('preset_id'))
tar = str(body.get('tar') or presets.role_tar(preset, 'isd')
or '000000').strip().upper()
try:
spi1, _ = _spi1_for_tar(preset, tar, body.get('spi1'),
prefer_msl=True)
except ValueError as e:
err = {'success': False, 'error': str(e)}
self._send_json(err, 400)
self._log_resp(err)
return
spi2 = body.get('spi2', '01') spi2 = body.get('spi2', '01')
kic = body.get('kic', '25') kic = body.get('kic', '25')
kid = body.get('kid', '25') kid = body.get('kid', '25')
tar = body.get('tar', '000000')
cntr = body.get('cntr', '00000000') cntr = body.get('cntr', '00000000')
kic_key = body.get('kicKey', '') kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '') kid_key = body.get('kidKey', '')
@@ -7612,9 +7697,9 @@ class PysimHandler(BaseHTTPRequestHandler):
stk_params=stk_params_hex, make_selectable=make_selectable) stk_params=stk_params_hex, make_selectable=make_selectable)
step_name = 'INSTALL [for install]' step_name = 'INSTALL [for install]'
sp_state = { sp_state = {
'spi1': body.get('spi1', '16'), 'spi2': body.get('spi2', '01'), 'spi1': spi1, 'spi2': body.get('spi2', '01'),
'kic': body.get('kic', '25'), 'kid': body.get('kid', '25'), 'kic': body.get('kic', '25'), 'kid': body.get('kid', '25'),
'tar': body.get('tar', '000000'), 'cntr': body.get('cntr', '00000000'), 'tar': tar, 'cntr': body.get('cntr', '00000000'),
'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''), 'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''),
'include_cpi': body.get('includeCpi', True)} 'include_cpi': body.get('includeCpi', True)}
# The keyset number must be consistent and defined in the preset # The keyset number must be consistent and defined in the preset
@@ -7723,11 +7808,20 @@ class PysimHandler(BaseHTTPRequestHandler):
'(block size %d)\n' % (loadfile_aid, boundary, '(block size %d)\n' % (loadfile_aid, boundary,
total_blocks, block_size)) total_blocks, block_size))
spi1 = body.get('spi1', '16') preset = _preset_by_id(self.server, body.get('preset_id'))
tar = str(body.get('tar') or presets.role_tar(preset, 'isd')
or '000000').strip().upper()
try:
spi1, _ = _spi1_for_tar(preset, tar, body.get('spi1'),
prefer_msl=True)
except ValueError as e:
err = {'success': False, 'error': str(e)}
self._send_json(err, 400)
self._log_resp(err)
return
spi2 = body.get('spi2', '01') spi2 = body.get('spi2', '01')
kic = body.get('kic', '25') kic = body.get('kic', '25')
kid = body.get('kid', '25') kid = body.get('kid', '25')
tar = body.get('tar', '000000')
cntr = body.get('cntr', '00000000') cntr = body.get('cntr', '00000000')
kic_key = body.get('kicKey', '') kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '') kid_key = body.get('kidKey', '')
@@ -7939,16 +8033,12 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp(err) self._log_resp(err)
return return
# The probe sends real secured packets: it needs a counter check # The probe sends real secured packets: it needs a counter check
# (every accepted packet advances and is persisted) and a PoR. # (every accepted packet advances and is persisted) and a PoR. Each
spi1 = str(body.get('spi1') or '16').strip().upper() # TAR uses its own MSL when the preset carries it; the caller's SPI1
# is the fallback for the arbitrary TARs the checklist probes.
spi1_base = str(body.get('spi1') or '').strip().upper()
spi2 = str(body.get('spi2') or '01').strip().upper() spi2 = str(body.get('spi2') or '01').strip().upper()
if not _counter_tracked(spi1): preset = _preset_by_id(self.server, body.get('preset_id'))
err = {'success': False,
'error': 'the TAR probe needs a counter check - SPI1 %s has '
'none (b5b4 = 00)' % spi1}
self._send_json(err, 400)
self._log_resp(err)
return
apdu = (body.get('apdu') or TAR_PROBE_DEFAULT_APDU).replace(' ', '').upper() apdu = (body.get('apdu') or TAR_PROBE_DEFAULT_APDU).replace(' ', '').upper()
labels = {tar: label for tar, label in TAR_PROBE_TARS} labels = {tar: label for tar, label in TAR_PROBE_TARS}
wanted = body.get('tars') wanted = body.get('tars')
@@ -7959,26 +8049,49 @@ class PysimHandler(BaseHTTPRequestHandler):
entries.append((tar, labels.get(tar, ''))) entries.append((tar, labels.get(tar, '')))
else: else:
entries = list(TAR_PROBE_TARS) entries = list(TAR_PROBE_TARS)
# Resolve every TAR's SPI1 before the first packet is sent: a
# missing MSL must refuse the whole probe, not leave it half-run.
resolved = []
for tar, label in entries:
try:
tar_spi1, _ = _spi1_for_tar(preset, tar, spi1_base,
prefer_msl=True)
except ValueError as e:
err = {'success': False, 'error': str(e)}
self._send_json(err, 400)
self._log_resp(err)
return
if not _counter_tracked(tar_spi1):
err = {'success': False,
'error': 'the TAR probe needs a counter check - SPI1 %s '
'for TAR %s has none (b5b4 = 00)'
% (tar_spi1, tar)}
self._send_json(err, 400)
self._log_resp(err)
return
resolved.append((tar, label, tar_spi1))
state = {'steps': [], 'encode_error': None, 'failure': {}, state = {'steps': [], 'encode_error': None, 'failure': {},
'cntr': body.get('cntr', '00000000'), 'cntr': body.get('cntr', '00000000'),
'preset_id': body.get('preset_id'), 'kvn': kvn} 'preset_id': body.get('preset_id'), 'kvn': kvn}
sp = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid, 'tar': '000000', sp = {'spi1': spi1_base, 'spi2': spi2, 'kic': kic, 'kid': kid, 'tar': '000000',
'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''), 'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''),
'include_cpi': body.get('includeCpi', True)} 'include_cpi': body.get('includeCpi', True)}
sys.stderr.write('TAR-PROBE: %d TAR(s), APDU %s, SPI %s/%s, kvn=%s\n' sys.stderr.write('TAR-PROBE: %d TAR(s), APDU %s, SPI %s/%s, kvn=%s\n'
% (len(entries), apdu, spi1, spi2, kvn)) % (len(entries), apdu, spi1_base or '-', spi2, kvn))
_ram_progress_begin('tar-probe', len(entries)) _ram_progress_begin('tar-probe', len(entries))
try: try:
for index, (tar, _label) in enumerate(entries): for index, (tar, _label, tar_spi1) in enumerate(resolved):
_ram_progress_step(index, 'TAR ' + tar) _ram_progress_step(index, 'TAR ' + tar)
sp['tar'] = tar sp['tar'] = tar
sp['spi1'] = tar_spi1
_ram_send_gp_apdu(self.server, scc, sp, state, 'TAR ' + tar, apdu) _ram_send_gp_apdu(self.server, scc, sp, state, 'TAR ' + tar, apdu)
finally: finally:
_ram_progress_end() _ram_progress_end()
results = [] results = []
for (tar, label), step in zip(entries, state['steps']): for (tar, label, tar_spi1), step in zip(resolved, state['steps']):
results.append({ results.append({
'tar': tar, 'label': label, 'verdict': _tar_probe_verdict(step), 'tar': tar, 'label': label, 'spi1': tar_spi1,
'verdict': _tar_probe_verdict(step),
'sw': step.get('sw'), 'por_status': step.get('por_status'), 'sw': step.get('sw'), 'por_status': step.get('por_status'),
'por_sw': step.get('por_sw'), 'por_data': step.get('por_data'), 'por_sw': step.get('por_sw'), 'por_data': step.get('por_data'),
'bytes': step.get('bytes'), 'segments': step.get('segments'), 'bytes': step.get('bytes'), 'segments': step.get('segments'),
@@ -7987,7 +8100,7 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = {'success': True, 'results': results, 'steps': state['steps'], resp = {'success': True, 'results': results, 'steps': state['steps'],
'registered': registered, 'total': len(results), 'registered': registered, 'total': len(results),
'final_cntr': state['cntr'], 'kvn': kvn, 'final_cntr': state['cntr'], 'kvn': kvn,
'spi1': spi1, 'spi2': spi2, 'apdu': apdu} 'spi1': spi1_base, 'spi2': spi2, 'apdu': apdu}
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
elif self.path == '/api/counter-probe': elif self.path == '/api/counter-probe':
+88 -4
View File
@@ -237,7 +237,7 @@ class CapCompatHttpTests(unittest.TestCase):
p.start() p.start()
try: try:
status, resp = self._post('/api/cap-compat', { status, resp = self._post('/api/cap-compat', {
'cap_hex': _mini_cap_hex(), 'cap_hex': _mini_cap_hex(), 'spi1': '16',
'probe_imports': {'A0000000620101': '0.0', 'dead beef': '1.2'}, 'probe_imports': {'A0000000620101': '0.0', 'dead beef': '1.2'},
}) })
finally: finally:
@@ -306,7 +306,7 @@ class RamInstallNvFootprintHttpTests(unittest.TestCase):
for p in patches: for p in patches:
p.start() p.start()
try: try:
return self._post('/api/ram-install', {'cap_hex': 'AA'}) return self._post('/api/ram-install', {'cap_hex': 'AA', 'spi1': '16'})
finally: finally:
for p in reversed(patches): for p in reversed(patches):
p.stop() p.stop()
@@ -444,7 +444,7 @@ class CntrLowGuardHttpTests(unittest.TestCase):
for p in patches: for p in patches:
p.start() p.start()
try: try:
status, resp = self._post('/api/ram-install', {'cap_hex': 'AA'}) status, resp = self._post('/api/ram-install', {'cap_hex': 'AA', 'spi1': '16'})
finally: finally:
for p in reversed(patches): for p in reversed(patches):
p.stop() p.stop()
@@ -653,6 +653,90 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(resp['final_cntr'], '0000000002') self.assertEqual(resp['final_cntr'], '0000000002')
self.assertEqual(self.store.find_keyset(p['id'], 1)['cntr'], '0000000002') self.assertEqual(self.store.find_keyset(p['id'], 1)['cntr'], '0000000002')
def test_the_spi1_is_derived_from_the_msl_and_a_low_value_warns(self):
p = self.store.add(self._preset(iccid='8970119000004600098'))
seen = []
def fake_decode(spi1, spi2, *a, **k):
seen.append(spi1)
return None
patches = [
mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': ''}),
mock.patch.object(self.srv, '_decode_por', fake_decode),
]
self.server.app = object()
for patch in patches:
patch.start()
try:
# no spi1: derived from the ISD entry's MSL (the store default 16)
status, resp = self._post('/api/send-ota', {
'sp': '00', 'spi2': '01', 'kic': '15', 'kid': '15',
'tar': '000000', 'preset_id': p['id']})
self.assertEqual(status, 200, resp)
self.assertNotIn('warning', resp)
# an explicit value below the MSL wins (a hand send may ask for
# less) but the response names the risk
status, resp = self._post('/api/send-ota', {
'sp': '00', 'spi1': '0A', 'spi2': '01', 'kic': '15', 'kid': '15',
'tar': '000000', 'preset_id': p['id']})
self.assertEqual(status, 200, resp)
self.assertIn('MSL 16', resp['warning'])
# an explicit value above the MSL is fine
status, resp = self._post('/api/send-ota', {
'sp': '00', 'spi1': '21', 'spi2': '01', 'kic': '15', 'kid': '15',
'tar': '000000', 'preset_id': p['id']})
self.assertEqual(status, 200, resp)
self.assertNotIn('warning', resp)
# a TAR the preset does not carry, without an explicit SPI1:
# refused with a hint (guessing the MSL is not an option)
status, resp = self._post('/api/send-ota', {
'sp': '00', 'spi2': '01', 'kic': '15', 'kid': '15',
'tar': 'AF4D01'})
self.assertEqual(status, 400, resp)
self.assertIn('MSL', resp['error'])
finally:
for patch in reversed(patches):
patch.stop()
self.assertEqual(seen, ['16', '0A', '21'])
def test_a_ram_operation_derives_the_spi1_from_the_msl(self):
p = self.store.add(self._preset(iccid='8970119000004600098'))
self.store.update(p['id'], {'tars': [
{'role': 'isd', 'tar': '000000', 'msl': '1A', 'desc': ''},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16', 'desc': ''},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16', 'desc': ''}]})
captured = []
def fake_step(server, scc, sp, state, name, apdu, silent=False):
captured.append(sp['spi1'])
state['steps'].append({'name': name, 'por_status': 'por_ok', 'por_sw': '9000'})
return True
patches = [
mock.patch.object(self.srv, '_cap_parse',
lambda cap_hex: ('F0414C46416101', 'F0414C4641610101', b'\x01\x02')),
mock.patch.object(self.srv, '_cap_apdu_sequence',
lambda *a, **k: ['80E60200', '80E80000', '80E60000']),
mock.patch.object(self.srv, '_ram_detect_format', lambda *a, **k: 'compact'),
mock.patch.object(self.srv, '_ram_send_gp_apdu', fake_step),
mock.patch.object(self.srv, '_ram_read_ff21', lambda *a, **k: None),
]
for patch in patches:
patch.start()
try:
status, resp = self._post('/api/ram-install', {
'cap_hex': 'AA', 'preset_id': p['id'], 'kic': '15', 'kid': '15'})
finally:
for patch in reversed(patches):
patch.stop()
self.assertEqual(status, 200, resp)
self.assertTrue(resp['success'], resp)
self.assertTrue(captured)
self.assertEqual(set(captured), {'1A'})
def test_a_counter_less_send_ota_body_still_answers(self): def test_a_counter_less_send_ota_body_still_answers(self):
# a pre-built packet without a counter: no final_cntr, no persist, # a pre-built packet without a counter: no final_cntr, no persist,
# and above all no int('') crash (v3.8.0 review fix) # and above all no int('') crash (v3.8.0 review fix)
@@ -811,7 +895,7 @@ class PresetStoreHttpTests(unittest.TestCase):
self.server.app = object() # the probe only checks truthiness self.server.app = object() # the probe only checks truthiness
with mock.patch.object(self.srv, '_ram_send_gp_apdu', fake_step): with mock.patch.object(self.srv, '_ram_send_gp_apdu', fake_step):
status, resp = self._post('/api/tar-probe', { status, resp = self._post('/api/tar-probe', {
'preset_id': p['id'], 'kic': '25', 'kid': '25', 'preset_id': p['id'], 'spi1': '16', 'kic': '25', 'kid': '25',
'kicKey': 'AA', 'kidKey': 'BB', 'cntr': '0000000005', 'kicKey': 'AA', 'kidKey': 'BB', 'cntr': '0000000005',
'tars': ['000000', 'B00000', 'B00001', 'B00200']}) 'tars': ['000000', 'B00000', 'B00001', 'B00200']})
self.assertEqual(status, 200, resp) self.assertEqual(status, 200, resp)
+52 -3
View File
@@ -54,6 +54,7 @@ from pysim_simple_server.server import (
_ram_step_result, _ram_step_result,
_record_tr, _record_tr,
_send_secured_packet, _send_secured_packet,
_spi1_for_tar,
_spi_from_bytes, _spi_from_bytes,
_split_secured_packet, _split_secured_packet,
_tr_data_only, _tr_data_only,
@@ -2296,13 +2297,61 @@ class ExpandedRemoteTests(unittest.TestCase):
self.assertIn('wrong length', error) self.assertIn('wrong length', error)
class Spi1ForTarTests(unittest.TestCase):
"""The SPI1 of a packet comes from the MSL (Minimum SPI1) of its TAR
(TS 102 226 8.2.1.3.2.4) - the preset's TAR table is the source."""
PRESET = {'tars': [
{'role': 'isd', 'tar': '000000', 'msl': '16', 'desc': ''},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '10', 'desc': ''},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16', 'desc': ''},
{'tar': 'AF4D01', 'msl': '0A', 'desc': 'applet'},
]}
def test_a_hand_send_keeps_its_explicit_spi1(self):
self.assertEqual(_spi1_for_tar(self.PRESET, '000000', '21'), ('21', ''))
self.assertEqual(_spi1_for_tar(self.PRESET, '000000', '1A'), ('1A', ''))
def test_an_explicit_spi1_below_the_msl_warns(self):
spi1, warning = _spi1_for_tar(self.PRESET, 'B00000', '0A')
self.assertEqual(spi1, '0A')
self.assertIn('MSL 10', warning)
self.assertIn('B00000', warning)
def test_the_comparison_is_numeric(self):
# '9' < '10' numerically - a lexicographic compare would miss it
_, warning = _spi1_for_tar(self.PRESET, 'B00000', '9')
self.assertIn('MSL 10', warning)
def test_without_an_explicit_value_the_msl_is_used(self):
self.assertEqual(_spi1_for_tar(self.PRESET, 'B00001'), ('16', ''))
self.assertEqual(_spi1_for_tar(self.PRESET, 'af4d01'), ('0A', ''))
def test_operations_prefer_the_msl_over_a_stale_explicit_value(self):
self.assertEqual(_spi1_for_tar(self.PRESET, 'B00001', '21',
prefer_msl=True), ('16', ''))
# ... but the explicit value covers a TAR the preset does not carry
self.assertEqual(_spi1_for_tar(self.PRESET, 'B00200', '21',
prefer_msl=True), ('21', ''))
def test_an_unknown_tar_without_an_explicit_value_is_refused(self):
with self.assertRaises(ValueError) as cm:
_spi1_for_tar(self.PRESET, 'B00200')
self.assertIn('B00200', str(cm.exception))
self.assertIn('MSL', str(cm.exception))
with self.assertRaises(ValueError):
_spi1_for_tar({}, '000000')
class CounterProbeTests(unittest.TestCase): class CounterProbeTests(unittest.TestCase):
"""The bounded counter synchronisation probe (v3.9.x).""" """The bounded counter synchronisation probe (v3.9.x)."""
@staticmethod @staticmethod
def _preset(cntr='0000000C2D'): def _preset(cntr='0000000C2D'):
return {'id': 'p1', 'name': 'test card', 'spi1': '16', 'spi2': '21', return {'id': 'p1', 'name': 'test card',
'tar': '000000', 'tars': [{'role': 'isd', 'tar': '000000', 'msl': '16', 'desc': ''},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16', 'desc': ''},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16', 'desc': ''}],
'keysets': [{'kic': '25', 'kid': '25', 'kicKey': '00' * 16, 'keysets': [{'kic': '25', 'kid': '25', 'kicKey': '00' * 16,
'kidKey': '11' * 16, 'cntr': cntr}]} 'kidKey': '11' * 16, 'cntr': cntr}]}
@@ -2373,7 +2422,7 @@ class CounterProbeTests(unittest.TestCase):
self.assertIn('not defined', str(cm.exception)) self.assertIn('not defined', str(cm.exception))
# SPI1 without a counter check cannot probe # SPI1 without a counter check cannot probe
no_check = self._preset() no_check = self._preset()
no_check['spi1'] = '06' # ciphering+CC but b5b4 = 00: no counter field no_check['tars'][0]['msl'] = '06' # ciphering+CC but b5b4 = 00: no counter field
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_counter_probe_params(no_check, {}) _counter_probe_params(no_check, {})
# the ceiling must stay below the 40-bit maximum # the ceiling must stay below the 40-bit maximum
+85 -5
View File
@@ -91,15 +91,80 @@ class StoreCrudTests(unittest.TestCase):
def test_add_applies_the_form_defaults_and_assigns_an_id(self): def test_add_applies_the_form_defaults_and_assigns_an_id(self):
p = self.store.add(preset(iccid=DIGITS)) p = self.store.add(preset(iccid=DIGITS))
self.assertTrue(p['id']) self.assertTrue(p['id'])
self.assertEqual(p['spi1'], '16') self.assertEqual([t['role'] for t in p['tars']],
self.assertEqual(p['spi2'], '01') ['isd', 'uiccRfm', 'usimRfm'])
self.assertEqual(p['tar'], '000000') self.assertEqual([t['tar'] for t in p['tars']],
self.assertEqual(p['uiccTar'], 'B00000') ['000000', 'B00000', 'B00001'])
self.assertEqual(p['usimTar'], 'B00001') self.assertEqual([t['msl'] for t in p['tars']], ['16', '16', '16'])
self.assertNotIn('spi1', p) # the card-wide SPI1 is gone: per-TAR MSL
self.assertNotIn('spi2', p)
self.assertEqual(p['keysets'][0]['cntr'], '0000000001') self.assertEqual(p['keysets'][0]['cntr'], '0000000001')
self.assertEqual(presets.keyset_kvn(p['keysets'][0]), 1) self.assertEqual(presets.keyset_kvn(p['keysets'][0]), 1)
self.assertEqual([x['id'] for x in self.store.list()], [p['id']]) self.assertEqual([x['id'] for x in self.store.list()], [p['id']])
def test_a_preset_carries_extra_tars_with_optional_descriptions(self):
p = self.store.add(preset(tars=[
{'role': 'isd', 'tar': 'AF4D01', 'msl': '1A'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'},
{'tar': 'AF4D02', 'msl': '0A', 'desc': 'My applet'},
{'tar': '', 'msl': '', 'desc': ''}, # a blank editor row
]))
self.assertEqual([(t['role'], t['tar'], t['msl']) for t in p['tars']],
[('isd', 'AF4D01', '1A'), ('uiccRfm', 'B00000', '16'),
('usimRfm', 'B00001', '16'), ('', 'AF4D02', '0A')])
self.assertEqual(p['tars'][3]['desc'], 'My applet')
self.assertEqual(len(p['tars']), 4) # the blank row was dropped
def test_every_tar_needs_its_msl_and_a_unique_tar(self):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(tars=[
{'role': 'isd', 'tar': '000000', 'msl': '16'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'},
{'tar': 'AF4D02', 'msl': ''},
]))
self.assertIn('MSL', str(ctx.exception))
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(tars=[
{'role': 'isd', 'tar': '000000', 'msl': '16'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': '000000', 'msl': '16'},
]))
self.assertIn('duplicate TAR', str(ctx.exception))
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(tars=[
{'role': 'isd', 'tar': '0000', 'msl': '16'}]))
self.assertIn('three hex bytes', str(ctx.exception))
def test_tar_lookup_helpers(self):
p = self.store.add(preset(tars=[
{'role': 'isd', 'tar': 'AF4D01', 'msl': '1A'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'},
{'tar': 'AF4D02', 'msl': '0A', 'desc': 'My applet'},
]))
self.assertEqual(presets.role_tar(p, 'isd'), 'AF4D01')
self.assertEqual(presets.role_tar(p, 'usimRfm'), 'B00001')
self.assertEqual(presets.role_tar(p, 'nope'), '')
self.assertEqual(presets.tar_msl(p, 'af4d01'), '1A')
self.assertEqual(presets.tar_msl(p, 'AF4D02'), '0A')
self.assertEqual(presets.tar_msl(p, '123456'), '')
self.assertIsNone(presets.tar_entry(p, ''))
self.assertEqual(presets.tar_entry(p, 'AF4D02')['desc'], 'My applet')
def test_update_replaces_the_tar_table(self):
p = self.store.add(preset())
upd = self.store.update(p['id'], {'tars': [
{'role': 'isd', 'tar': 'AF4D01', 'msl': '1A'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'}]})
self.assertEqual(presets.role_tar(upd, 'isd'), 'AF4D01')
self.assertEqual(presets.tar_msl(upd, 'AF4D01'), '1A')
# a malformed tars value must not silently reset the table
again = self.store.update(p['id'], {'tars': 'nonsense'})
self.assertEqual(presets.role_tar(again, 'isd'), 'AF4D01')
def test_a_preset_carries_several_keysets(self): def test_a_preset_carries_several_keysets(self):
p = self.store.add(preset(keysets=[keyset(kic='15', kid='15'), p = self.store.add(preset(keysets=[keyset(kic='15', kid='15'),
keyset(kic='29', kid='29', cntr='0000000005')])) keyset(kic='29', kid='29', cntr='0000000005')]))
@@ -317,6 +382,21 @@ class StorePersistenceTests(unittest.TestCase):
self.assertEqual(p['keysets'][0]['cntr'], '0000000007') self.assertEqual(p['keysets'][0]['cntr'], '0000000007')
self.assertEqual(p['keysets'][0]['kicKey'], '11') self.assertEqual(p['keysets'][0]['kicKey'], '11')
self.assertNotIn('cntr', p) self.assertNotIn('cntr', p)
# the card-wide spi1 becomes the MSL of all three role entries
self.assertEqual([t['msl'] for t in p['tars']], ['16', '16', '16'])
def test_a_v391_flat_tars_preset_converts_to_the_tar_table(self):
path = pathlib.Path(self.tmp.name) / 'card_presets.json'
path.write_text(json.dumps({'version': 2, 'presets': [
{'name': 'Old', 'tar': 'AF4D01', 'uiccTar': 'B00000',
'usimTar': 'B00001', 'spi1': '1A', 'spi2': '21',
'keysets': [keyset()]}]}), encoding='utf-8')
store = make_store(self.tmp.name)
p = store.list()[0]
self.assertEqual([t['tar'] for t in p['tars']],
['AF4D01', 'B00000', 'B00001'])
self.assertEqual([t['msl'] for t in p['tars']], ['1A', '1A', '1A'])
self.assertNotIn('spi2', p)
def test_no_temporary_files_are_left_behind(self): def test_no_temporary_files_are_left_behind(self):
store = make_store(self.tmp.name) store = make_store(self.tmp.name)
+13 -4
View File
@@ -22,6 +22,13 @@ def _resolver(name):
return {n.upper(): c for c, n in S.PROACTIVE_TYPE_NAMES.items()}.get(name.upper()) return {n.upper(): c for c, n in S.PROACTIVE_TYPE_NAMES.items()}.get(name.upper())
def preset_tars(isd='000000', msl='16'):
"""The three mandatory role entries of a card preset's TAR table."""
return [{'role': 'isd', 'tar': isd, 'msl': msl, 'desc': ''},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16', 'desc': ''},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16', 'desc': ''}]
class TestValidation(unittest.TestCase): class TestValidation(unittest.TestCase):
def test_minimal_script_normalises(self): def test_minimal_script_normalises(self):
script = T.normalise_script({'name': 'demo', 'steps': [ script = T.normalise_script({'name': 'demo', 'steps': [
@@ -354,8 +361,10 @@ class TestRunnerActions(RunnerTestCase):
def test_scp80_uses_the_preset_and_advances_the_counter(self): def test_scp80_uses_the_preset_and_advances_the_counter(self):
scc = FakeScc() scc = FakeScc()
server = FakeServer(scc) server = FakeServer(scc)
# the ISD entry carries a distinctive TAR: the assertion below must
# fail if the runner ever falls back to a built-in default
preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16, 'kidKey': 'BB' * 16, preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16, 'kidKey': 'BB' * 16,
'counter': '0000000A', 'tar': 'B00000', 'spi1': '16', 'spi2': '01'} 'counter': '0000000A', 'tars': preset_tars(isd='B00000')}
with mock.patch.object(S, '_build_secured_packet', with mock.patch.object(S, '_build_secured_packet',
return_value=('AA' * 10, {})) as build, \ return_value=('AA' * 10, {})) as build, \
mock.patch.object(S, '_send_secured_packet', mock.patch.object(S, '_send_secured_packet',
@@ -401,7 +410,7 @@ class TestRunnerActions(RunnerTestCase):
def test_scp80_step_overrides_tar_and_spi_only(self): def test_scp80_step_overrides_tar_and_spi_only(self):
scc = FakeScc() scc = FakeScc()
preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16, 'kidKey': 'BB' * 16, preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16, 'kidKey': 'BB' * 16,
'counter': '00000001', 'tar': 'B00000', 'spi1': '16', 'spi2': '01'} 'counter': '00000001', 'tars': preset_tars()}
with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})) as build, \ with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})) as build, \
mock.patch.object(S, '_send_secured_packet', mock.patch.object(S, '_send_secured_packet',
return_value={'success': True, 'sw': '9000', return_value={'success': True, 'sw': '9000',
@@ -421,9 +430,9 @@ class TestRunnerActions(RunnerTestCase):
]}, S._test_command_type) ]}, S._test_command_type)
self.assertIn('incomplete', S._test_preset_error(script, {}) or '') self.assertIn('incomplete', S._test_preset_error(script, {}) or '')
full = {'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB', full = {'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB',
'counter': '00000000', 'tar': 'B00000', 'spi1': '16', 'spi2': '01'} 'counter': '00000000', 'tars': preset_tars()}
self.assertIsNone(S._test_preset_error(script, full)) self.assertIsNone(S._test_preset_error(script, full))
no_tar = dict(full, tar='') no_tar = dict(full, tars=[])
self.assertIn('TAR', S._test_preset_error(script, no_tar) or '') self.assertIn('TAR', S._test_preset_error(script, no_tar) or '')
def test_file_write_and_read_actions(self): def test_file_write_and_read_actions(self):