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:
+44
-11
@@ -192,7 +192,7 @@ A blocked ADM (`6983`/`9804`) reports `{"ok": false, "sw": "9804",
|
||||
|
||||
### 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
|
||||
it; `Path.home()` resolves the same way on Linux, macOS and Windows). The file
|
||||
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:
|
||||
|
||||
```json
|
||||
{"id": "…", "name": "Card A", "iccid": "…", "spi1": "16", "spi2": "01",
|
||||
"tar": "000000", "uiccTar": "B00000", "usimTar": "B00001",
|
||||
{"id": "…", "name": "Card A", "iccid": "…",
|
||||
"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": [
|
||||
{"kic": "15", "kid": "15", "kicKey": "…", "kidKey": "…", "cntr": "0000000001"},
|
||||
{"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,
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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).
|
||||
- `POST /api/presets` — body = the preset fields (`keysets` included); returns
|
||||
`{"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
|
||||
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
|
||||
`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
|
||||
@@ -286,10 +305,13 @@ first attempt instead of hammering the card.
|
||||
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
|
||||
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
|
||||
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
|
||||
{"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,
|
||||
"final_cntr": "…", "kvn": 2, "spi1": "16", "spi2": "01",
|
||||
"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": [...]}`
|
||||
|
||||
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
|
||||
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 real response (a big GET STATUS listing, for example) then arrives as one
|
||||
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"}],
|
||||
"respond": {"result": "ok"}}
|
||||
]},
|
||||
"preset": {"name": "lab card", "kic": "15", "kid": "15", "kicKey": "...",
|
||||
"kidKey": "...", "counter": "0000000A", "tar": "B00000",
|
||||
"spi1": "16", "spi2": "01"}}
|
||||
"preset": {"name": "lab card",
|
||||
"keysets": [{"kic": "15", "kid": "15", "kicKey": "...", "kidKey": "...",
|
||||
"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`),
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<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">
|
||||
<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>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>RAM/GP</strong>, <strong>Expanded Script</strong>, <strong>«Пуш/триггер»</strong>, <strong>«Разбор»</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>«Слушатель»</strong>, <strong>«Скрипты»</strong>), <strong>«Карты»</strong>, <strong>«Профайлер»</strong> (вкладки <strong>«Профили»</strong>, <strong>«Снимки карт»</strong>, <strong>«Пользовательские файлы»</strong>), <strong>«Картридер»</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>«Симулятор»</strong>.</li>
|
||||
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка «Профайлер» открывает §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>
|
||||
|
||||
<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 §8.2.1.3.2.4 — карта проверяет его до обработки защиты и отвечает <code class="font-mono text-sm">0A</code>, если SPI1 пакета ниже). Значение ниже MSL показывает жёлтое предупреждение под формой; введённый вручную TAR сохраняет SPI1 оператора.</p>
|
||||
<h4 id="packet-structure" class="font-medium mb-1">Структура пакета</h4>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Поле</th><th class="text-left py-1 px-2">Размер</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
|
||||
@@ -314,7 +314,7 @@
|
||||
<p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). 2-байтовый префикс CPL передаётся, когда пакет зашифрован или несёт RC/CC/DS (входит в их входные данные), а также при конкатенации SMS; одиночное незащищённое SMS сохраняет форму pySim, начинающуюся с CHL (TS 31.115 Table 1 NOTE). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется <strong>конкатенированной</strong> загрузкой (TS 31.115 §4.3) — пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>«Разбор R-APDU»</strong> (Remote APDU → «Разбор»), а успешный PoR увеличивает счётчик повторов (когда в SPI1 есть проверка счётчика — b5 b4 ≠ 00; без неё счётчик не увеличивается и не сохраняется) и очищает пакет.</p>
|
||||
|
||||
<h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3>
|
||||
<p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Пресет карты со вкладки <strong>Карты</strong> обеспечивает SPI, ключи, TAR и счётчик (RAM использует <strong>ISD TAR</strong> пресета, по умолчанию <code class="font-mono text-sm">000000</code>). Селектор <strong>Keyset</strong> рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его 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 §5.2.1 (на живой карте выполняются обе; шаблон, у которого заявленная длина не совпадает с телом, получает Bad format TLV — операция показывает это как ошибку шага). Запросы листингов и реестра следуют этому формату: компактные листинги несут сцепленный GET RESPONSE, а expanded-прогон отправляет те же запросы P2=00 обёрнутыми и без цепочки (форма эталонного загрузчика TCA; Le=’00’ возвращает листинг сразу), и запрос реестра, отклонённый картой в текущем формате, повторяется в остальных — отчёт называет тот, который сработал. Блок ресурсов карты также называет предполагаемый <strong>релиз ETSI</strong> карты (версия <code class="font-mono text-sm">uicc.toolkit</code> → 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 §5.2.1 (на живой карте выполняются обе; шаблон, у которого заявленная длина не совпадает с телом, получает Bad format TLV — операция показывает это как ошибку шага). Запросы листингов и реестра следуют этому формату: компактные листинги несут сцепленный GET RESPONSE, а expanded-прогон отправляет те же запросы P2=00 обёрнутыми и без цепочки (форма эталонного загрузчика TCA; Le=’00’ возвращает листинг сразу), и запрос реестра, отклонённый картой в текущем формате, повторяется в остальных — отчёт называет тот, который сработал. Блок ресурсов карты также называет предполагаемый <strong>релиз ETSI</strong> карты (версия <code class="font-mono text-sm">uicc.toolkit</code> → REL-N, то же сопоставление, что и в анализе CAP; показывается, только если расширенный реестр вернул версии).</p>
|
||||
|
||||
<h4 id="ram-operations" class="font-medium mb-1">Операции</h4>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
@@ -340,13 +340,13 @@
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<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> — стандартные значения из TS 101 220 V18.3.0 Annex D, Table D.1, каждая строка несёт документ-источник — с безобидной 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> — стандартные значения из TS 101 220 V18.3.0 Annex D, Table D.1, каждая строка несёт документ-источник — с безобидной C-APDU (по умолчанию <code class="font-mono text-sm">SELECT MF</code>), пресетом и keyset этого представления — каждый проверяемый TAR использует свой <strong>MSL</strong>, если запись есть в пресете, иначе SPI1 из общей формы; TAR, для которого нет ни того, ни другого, отклоняется до отправки. Разрешённый SPI1 должен иметь проверку счётчика (b5b4 ≠ 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> по возможности — их потеря безвредна.</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 пресета — проба отклоняется, если в 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 пресета — проба отклоняется, если в разрешённом 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. Вкладка «Карты»</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, израсходованные операцией, сохранялись самой операцией — закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Пресет содержит <strong>несколько keysets</strong> — номер keyset задаётся нибблом <code class="font-mono text-sm">b8..b5</code> его KIc/KID (TS 102 225 §5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (01–0F; A.2 отвергает несовпадение), и у каждого keyset на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по keysets. В форме keysets редактируются строками — по одной на номер, с кнопками <strong>Добавить keyset</strong> / <strong>Удалить keyset</strong>; номер в строке вычисляется из байта KIc/KID, а <code class="font-mono text-sm">00</code> («без защиты») не принимается как keyset. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и пресеты старых сборок — плоские и с keysets). «Карты» — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх представлениях SCP80; если подключённая карта не совпадает <strong>ни с одним</strong> пресетом — или выбранный пресет принадлежит другой карте — все три представления 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, израсходованные операцией, сохранялись самой операцией — закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Пресет содержит <strong>несколько keysets</strong> — номер keyset задаётся нибблом <code class="font-mono text-sm">b8..b5</code> его KIc/KID (TS 102 225 §5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (01–0F; A.2 отвергает несовпадение), и у каждого keyset на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по keysets. В форме keysets редактируются строками — по одной на номер, с кнопками <strong>Добавить keyset</strong> / <strong>Удалить keyset</strong>; номер в строке вычисляется из байта KIc/KID, а <code class="font-mono text-sm">00</code> («без защиты») не принимается как keyset. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и пресеты старых сборок — плоские и с keysets). «Карты» — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх представлениях SCP80; если подключённая карта не совпадает <strong>ни с одним</strong> пресетом — или выбранный пресет принадлежит другой карте — все три представления 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">
|
||||
<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>
|
||||
@@ -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">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">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 — предзаполнены) и дополнительные TAR, у каждого свой MSL (минимальный SPI1, TS 102 226 §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">UICC RFM TAR</td><td class="py-1 px-2">TAR UICC Shared File System RFM (TS 102 226 §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 §7.3, TS 101 220 приложение D): ADF выбирается неявно, TAR привязан к AID приложения — 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>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">Столбец <strong>SCP81</strong> показывает, задана ли в пресете рабочая пара PSK: <strong>✓</strong> (зелёный) — идентификатор и ключ заданы; <strong>⚠</strong> (жёлтый) — задано только одно из двух, и слушатель такой пресет игнорирует; <strong>—</strong> — PSK нет. Идентификатор и ключ PSK задаются вместе. Кнопка <strong>Редактировать</strong> загружает пресет в форму (кнопка Add становится <strong>Сохранить</strong>; <strong>Отмена</strong> очищает форму), поэтому поля можно менять без повторного ввода карты; <strong>Удалить</strong> удаляет строку. Обмен пресетами: <strong>Экспорт в файл</strong> для выгрузки и <strong>Импорт из файла</strong> для загрузки. Выбранный пресет автоматически заполняет форму Secured Packet и устанавливает TAR для операции (RAM/GP → ISD TAR, SIM RFM → UICC RFM TAR, USIM RFM → ADF RFM TAR); TAR, введённый в форму Secured Packet вручную, сохраняется при отправке - счётчик, ключи и SPI по-прежнему берутся из пресета; изменения сразу передаются работающему слушателю SCP81.</p>
|
||||
<p class="text-sm mb-3">Столбец <strong>SCP81</strong> показывает, задана ли в пресете рабочая пара PSK: <strong>✓</strong> (зелёный) — идентификатор и ключ заданы; <strong>⚠</strong> (жёлтый) — задано только одно из двух, и слушатель такой пресет игнорирует; <strong>—</strong> — PSK нет. Идентификатор и ключ PSK задаются вместе. Кнопка <strong>Редактировать</strong> загружает пресет в форму (кнопка Add становится <strong>Сохранить</strong>; <strong>Отмена</strong> очищает форму), поэтому поля можно менять без повторного ввода карты; <strong>Удалить</strong> удаляет строку. Обмен пресетами: <strong>Экспорт в файл</strong> для выгрузки и <strong>Импорт из файла</strong> для загрузки. Выбранный пресет автоматически заполняет форму Secured Packet и устанавливает TAR для операции (RAM/GP → ISD TAR, SIM RFM → UICC RFM TAR, USIM RFM → ADF RFM TAR); TAR, введённый в форму Secured Packet вручную, сохраняется при отправке - счётчик, ключи и MSL этого TAR по-прежнему берутся из пресета; SPI1 берётся из MSL, а меньшее значение даёт предупреждение (сервер тоже возвращает его как <code class="font-mono text-sm">warning</code>); изменения сразу передаются работающему слушателю SCP81.</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
|
||||
+8
-8
@@ -42,7 +42,7 @@
|
||||
|
||||
<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">
|
||||
<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>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 §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>
|
||||
|
||||
<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 §8.2.1.3.2.4 — 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>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Size</th><th class="text-left py-1 px-2">Description</th></tr></thead>
|
||||
@@ -313,7 +313,7 @@
|
||||
<p class="text-sm mb-3">A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A “Send to Card” 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’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 §4.3) — 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’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’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 — b5 b4 ≠ 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’s CNTR field is a copy of the packet’s), so the notice offers a bounded <strong>Sync counter</strong> probe — 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 — 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>
|
||||
<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 §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’s form, Le=’00’ returns the listing directly), and a registry query the card refuses in the current format is retried in the other formats — the report names the one that served it. The card-resources block also names the card’s inferred <strong>ETSI release</strong> (<code class="font-mono text-sm">uicc.toolkit</code> version → 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 §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’s form, Le=’00’ returns the listing directly), and a registry query the card refuses in the current format is retried in the other formats — the report names the one that served it. The card-resources block also names the card’s inferred <strong>ETSI release</strong> (<code class="font-mono text-sm">uicc.toolkit</code> version → 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>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
@@ -339,13 +339,13 @@
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<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> — the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1, each row carrying the document it comes from — with a harmless C-APDU (default <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> — the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1, each row carrying the document it comes from — with a harmless C-APDU (default <code class="font-mono text-sm">SELECT MF</code>) using the view's preset and keyset — 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 ≠ 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 — 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">Each packet the card accepts <strong>consumes a counter</strong>, which is saved into the preset's keyset — the probe is refused when SPI1 has no counter check or the named preset does not define the 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 — 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>
|
||||
<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 — a closed tab or a second browser window can no longer lose an increment. A preset carries <strong>several keysets</strong> — a keyset's number is the <code class="font-mono text-sm">b8..b5</code> nibble of its KIc/KID (TS 102 225 §5.1.2/A.2): KIc and KID of a keyset must carry the same number (01–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 — 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 — or the selected preset belongs to another card — 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 — a closed tab or a second browser window can no longer lose an increment. A preset carries <strong>several keysets</strong> — a keyset's number is the <code class="font-mono text-sm">b8..b5</code> nibble of its KIc/KID (TS 102 225 §5.1.2/A.2): KIc and KID of a keyset must carry the same number (01–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 — 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 — or the selected preset belongs to another card — 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 — the three mandatory role entries ISD / UICC RFM / ADF RFM, each with its MSL, plus further TARs with an optional description — 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">
|
||||
<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>
|
||||
@@ -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">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">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 — mandatory, pre-settled) plus further TARs, each with its MSL (Minimum SPI1, TS 102 226 §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">UICC RFM TAR</td><td class="py-1 px-2">UICC Shared File System RFM TAR (TS 102 226 §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 §7.3, TS 101 220 Annex D): the ADF is implicitly selected and the TAR is linked to the application's AID — 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>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair: <strong>✓</strong> (green) — identity and key set; <strong>⚠</strong> (amber) — only one of the two, so the listener ignores the preset; <strong>—</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 → ISD TAR, SIM RFM → UICC RFM TAR, USIM RFM → 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>✓</strong> (green) — identity and key set; <strong>⚠</strong> (amber) — only one of the two, so the listener ignores the preset; <strong>—</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 → ISD TAR, SIM RFM → UICC RFM TAR, USIM RFM → 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 class="mb-10">
|
||||
|
||||
+300
-81
@@ -651,7 +651,8 @@
|
||||
<div class="flex gap-2 mb-3">
|
||||
<div class="flex-1">
|
||||
<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 class="flex-1">
|
||||
<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 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">
|
||||
<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>
|
||||
@@ -1590,7 +1593,7 @@
|
||||
|
||||
|
||||
<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">
|
||||
<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)">
|
||||
@@ -1601,24 +1604,38 @@
|
||||
</div>
|
||||
<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>
|
||||
<div class="flex gap-2 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">
|
||||
<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">
|
||||
<div class="flex gap-2">
|
||||
<div>
|
||||
<label for="cards-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">ISD TAR</label>
|
||||
<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-uicc-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">UICC RFM TAR</label>
|
||||
<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>
|
||||
<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 class="flex gap-3 mb-2 flex-wrap items-end">
|
||||
<div>
|
||||
<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>
|
||||
<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-msl" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400" data-l10n="MSL">MSL</label>
|
||||
<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" data-l10n="UICC RFM TAR">UICC RFM TAR</label>
|
||||
<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-uicc-tar-msl" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400" data-l10n="MSL">MSL</label>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
@@ -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="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">SPI1</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="TARs (MSL)">TARs (MSL)</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>
|
||||
</tr></thead>
|
||||
@@ -1801,7 +1816,7 @@
|
||||
// ===== Version =====
|
||||
// Single source of truth for the PWA version: shown in the header and used
|
||||
// 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;
|
||||
|
||||
// ===== Tab switching =====
|
||||
@@ -4693,9 +4708,10 @@ async function ramQueueScp81() {
|
||||
}
|
||||
}
|
||||
|
||||
// Which TAR of the selected preset feeds the SCP80 form: RAM/GP -> ISD,
|
||||
// SIM RFM -> UICC, USIM RFM -> USIM (spec defaults when the value is empty).
|
||||
let _spTarKey = 'tar';
|
||||
// Which TAR of the selected preset feeds the SCP80 form: RAM/GP -> the ISD
|
||||
// entry, SIM RFM -> the UICC RFM entry, USIM RFM -> the ADF RFM entry. Each
|
||||
// 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
|
||||
// command-format detection ('auto'); other packs (HOTA, §9 push, expanded
|
||||
// script) keep their payload byte-exact.
|
||||
@@ -4703,17 +4719,31 @@ let _spRamFormat = null;
|
||||
|
||||
function spTarKeyForPack(sourceId) {
|
||||
return {
|
||||
'chain-ram-preview': 'tar',
|
||||
'chain-sim-preview': 'uiccTar',
|
||||
'chain-usim-preview': 'usimTar',
|
||||
'chain-ram-preview': 'isd',
|
||||
'chain-sim-preview': 'uiccRfm',
|
||||
'chain-usim-preview': 'usimRfm',
|
||||
}[sourceId] || null;
|
||||
}
|
||||
|
||||
function spPresetTar(key) {
|
||||
function spPresetTar(role) {
|
||||
const sel = document.getElementById('sp-card-sel');
|
||||
const idx = sel ? parseInt(sel.value, 10) : NaN;
|
||||
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) {
|
||||
@@ -4738,6 +4768,7 @@ function spCntrAdjust(delta) {
|
||||
function spInvalidate() {
|
||||
document.getElementById('sp-result').value = '';
|
||||
spShowSizeInfo();
|
||||
spMslWarningRender();
|
||||
}
|
||||
|
||||
function updateSp() {
|
||||
@@ -8331,6 +8362,7 @@ async function pysimSendOta() {
|
||||
if (data.bytes && data.segments) {
|
||||
msg += ' | ' + data.bytes + ' ' + t('bytes') + ' · ' + data.segments + ' SMS';
|
||||
}
|
||||
if (data.warning) msg += ' | ' + t('Warning') + ': ' + data.warning;
|
||||
const por = data.por;
|
||||
if (por && por.response_status) {
|
||||
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
|
||||
@@ -8385,6 +8417,7 @@ async function pysimSendOta() {
|
||||
let msg = 'OTA failed';
|
||||
if (data.error) msg += ': ' + data.error;
|
||||
if (data.sw) msg += (data.error ? ' | ' : ': ') + 'SW ' + data.sw;
|
||||
if (data.warning) msg += ' | ' + t('Warning') + ': ' + data.warning;
|
||||
sendResultEl.textContent = msg;
|
||||
sendResultEl.classList.remove('hidden', 'text-green-600', 'text-gray-500');
|
||||
sendResultEl.classList.add('text-red-600');
|
||||
@@ -8452,7 +8485,7 @@ function ramApplyCard(idx) {
|
||||
// getRamSpParams() picks up the right SPI/KIc/KID/TAR/CNTR/keys
|
||||
const i = parseInt(idx, 10);
|
||||
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);
|
||||
spPresetWarningRender();
|
||||
}
|
||||
@@ -10845,7 +10878,7 @@ function tarApplyCard(idx) {
|
||||
// kic/kid/keys/counter from there, like the RAM view does)
|
||||
const i = parseInt(idx, 10);
|
||||
if (!isNaN(i) && cards[i]) _ramCardIdx = i;
|
||||
_spTarKey = 'tar';
|
||||
_spTarKey = 'isd'; // the probe's checklist TARs are per-row; the ISD is the form default
|
||||
cardsApply(idx);
|
||||
spPresetWarningRender();
|
||||
}
|
||||
@@ -11488,10 +11521,83 @@ function cardsPskName(identity) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
function cardsTarValue(key, value) {
|
||||
const defaults = { tar: '000000', uiccTar: 'B00000', usimTar: 'B00001' };
|
||||
return value || defaults[key] || '';
|
||||
// B00001) and the default MSL (Minimum SPI1, TS 102 226 8.2.1.3.2.4). The
|
||||
// three role entries of a preset are mandatory; the defaults pre-settle the
|
||||
// Cards form.
|
||||
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) =====
|
||||
@@ -11649,6 +11755,85 @@ function cardsKeysetsFromForm() {
|
||||
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) =====
|
||||
// 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
|
||||
@@ -11807,11 +11992,7 @@ function cardsFormValues() {
|
||||
name: val('name'),
|
||||
iccid: val('iccid'),
|
||||
adm: val('adm').replace(/\s/g, '').toUpperCase(),
|
||||
spi1: val('spi1'),
|
||||
spi2: val('spi2'),
|
||||
tar: val('tar'),
|
||||
uiccTar: val('uicc-tar'),
|
||||
usimTar: val('usim-tar'),
|
||||
tars: cardsTarsFromForm(),
|
||||
pskIdentity: val('psk-id'),
|
||||
pskKey: val('psk-key').replace(/\s/g, '').toUpperCase(),
|
||||
keysets: cardsKeysetsFromForm(),
|
||||
@@ -11826,24 +12007,17 @@ function cardsSetFormMode() {
|
||||
}
|
||||
|
||||
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);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
// TARs keep their per-target spec defaults instead of being emptied
|
||||
[['tar','tar'],['uicc-tar','uiccTar'],['usim-tar','usimTar']].forEach(([id, key]) => {
|
||||
const el = document.getElementById('cards-' + id);
|
||||
if (el) el.value = cardsTarValue(key, '');
|
||||
});
|
||||
// The three role entries keep their pre-settled defaults (TAR + MSL 16)
|
||||
cardsTarsReset();
|
||||
cardsKeysetRowsRender([]);
|
||||
}
|
||||
|
||||
async function cardsAdd() {
|
||||
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.keysets.length) {
|
||||
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'));
|
||||
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)) {
|
||||
alert(t('PSK identity and PSK key must be set together'));
|
||||
return;
|
||||
@@ -11902,12 +12092,15 @@ function cardsEdit(i) {
|
||||
if (el) el.value = value == null ? '' : value;
|
||||
};
|
||||
set('name', c.name); set('iccid', c.iccid); set('adm', c.adm);
|
||||
set('spi1', c.spi1); set('spi2', c.spi2);
|
||||
set('tar', cardsTarValue('tar', c.tar));
|
||||
set('uicc-tar', cardsTarValue('uiccTar', c.uiccTar));
|
||||
set('usim-tar', cardsTarValue('usimTar', c.usimTar));
|
||||
set('tar', cardsRoleTar(c, 'isd'));
|
||||
set('tar-msl', cardsRoleMsl(c, 'isd'));
|
||||
set('uicc-tar', cardsRoleTar(c, 'uiccRfm'));
|
||||
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);
|
||||
cardsKeysetRowsRender(spKeysetList(c));
|
||||
cardsTarRowsRender(cardsTarList(c).filter(e => !e.role));
|
||||
cardsSetFormMode();
|
||||
document.getElementById('cards-name').focus();
|
||||
}
|
||||
@@ -11943,7 +12136,7 @@ function cardsRender() {
|
||||
const tbody = document.getElementById('cards-tbody');
|
||||
if (!tbody) return;
|
||||
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;
|
||||
}
|
||||
// The preset of the equipped card (normalised ICCID match): its row is
|
||||
@@ -11973,9 +12166,7 @@ function cardsRender() {
|
||||
' \u2014 ' + esc(ks.cntr || '');
|
||||
}).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 text-center">' + esc(c.spi1) + '</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 font-mono text-xs" title="' + esc(t('TARs (MSL)')) + '">' + esc(cardsTarText(c)) + '</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> '
|
||||
+ '<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);
|
||||
}
|
||||
|
||||
// Completeness predicates used by the header indicators: SCP80 needs all
|
||||
// SPI/keys/counter fields, SCP81 the PSK pair; the ADM key is optional.
|
||||
// Completeness predicates used by the header indicators: SCP80 needs a
|
||||
// complete keyset plus the three role TAR entries with their MSLs, SCP81 the
|
||||
// PSK pair; the ADM key is optional.
|
||||
function cardsScp80Complete(c) {
|
||||
return !!(c && String(c.kic || '').trim() && String(c.kid || '').trim()
|
||||
&& String(c.spi1 || '').trim() && String(c.spi2 || '').trim()
|
||||
&& String(c.cntr || '').trim() && String(c.kicKey || '').trim() && String(c.kidKey || '').trim());
|
||||
if (!c) return false;
|
||||
const ks = spKeysetList(c).find(k => String(k.kic || '').trim() && String(k.kid || '').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) {
|
||||
@@ -12087,23 +12285,24 @@ function cardsAutoSelectByIccid(iccid) {
|
||||
function cardsApplyFields(idx, keepTar) {
|
||||
const c = cards[parseInt(idx)];
|
||||
if (!c) return false;
|
||||
document.getElementById('sp-spi1').value = c.spi1;
|
||||
// Sync the SPI2 selects with the preset byte (low 5 bits = PoR/security,
|
||||
// bit 0x20 = PoR via SMS-SUBMIT): updateSp() recomputes sp-spi2-hex from
|
||||
// them, so without this the preset value was clobbered on every refresh.
|
||||
const spi2Val = parseInt(c.spi2, 16) || 0;
|
||||
const baseHex = (spi2Val & 0x1F).toString(16).padStart(2, '0').toUpperCase();
|
||||
const baseSel = document.getElementById('sp-spi2');
|
||||
if (baseSel) {
|
||||
if (baseSel.options && typeof Option === 'function' &&
|
||||
!Array.prototype.some.call(baseSel.options, o => o.value === baseHex)) {
|
||||
baseSel.appendChild(new Option(baseHex + ' — preset', baseHex));
|
||||
// The TAR of the current operation (ISD for RAM/GP, the RFM TAR for the RFM
|
||||
// chains) and its MSL: a preset apply seeds the SPI1 from the MSL - the
|
||||
// card's Minimum SPI1 (TS 102 226 8.2.1.3.2.4). keepTar leaves the packet
|
||||
// TAR alone (the plain send may target a manually entered TAR): its SPI1 is
|
||||
// the operator's value too.
|
||||
if (!keepTar) {
|
||||
const roleTar = cardsRoleTar(c, _spTarKey);
|
||||
document.getElementById('sp-tar').value = roleTar;
|
||||
const msl = cardsTarMsl(c, roleTar) || CARDS_MSL_DEFAULT;
|
||||
const spi1Sel = document.getElementById('sp-spi1');
|
||||
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
|
||||
// defines it, otherwise start from the preset's first keyset.
|
||||
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-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();
|
||||
updateSp();
|
||||
spKeysetSync();
|
||||
spPresetWarningRender();
|
||||
spMslWarningRender();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -12137,6 +12338,7 @@ function cardsApply(idx, keepTar) {
|
||||
// warning must not outlive the selection it described.
|
||||
spKeysetSync();
|
||||
spPresetWarningRender();
|
||||
spTarListRender(null);
|
||||
return;
|
||||
}
|
||||
_genSpBuild();
|
||||
@@ -12152,7 +12354,7 @@ function spRefreshFromPreset(selId, keepTar) {
|
||||
const sel = document.getElementById(selId || 'sp-card-sel');
|
||||
const idx = sel ? parseInt(sel.value, 10) : NaN;
|
||||
if (isNaN(idx) || !cards[idx]) return '';
|
||||
if (selId === 'ram-card-sel') _spTarKey = 'tar';
|
||||
if (selId === 'ram-card-sel') _spTarKey = 'isd';
|
||||
cardsApply(idx, keepTar);
|
||||
return cards[idx].cntr;
|
||||
}
|
||||
@@ -20492,6 +20694,22 @@ const LANG_RU = {
|
||||
'Remove keyset': 'Удалить keyset',
|
||||
'Keyset': 'Keyset',
|
||||
'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 — счётчик)',
|
||||
'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)',
|
||||
@@ -20540,6 +20758,7 @@ const LANG_RU = {
|
||||
'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).',
|
||||
'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': 'Ошибка сохранения',
|
||||
'Delete error': 'Ошибка удаления',
|
||||
'Counter not saved:': 'Счётчик не сохранён:',
|
||||
|
||||
+25
-6
@@ -672,8 +672,8 @@ video {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.col-span-2 {
|
||||
grid-column: span 2 / span 2;
|
||||
.col-span-3 {
|
||||
grid-column: span 3 / span 3;
|
||||
}
|
||||
|
||||
.col-span-4 {
|
||||
@@ -843,6 +843,10 @@ video {
|
||||
max-height: 14rem;
|
||||
}
|
||||
|
||||
.max-h-64 {
|
||||
max-height: 16rem;
|
||||
}
|
||||
|
||||
.max-h-\[30vh\] {
|
||||
max-height: 30vh;
|
||||
}
|
||||
@@ -996,10 +1000,6 @@ video {
|
||||
max-width: 80rem;
|
||||
}
|
||||
|
||||
.max-w-lg {
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.max-w-md {
|
||||
max-width: 28rem;
|
||||
}
|
||||
@@ -1386,6 +1386,11 @@ video {
|
||||
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 {
|
||||
--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));
|
||||
}
|
||||
|
||||
.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 {
|
||||
--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));
|
||||
}
|
||||
|
||||
.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 *) {
|
||||
--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);
|
||||
}
|
||||
|
||||
.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 *) {
|
||||
background-color: rgb(127 29 29 / 0.5);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v357';
|
||||
const CACHE = 'simple-v358';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -5,6 +5,12 @@ const path = require('node:path');
|
||||
|
||||
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) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
@@ -46,6 +52,10 @@ code += extractFunc(html, 'pysimUpdatePresetIndicator') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdatePresetIndicators') + '\n';
|
||||
code += extractFunc(html, 'cardsMatchedPreset') + '\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, 'cardsScp81Complete') + '\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', () => {
|
||||
const { scp80El, scp81El } = setup();
|
||||
globalThis.cards = [{ name: 'C', kic: '15', kid: '15', spi1: '16', spi2: '01',
|
||||
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY' }];
|
||||
globalThis.cards = [{ name: 'C', kic: '15', kid: '15',
|
||||
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;
|
||||
pysimCardStateUpdate(status({ connected: true, card_session: 2, iccid: '89701450001700031958' }));
|
||||
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', () => {
|
||||
const { adm, scp80El, scp81El } = setup();
|
||||
globalThis.cards = [{ name: 'C', adm: '0011', kic: '15', kid: '15', spi1: '16', spi2: '01',
|
||||
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY' }];
|
||||
globalThis.cards = [{ name: 'C', adm: '0011', kic: '15', kid: '15',
|
||||
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;
|
||||
pysimCardStateUpdate(status({ connected: true, adm_verified: true, iccid: '89701450001700031958' }));
|
||||
assert.ok(!adm.classes.has('hidden'));
|
||||
|
||||
@@ -5,6 +5,12 @@ const path = require('node:path');
|
||||
|
||||
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) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
@@ -22,18 +28,23 @@ function extractFunc(src, name) {
|
||||
}
|
||||
|
||||
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',
|
||||
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
|
||||
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
|
||||
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
|
||||
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard',
|
||||
'spTarListRender',
|
||||
'ramKeysetGuard',
|
||||
]) {
|
||||
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'
|
||||
+ 'globalThis.spMslWarningRender = function() {};\n'
|
||||
+ 'globalThis.pysimApplyAvailability = function() {};\n' + code;
|
||||
code = 'var _genSpBuildStub = function() {};\n' + code;
|
||||
eval(code);
|
||||
@@ -52,10 +63,12 @@ function fakeEnv(selValue, selId, presetCntr) {
|
||||
}
|
||||
els[selId || 'sp-card-sel'] = { value: selValue };
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.cards = [{ name: 'C', cntr: presetCntr, spi1: '16', spi2: '01',
|
||||
kic: '15', kid: '15', tar: '000000', uiccTar: 'B00000',
|
||||
kicKey: 'AA', kidKey: 'BB' }];
|
||||
globalThis._spTarKey = 'uiccTar';
|
||||
globalThis.cards = [{ name: 'C', cntr: presetCntr,
|
||||
kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB',
|
||||
tars: [{ role: 'isd', tar: '000000', msl: '16' },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
|
||||
globalThis._spTarKey = 'uiccRfm';
|
||||
globalThis.updateSpKic = () => {};
|
||||
globalThis.updateSpKid = () => {};
|
||||
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', () => {
|
||||
const env = fakeEnv('0', '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');
|
||||
});
|
||||
|
||||
|
||||
@@ -21,38 +21,57 @@ function extractFunc(src, name, asyncFn) {
|
||||
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 = '';
|
||||
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',
|
||||
'spPresetIdx', 'spCntrSyncPreset', 'spCntrLocalSync', 'ramSaveCntr',
|
||||
'cardsApplyFields', 'cardsApply', 'ramApplyCard',
|
||||
'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent',
|
||||
'spTarKeyForPack', 'spPresetTar', 'packToSp',
|
||||
'spTarKeyForPack', 'spPresetTar', 'spTarListRender', 'packToSp',
|
||||
'esc', 'escHtml',
|
||||
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
|
||||
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
|
||||
'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 = '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;
|
||||
eval(code);
|
||||
globalThis.t = s => s;
|
||||
|
||||
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-add-btn','cards-cancel-btn',
|
||||
// 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-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-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',
|
||||
'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'];
|
||||
const CHAIN_IDS = ['chain-sim-preview','chain-usim-preview','chain-ram-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() {
|
||||
return {
|
||||
value: '',
|
||||
@@ -86,7 +123,8 @@ function setup() {
|
||||
globalThis._cardsEditIdx = null;
|
||||
globalThis._ramCardIdx = null;
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
globalThis._spTarKey = 'tar';
|
||||
_cardsTarCount = 0;
|
||||
globalThis._spTarKey = 'isd';
|
||||
globalThis.t = s => s;
|
||||
globalThis.esc = s => s;
|
||||
globalThis.escHtml = s => s;
|
||||
@@ -125,25 +163,58 @@ function setup() {
|
||||
return els;
|
||||
}
|
||||
|
||||
test('cardsTarValue falls back to the per-target spec defaults', () => {
|
||||
assert.strictEqual(cardsTarValue('tar', '123456'), '123456');
|
||||
assert.strictEqual(cardsTarValue('tar', ''), '000000');
|
||||
assert.strictEqual(cardsTarValue('uiccTar', ''), 'B00000');
|
||||
assert.strictEqual(cardsTarValue('usimTar', null), 'B00001');
|
||||
assert.strictEqual(cardsTarValue('nope', ''), '');
|
||||
test('the preset TAR table helpers read the role entries and the MSLs', () => {
|
||||
const p = presetFixture({ tars: [
|
||||
{ role: 'isd', tar: 'AF4D01', msl: '1A' },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '10' },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16' },
|
||||
{ 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', () => {
|
||||
const full = { kic: '15', kid: '15', spi1: '16', spi2: '01', cntr: '0000000001',
|
||||
kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY', adm: '0011' };
|
||||
const full = presetFixture({ pskIdentity: 'id', pskKey: 'KEY', adm: '0011' });
|
||||
assert.ok(cardsScp80Complete(full));
|
||||
assert.ok(cardsScp81Complete(full));
|
||||
assert.ok(cardsAdmPresent(full));
|
||||
// every SCP80 field is required; whitespace counts as empty
|
||||
for (const field of ['kic', 'kid', 'spi1', 'spi2', 'cntr', 'kicKey', 'kidKey']) {
|
||||
const c = Object.assign({}, full);
|
||||
c[field] = ' ';
|
||||
assert.ok(!cardsScp80Complete(c), 'SCP80 marked complete with empty ' + field);
|
||||
// a complete keyset is required
|
||||
assert.ok(!cardsScp80Complete(Object.assign({}, full, { keysets: [] })));
|
||||
assert.ok(!cardsScp80Complete(Object.assign({}, full, {
|
||||
keysets: [{ kic: '15', kid: '15', kicKey: '', kidKey: 'BB', cntr: '0000000001' }] })));
|
||||
// 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({ pskKey: 'KEY' }));
|
||||
@@ -153,60 +224,82 @@ test('preset completeness predicates used by the header markers', () => {
|
||||
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();
|
||||
els['cards-tar'].value = 'AABBCC';
|
||||
els['cards-tar-msl'].value = 'FF';
|
||||
els['cards-uicc-tar'].value = 'AABBCC';
|
||||
els['cards-usim-tar'].value = 'AABBCC';
|
||||
els['cards-adm'].value = 'DEAD';
|
||||
els['cards-name'].value = 'X';
|
||||
tarRow(els, 0, { tar: 'AF4D01', msl: '0A', desc: 'applet' });
|
||||
_cardsTarCount = 1;
|
||||
cardsClearForm();
|
||||
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-msl'].value, '16');
|
||||
assert.strictEqual(els['cards-usim-tar'].value, 'B00001');
|
||||
assert.strictEqual(els['cards-adm'].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();
|
||||
globalThis.cards = [
|
||||
{ name: 'Legacy', kic: '15', kid: '15', tar: '' },
|
||||
{ name: 'Full', kic: '15', kid: '15', tar: '000001', uiccTar: 'B0000C', usimTar: 'B0000D', adm: '0011' },
|
||||
{ name: 'Legacy', keysets: [] },
|
||||
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);
|
||||
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-usim-tar'].value, 'B00001');
|
||||
assert.strictEqual(els['cards-adm'].value, '');
|
||||
cardsEdit(1);
|
||||
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-usim-tar'].value, 'B0000D');
|
||||
assert.strictEqual(els['cards-usim-tar-msl'].value, '16');
|
||||
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();
|
||||
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-msl'].value = '1A';
|
||||
els['cards-adm'].value = ' 00 11 22 33 ';
|
||||
tarRow(els, 0, { tar: 'af4d02', msl: '0A', desc: 'My applet' });
|
||||
_cardsTarCount = 1;
|
||||
const v = cardsFormValues();
|
||||
assert.strictEqual(v.tar, '000001');
|
||||
assert.strictEqual(v.uiccTar, 'B00002');
|
||||
assert.strictEqual(v.usimTar, 'B00003');
|
||||
assert.deepStrictEqual(v.tars, [
|
||||
{ role: 'isd', tar: '000001', msl: '0A', desc: '' },
|
||||
{ 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');
|
||||
});
|
||||
|
||||
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();
|
||||
els['cards-name'].value = 'New card';
|
||||
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
els['cards-tar'].value = '';
|
||||
els['cards-uicc-tar'].value = '';
|
||||
els['cards-usim-tar'].value = '';
|
||||
// the role fields are pre-settled by the markup; the test's fake elements
|
||||
// start empty - the defaults fill in
|
||||
els['cards-adm'].value = 'aa bb';
|
||||
await cardsAdd();
|
||||
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.deepStrictEqual(apiCalls[0].body.keysets, [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '' }]);
|
||||
assert.strictEqual(apiCalls[0].body.tar, '000000');
|
||||
assert.strictEqual(apiCalls[0].body.uiccTar, 'B00000');
|
||||
assert.strictEqual(apiCalls[0].body.usimTar, 'B00001');
|
||||
assert.deepStrictEqual(apiCalls[0].body.tars, [
|
||||
{ role: 'isd', tar: '000000', msl: '16', desc: '' },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '' },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '' },
|
||||
]);
|
||||
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 () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [{ id: 'abc123', name: 'Old', keysets: [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
|
||||
globalThis.cards = [presetFixture({ id: 'abc123', name: 'Old' })];
|
||||
globalThis._cardsEditIdx = 0;
|
||||
els['cards-name'].value = 'Renamed';
|
||||
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].body.id, 'abc123');
|
||||
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 () => {
|
||||
@@ -284,72 +399,91 @@ test('the SCP80 counter edit is written to the store, a reported counter only lo
|
||||
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();
|
||||
globalThis.cards = [{
|
||||
name: 'C', kic: '15', kid: '15', spi1: '16', spi2: '01',
|
||||
tar: '000000', uiccTar: 'B0000C', usimTar: 'B0000D',
|
||||
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB',
|
||||
}];
|
||||
globalThis._spTarKey = 'tar';
|
||||
globalThis.cards = [presetFixture({ tars: [
|
||||
{ role: 'isd', tar: '000000', msl: '16' },
|
||||
{ role: 'uiccRfm', tar: 'B0000C', msl: '0A' },
|
||||
{ role: 'usimRfm', tar: 'B0000D', msl: '1A' }] })];
|
||||
globalThis._spTarKey = 'isd';
|
||||
cardsApply(0);
|
||||
assert.strictEqual(els['sp-tar'].value, '000000');
|
||||
globalThis._spTarKey = 'uiccTar';
|
||||
assert.strictEqual(els['sp-spi1'].value, '16');
|
||||
globalThis._spTarKey = 'uiccRfm';
|
||||
cardsApply(0);
|
||||
assert.strictEqual(els['sp-tar'].value, 'B0000C');
|
||||
globalThis._spTarKey = 'usimTar';
|
||||
assert.strictEqual(els['sp-spi1'].value, '0A');
|
||||
globalThis._spTarKey = 'usimRfm';
|
||||
cardsApply(0);
|
||||
assert.strictEqual(els['sp-tar'].value, 'B0000D');
|
||||
// empty stored TAR -> spec default
|
||||
globalThis.cards = [{ name: 'D', kic: '15', kid: '15', tar: '' }];
|
||||
globalThis._spTarKey = 'tar';
|
||||
assert.strictEqual(els['sp-spi1'].value, '1A');
|
||||
// an empty stored TAR -> spec default
|
||||
globalThis.cards = [{ name: 'D', keysets: [], tars: [] }];
|
||||
globalThis._spTarKey = 'isd';
|
||||
cardsApply(0);
|
||||
assert.strictEqual(els['sp-tar'].value, '000000');
|
||||
assert.strictEqual(els['sp-spi1'].value, '16');
|
||||
});
|
||||
|
||||
test('RAM card selection forces the ISD TAR', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [{ name: 'C', kic: '15', kid: '15', tar: '000000', uiccTar: 'B0000C', usimTar: 'B0000D' }];
|
||||
globalThis._spTarKey = 'usimTar';
|
||||
globalThis.cards = [presetFixture()];
|
||||
globalThis._spTarKey = 'usimRfm';
|
||||
ramApplyCard('0');
|
||||
assert.strictEqual(globalThis._spTarKey, 'tar');
|
||||
assert.strictEqual(globalThis._spTarKey, 'isd');
|
||||
assert.strictEqual(els['sp-tar'].value, '000000');
|
||||
});
|
||||
|
||||
test('spTarKeyForPack maps each chain to its preset TAR field', () => {
|
||||
assert.strictEqual(spTarKeyForPack('chain-ram-preview'), 'tar');
|
||||
assert.strictEqual(spTarKeyForPack('chain-sim-preview'), 'uiccTar');
|
||||
assert.strictEqual(spTarKeyForPack('chain-usim-preview'), 'usimTar');
|
||||
test('spTarKeyForPack maps each chain to its preset TAR role', () => {
|
||||
assert.strictEqual(spTarKeyForPack('chain-ram-preview'), 'isd');
|
||||
assert.strictEqual(spTarKeyForPack('chain-sim-preview'), 'uiccRfm');
|
||||
assert.strictEqual(spTarKeyForPack('chain-usim-preview'), 'usimRfm');
|
||||
assert.strictEqual(spTarKeyForPack('ber-result'), null);
|
||||
assert.strictEqual(spTarKeyForPack('hota-preview'), null);
|
||||
});
|
||||
|
||||
test('spPresetTar reads the selected preset and falls back to the spec defaults', () => {
|
||||
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';
|
||||
assert.strictEqual(spPresetTar('uiccTar'), 'B0000A');
|
||||
assert.strictEqual(spPresetTar('usimTar'), 'B00001');
|
||||
assert.strictEqual(spPresetTar('uiccRfm'), 'B0000A');
|
||||
assert.strictEqual(spPresetTar('usimRfm'), 'B00001');
|
||||
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', () => {
|
||||
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['chain-sim-preview'].value = 'A0A4000002';
|
||||
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-apdu'].value, 'A0A4000002');
|
||||
els['chain-usim-preview'].value = '00A4000002';
|
||||
packToSp('chain-usim-preview');
|
||||
assert.strictEqual(globalThis._spTarKey, 'usimTar');
|
||||
assert.strictEqual(globalThis._spTarKey, 'usimRfm');
|
||||
assert.strictEqual(els['sp-tar'].value, 'B0000D');
|
||||
els['chain-ram-preview'].value = '80E29000';
|
||||
packToSp('chain-ram-preview');
|
||||
assert.strictEqual(globalThis._spTarKey, 'tar');
|
||||
assert.strictEqual(globalThis._spTarKey, 'isd');
|
||||
assert.strictEqual(els['sp-tar'].value, '000000');
|
||||
// BER / HTTP OTA packs leave the TAR alone
|
||||
els['sp-tar'].value = 'ABCDEF';
|
||||
@@ -371,3 +505,24 @@ test('packing without a selected preset uses the spec default TAR', () => {
|
||||
packToSp('chain-ram-preview');
|
||||
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' }]);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,12 @@ const path = require('node:path');
|
||||
|
||||
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) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
@@ -25,9 +31,13 @@ let code = '';
|
||||
for (const fn of ['swapNibbles', 'encIccid', 'decIccid',
|
||||
'cardsNormIccid', 'cardsFindByIccid', 'cardsFindDuplicateIccid',
|
||||
'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 += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
|
||||
+ extractConst(html, 'CARDS_MSL_DEFAULT');
|
||||
eval(code);
|
||||
|
||||
// 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: '' } };
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.cards = [
|
||||
{ name: 'other', iccid: '1111111111111111111', spi1: '16', spi2: '01', keysets: [] },
|
||||
{ name: 'live', iccid: DIGITS, spi1: '16', spi2: '01', keysets: [] },
|
||||
{ name: 'other', iccid: '1111111111111111111', keysets: [] },
|
||||
{ name: 'live', iccid: DIGITS, keysets: [] },
|
||||
];
|
||||
globalThis._pysimCardIccid = DIGITS;
|
||||
cardsRender();
|
||||
|
||||
@@ -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, />UICC RFM TAR<\/label>/);
|
||||
assert.match(scp80, />ADF RFM TAR<\/label>/);
|
||||
assert.ok(scp80.indexOf('id="cards-spi1"') < scp80.indexOf('id="cards-tar"'),
|
||||
'TAR fields must follow the SPI fields');
|
||||
// the preset-wide SPI1/SPI2 inputs are gone: each TAR carries its MSL
|
||||
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-key"/);
|
||||
// the PSK explanation lives inside the SCP81 group, not outside it
|
||||
|
||||
@@ -5,6 +5,12 @@ const path = require('node:path');
|
||||
|
||||
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) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
@@ -31,6 +37,8 @@ code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdateIccidIndicator') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdatePresetIndicator') + '\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, 'cardsScp81Complete') + '\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', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [{ name: 'C', kic: '15', kid: '15', spi1: '16', spi2: '01',
|
||||
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY' }];
|
||||
globalThis.cards = [{ name: 'C', kic: '15', kid: '15',
|
||||
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;
|
||||
_pysimCardIccid = '89701450001700031958';
|
||||
pysimUpdatePresetIndicators();
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-simple-server"
|
||||
version = "3.9.1"
|
||||
version = "3.10.0"
|
||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||
requires-python = ">=3.8"
|
||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||
|
||||
+115
-13
@@ -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
|
||||
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
|
||||
(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"
|
||||
@@ -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
|
||||
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``
|
||||
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
|
||||
@@ -46,21 +59,28 @@ from pathlib import Path
|
||||
|
||||
DEFAULT_DIR = '.pysim-simple-server'
|
||||
DEFAULT_FILENAME = 'card_presets.json'
|
||||
SCHEMA_VERSION = 2
|
||||
SCHEMA_VERSION = 3
|
||||
|
||||
# The fields a preset carries: the localStorage shape plus the id. Defaults
|
||||
# mirror the Cards form (spi1/spi2 and the per-target TARs). The OTA key
|
||||
# The fields a preset carries: the localStorage shape plus the id. The OTA key
|
||||
# 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
|
||||
# be associated to each key version").
|
||||
PRESET_FIELDS = ('name', 'iccid', 'adm', 'spi1', 'spi2',
|
||||
'tar', 'uiccTar', 'usimTar', 'pskIdentity', 'pskKey')
|
||||
# be associated to each key version"); the TAR table lives in `tars` (see
|
||||
# TAR_ROLES).
|
||||
PRESET_FIELDS = ('name', 'iccid', 'adm', 'pskIdentity', 'pskKey')
|
||||
# The v3.8.0 flat key fields: converted into a single keyset on load/import.
|
||||
LEGACY_KEY_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')
|
||||
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_WIDTH = 10 # ... shown as 10 hex digits
|
||||
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))
|
||||
|
||||
|
||||
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):
|
||||
"""A refused mutation (validation, duplicate ICCID, ...)."""
|
||||
|
||||
@@ -234,10 +280,7 @@ class PresetStore:
|
||||
if k in HEX_FIELDS:
|
||||
v = re.sub(r'\s', '', v).upper()
|
||||
out[k] = v
|
||||
out['spi1'] = out['spi1'] or '16'
|
||||
out['spi2'] = out['spi2'] or '01'
|
||||
for k, d in TAR_DEFAULTS.items():
|
||||
out[k] = out[k] or d
|
||||
out['tars'] = self._normalise_tars(fields)
|
||||
# Keysets: a v3.8.0 preset (flat kic/kid/kicKey/kidKey/cntr) converts
|
||||
# into a single keyset, so old files and exports keep working.
|
||||
raw = fields.get('keysets')
|
||||
@@ -251,6 +294,51 @@ class PresetStore:
|
||||
out['id'] = pid or uuid.uuid4().hex
|
||||
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):
|
||||
"""One keyset, cleaned. The counter is kept as typed here and
|
||||
validated + padded in _validate_keyset() - a bad value must be
|
||||
@@ -281,6 +369,18 @@ class PresetStore:
|
||||
if kvn in seen:
|
||||
raise PresetError('duplicate keyset number %02X in this preset' % 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'])
|
||||
if norm:
|
||||
for other in self._presets:
|
||||
@@ -372,6 +472,8 @@ class PresetStore:
|
||||
merged[k] = fields[k]
|
||||
if isinstance(fields, dict) and 'keysets' in fields:
|
||||
merged['keysets'] = fields['keysets']
|
||||
if isinstance(fields, dict) and isinstance(fields.get('tars'), list):
|
||||
merged['tars'] = fields['tars']
|
||||
new = self._normalise(merged)
|
||||
new['id'] = cur['id']
|
||||
self._validate(new, skip_id=cur['id'])
|
||||
|
||||
+153
-40
@@ -32,7 +32,7 @@ from osmocom.tlv import BER_TLV_IE
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
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
|
||||
# Command TLV '22' inside the 'AA' scripting template (expanded) - the form
|
||||
# the reference terminal traces use. The format is detected per operation
|
||||
@@ -1656,7 +1707,8 @@ def _counter_probe_params(preset, body):
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('invalid attempt budget')
|
||||
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):
|
||||
raise ValueError('the probe needs a counter check - SPI1 %s has none '
|
||||
'(b5b4 = 00)' % spi1)
|
||||
@@ -1666,8 +1718,8 @@ def _counter_probe_params(preset, body):
|
||||
'start': start, 'ceiling': ceiling,
|
||||
'max_attempts': max_attempts,
|
||||
'spi1': spi1,
|
||||
'spi2': str(body.get('spi2') or preset.get('spi2') or '21').strip().upper(),
|
||||
'tar': str(body.get('tar') or preset.get('tar') or '000000').strip().upper(),
|
||||
'spi2': str(body.get('spi2') or '01').strip().upper(),
|
||||
'tar': tar,
|
||||
# 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
|
||||
# security acceptance, not on the command's own result)
|
||||
@@ -5594,10 +5646,12 @@ def _test_preset_error(script, preset):
|
||||
if not _keyset_complete(ks):
|
||||
return ('step %d: keyset %d is incomplete (KIc, KID, both '
|
||||
'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)
|
||||
if not (p.get('spi1') or preset.get('spi1')) or not (p.get('spi2') or preset.get('spi2')):
|
||||
return 'step %d: no SPI1/SPI2 (neither in the step nor in the preset)' % (i + 1)
|
||||
if not (p.get('spi1') or presets.tar_msl(preset, tar)):
|
||||
return ('step %d: no SPI1 for TAR %s (neither in the step nor as '
|
||||
'its MSL in the preset)' % (i + 1, tar))
|
||||
return None
|
||||
|
||||
|
||||
@@ -5742,9 +5796,14 @@ def _test_run_scp80(server, step, ctx):
|
||||
kvn, kvn_err = _kvn_of(kic, kid)
|
||||
if kvn_err:
|
||||
raise testscript.ScriptError(kvn_err)
|
||||
spi1 = p.get('spi1') or preset.get('spi1') or '16'
|
||||
spi2 = p.get('spi2') or preset.get('spi2') or '01'
|
||||
tar = p.get('tar') or preset.get('tar') or ''
|
||||
tar = p.get('tar') or presets.role_tar(preset, 'isd') or ''
|
||||
try:
|
||||
# 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', {})
|
||||
if kvn and kvn not in counters:
|
||||
counters[kvn] = (keyset or {}).get('cntr') or '00000000'
|
||||
@@ -7210,14 +7269,29 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(resp, 400)
|
||||
self._log_resp(resp)
|
||||
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
|
||||
# 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
|
||||
# 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:
|
||||
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)
|
||||
ram_format = None
|
||||
try:
|
||||
@@ -7225,8 +7299,6 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
# RAM operation: SCP80-wrap the raw GP command. The packet
|
||||
# may exceed one SMS (pySim refuses that), so use our own
|
||||
# encoder and let _send_secured_packet segment it.
|
||||
spi1 = body.get('spi1', '16')
|
||||
spi2 = body.get('spi2', '01')
|
||||
kic = body.get('kic', '25')
|
||||
kid = body.get('kid', '25')
|
||||
tar = body.get('tar', '000000')
|
||||
@@ -7256,7 +7328,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
# Regular SCP80: use pre-built secured packet
|
||||
sp_hex = sp
|
||||
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
|
||||
# ENVELOPE response or as a proactive SEND SHORT MESSAGE (some
|
||||
# cards always submit it, whatever the SPI2 request bit says;
|
||||
@@ -7269,7 +7341,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
scc._tp.proactive_handler = submit_handler
|
||||
try:
|
||||
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', ''),
|
||||
len(sp_hex) // 2))
|
||||
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 ''
|
||||
if not failed_por_hex and 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('cntr', ''), body.get('kicKey', ''),
|
||||
body.get('kidKey', ''), failed_por_hex)
|
||||
@@ -7326,7 +7398,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
if idx >= 0:
|
||||
por_hex = tpdu_b[idx:].hex()
|
||||
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('kidKey', ''), por_hex)
|
||||
# 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:
|
||||
_preset_counter_persist(self.server, body.get('preset_id'),
|
||||
resp.get('final_cntr'), 'send-ota', kvn)
|
||||
if spi_warn:
|
||||
resp['warning'] = spi_warn
|
||||
self._send_json(resp)
|
||||
self._log_resp(resp)
|
||||
except Exception as e:
|
||||
@@ -7434,12 +7508,23 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
# Parse .cap file
|
||||
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
|
||||
|
||||
# SCP80 params
|
||||
spi1 = body.get('spi1', '16')
|
||||
# SCP80 params: the SPI1 comes from the MSL of the packet's
|
||||
# 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')
|
||||
kic = body.get('kic', '25')
|
||||
kid = body.get('kid', '25')
|
||||
tar = body.get('tar', '000000')
|
||||
cntr = body.get('cntr', '00000000')
|
||||
kic_key = body.get('kicKey', '')
|
||||
kid_key = body.get('kidKey', '')
|
||||
@@ -7612,9 +7697,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
stk_params=stk_params_hex, make_selectable=make_selectable)
|
||||
step_name = 'INSTALL [for install]'
|
||||
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'),
|
||||
'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', ''),
|
||||
'include_cpi': body.get('includeCpi', True)}
|
||||
# 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,
|
||||
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')
|
||||
kic = body.get('kic', '25')
|
||||
kid = body.get('kid', '25')
|
||||
tar = body.get('tar', '000000')
|
||||
cntr = body.get('cntr', '00000000')
|
||||
kic_key = body.get('kicKey', '')
|
||||
kid_key = body.get('kidKey', '')
|
||||
@@ -7939,16 +8033,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
self._log_resp(err)
|
||||
return
|
||||
# The probe sends real secured packets: it needs a counter check
|
||||
# (every accepted packet advances and is persisted) and a PoR.
|
||||
spi1 = str(body.get('spi1') or '16').strip().upper()
|
||||
# (every accepted packet advances and is persisted) and a PoR. Each
|
||||
# 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()
|
||||
if not _counter_tracked(spi1):
|
||||
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
|
||||
preset = _preset_by_id(self.server, body.get('preset_id'))
|
||||
apdu = (body.get('apdu') or TAR_PROBE_DEFAULT_APDU).replace(' ', '').upper()
|
||||
labels = {tar: label for tar, label in TAR_PROBE_TARS}
|
||||
wanted = body.get('tars')
|
||||
@@ -7959,26 +8049,49 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
entries.append((tar, labels.get(tar, '')))
|
||||
else:
|
||||
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': {},
|
||||
'cntr': body.get('cntr', '00000000'),
|
||||
'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', ''),
|
||||
'include_cpi': body.get('includeCpi', True)}
|
||||
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))
|
||||
try:
|
||||
for index, (tar, _label) in enumerate(entries):
|
||||
for index, (tar, _label, tar_spi1) in enumerate(resolved):
|
||||
_ram_progress_step(index, 'TAR ' + tar)
|
||||
sp['tar'] = tar
|
||||
sp['spi1'] = tar_spi1
|
||||
_ram_send_gp_apdu(self.server, scc, sp, state, 'TAR ' + tar, apdu)
|
||||
finally:
|
||||
_ram_progress_end()
|
||||
results = []
|
||||
for (tar, label), step in zip(entries, state['steps']):
|
||||
for (tar, label, tar_spi1), step in zip(resolved, state['steps']):
|
||||
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'),
|
||||
'por_sw': step.get('por_sw'), 'por_data': step.get('por_data'),
|
||||
'bytes': step.get('bytes'), 'segments': step.get('segments'),
|
||||
@@ -7987,7 +8100,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
resp = {'success': True, 'results': results, 'steps': state['steps'],
|
||||
'registered': registered, 'total': len(results),
|
||||
'final_cntr': state['cntr'], 'kvn': kvn,
|
||||
'spi1': spi1, 'spi2': spi2, 'apdu': apdu}
|
||||
'spi1': spi1_base, 'spi2': spi2, 'apdu': apdu}
|
||||
self._send_json(resp)
|
||||
self._log_resp(resp)
|
||||
elif self.path == '/api/counter-probe':
|
||||
|
||||
@@ -237,7 +237,7 @@ class CapCompatHttpTests(unittest.TestCase):
|
||||
p.start()
|
||||
try:
|
||||
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'},
|
||||
})
|
||||
finally:
|
||||
@@ -306,7 +306,7 @@ class RamInstallNvFootprintHttpTests(unittest.TestCase):
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
return self._post('/api/ram-install', {'cap_hex': 'AA'})
|
||||
return self._post('/api/ram-install', {'cap_hex': 'AA', 'spi1': '16'})
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
@@ -444,7 +444,7 @@ class CntrLowGuardHttpTests(unittest.TestCase):
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
status, resp = self._post('/api/ram-install', {'cap_hex': 'AA'})
|
||||
status, resp = self._post('/api/ram-install', {'cap_hex': 'AA', 'spi1': '16'})
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
@@ -653,6 +653,90 @@ class PresetStoreHttpTests(unittest.TestCase):
|
||||
self.assertEqual(resp['final_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):
|
||||
# a pre-built packet without a counter: no final_cntr, no persist,
|
||||
# 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
|
||||
with mock.patch.object(self.srv, '_ram_send_gp_apdu', fake_step):
|
||||
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',
|
||||
'tars': ['000000', 'B00000', 'B00001', 'B00200']})
|
||||
self.assertEqual(status, 200, resp)
|
||||
|
||||
@@ -54,6 +54,7 @@ from pysim_simple_server.server import (
|
||||
_ram_step_result,
|
||||
_record_tr,
|
||||
_send_secured_packet,
|
||||
_spi1_for_tar,
|
||||
_spi_from_bytes,
|
||||
_split_secured_packet,
|
||||
_tr_data_only,
|
||||
@@ -2296,13 +2297,61 @@ class ExpandedRemoteTests(unittest.TestCase):
|
||||
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):
|
||||
"""The bounded counter synchronisation probe (v3.9.x)."""
|
||||
|
||||
@staticmethod
|
||||
def _preset(cntr='0000000C2D'):
|
||||
return {'id': 'p1', 'name': 'test card', 'spi1': '16', 'spi2': '21',
|
||||
'tar': '000000',
|
||||
return {'id': 'p1', 'name': 'test card',
|
||||
'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,
|
||||
'kidKey': '11' * 16, 'cntr': cntr}]}
|
||||
|
||||
@@ -2373,7 +2422,7 @@ class CounterProbeTests(unittest.TestCase):
|
||||
self.assertIn('not defined', str(cm.exception))
|
||||
# SPI1 without a counter check cannot probe
|
||||
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):
|
||||
_counter_probe_params(no_check, {})
|
||||
# the ceiling must stay below the 40-bit maximum
|
||||
|
||||
+85
-5
@@ -91,15 +91,80 @@ class StoreCrudTests(unittest.TestCase):
|
||||
def test_add_applies_the_form_defaults_and_assigns_an_id(self):
|
||||
p = self.store.add(preset(iccid=DIGITS))
|
||||
self.assertTrue(p['id'])
|
||||
self.assertEqual(p['spi1'], '16')
|
||||
self.assertEqual(p['spi2'], '01')
|
||||
self.assertEqual(p['tar'], '000000')
|
||||
self.assertEqual(p['uiccTar'], 'B00000')
|
||||
self.assertEqual(p['usimTar'], 'B00001')
|
||||
self.assertEqual([t['role'] for t in p['tars']],
|
||||
['isd', 'uiccRfm', 'usimRfm'])
|
||||
self.assertEqual([t['tar'] for t in p['tars']],
|
||||
['000000', 'B00000', '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(presets.keyset_kvn(p['keysets'][0]), 1)
|
||||
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):
|
||||
p = self.store.add(preset(keysets=[keyset(kic='15', kid='15'),
|
||||
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]['kicKey'], '11')
|
||||
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):
|
||||
store = make_store(self.tmp.name)
|
||||
|
||||
@@ -22,6 +22,13 @@ def _resolver(name):
|
||||
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):
|
||||
def test_minimal_script_normalises(self):
|
||||
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):
|
||||
scc = FakeScc()
|
||||
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,
|
||||
'counter': '0000000A', 'tar': 'B00000', 'spi1': '16', 'spi2': '01'}
|
||||
'counter': '0000000A', 'tars': preset_tars(isd='B00000')}
|
||||
with mock.patch.object(S, '_build_secured_packet',
|
||||
return_value=('AA' * 10, {})) as build, \
|
||||
mock.patch.object(S, '_send_secured_packet',
|
||||
@@ -401,7 +410,7 @@ class TestRunnerActions(RunnerTestCase):
|
||||
def test_scp80_step_overrides_tar_and_spi_only(self):
|
||||
scc = FakeScc()
|
||||
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, \
|
||||
mock.patch.object(S, '_send_secured_packet',
|
||||
return_value={'success': True, 'sw': '9000',
|
||||
@@ -421,9 +430,9 @@ class TestRunnerActions(RunnerTestCase):
|
||||
]}, S._test_command_type)
|
||||
self.assertIn('incomplete', S._test_preset_error(script, {}) or '')
|
||||
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))
|
||||
no_tar = dict(full, tar='')
|
||||
no_tar = dict(full, tars=[])
|
||||
self.assertIn('TAR', S._test_preset_error(script, no_tar) or '')
|
||||
|
||||
def test_file_write_and_read_actions(self):
|
||||
|
||||
Reference in New Issue
Block a user