v3.11.0: Cards tab redesign (list + editor), preset PIN/PUK codes

The Cards tab now shows the preset list by default; "Add preset" / "Edit"
open a separate editor view (Save stores and returns to the list, Cancel
discards; ramGoToPreset opens it too).  The editor keeps the name/ICCID
header (with From card) and groups its fields into bordered blocks:
- PIN/ADM: the new optional card codes PIN1/PUK1/PIN2/PUK2 (plain text,
  4-8 decimal digits) plus ADM, moved out of the top row; nothing consumes
  the PIN/PUK codes yet.
- TAR: one uniform table (TAR, MSL, description); the first three rows are
  the fixed role entries with the immutable English descriptions "ISD
  compact format" / "UICC shared FS RFM compact format" / "ADF FS RFM
  compact format"; further rows are deletable with an optional description.
- SCP80 (keyset editor) and SCP81 (PSK pair) as before.

Store: PRESET_FIELDS gains pin1/puk1/pin2/puk2 (optional; whitespace
stripped; 4-8 decimal digits validated on save and import), SCHEMA_VERSION
4, docs/help/AGENTS updated.  TAR rows use unified ids (cards-tr-N-*), the
editor switches via cardsShowList/cardsShowEditor.

Tests: 665 Python + 783 frontend green; the whole inline PWA script
syntax-checked.  sw cache simple-v361.
This commit is contained in:
2026-10-02 09:32:45 +03:00
parent 5dd056beb8
commit 65c565e058
12 changed files with 451 additions and 188 deletions
+7 -1
View File
@@ -204,6 +204,7 @@ the bytes, there is no separate field:
```json ```json
{"id": "…", "name": "Card A", "iccid": "…", {"id": "…", "name": "Card A", "iccid": "…",
"pin1": "", "puk1": "", "pin2": "", "puk2": "",
"pskIdentity": "", "pskKey": "", "adm": "", "pskIdentity": "", "pskKey": "", "adm": "",
"tars": [ "tars": [
{"role": "isd", "tar": "000000", "msl": "16", "desc": ""}, {"role": "isd", "tar": "000000", "msl": "16", "desc": ""},
@@ -239,6 +240,11 @@ 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 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. pass `spi1` itself, otherwise it is refused with `400` and a hint.
**Card codes.** The optional `pin1`/`puk1`/`pin2`/`puk2`/`adm` fields are
stored for later use (nothing consumes them yet). PIN/PUK must be **4–8
decimal digits** when set (empty is fine; anything else is refused with `400`
naming the field); the ADM key stays free-form (hex or ASCII digits).
**Counters.** The store is the source of truth for the SCP80 counters, one per **Counters.** The store is the source of truth for the SCP80 counters, one per
keyset ("a dedicated counter shall be associated to each key version", keyset ("a dedicated counter shall be associated to each key version",
Annex A.1 — the spec's wording). The operations below accept a `preset_id` and **persist the Annex A.1 — the spec's wording). The operations below accept a `preset_id` and **persist the
@@ -250,7 +256,7 @@ is not defined in preset '…' - add it in the Cards tab"}`), and so is a
KIc/KID number mismatch. The Cards tab's export/import uses this API; the KIc/KID number mismatch. The Cards tab's export/import uses this API; the
import also accepts presets exported by the older localStorage-based builds. import also accepts presets exported by the older localStorage-based builds.
- `GET /api/presets` — `{"path": "…", "count": 2, "version": 3, "presets": [{…}]}` - `GET /api/presets` — `{"path": "…", "count": 2, "version": 4, "presets": [{…}]}`
(card-free: it answers while a long card operation runs). (card-free: it answers while a long card operation runs).
- `POST /api/presets` — body = the preset fields (`keysets` included); returns - `POST /api/presets` — body = the preset fields (`keysets` included); returns
`{"ok": true, "preset": {…}}`. A duplicate ICCID (digits, spaced or raw EF `{"ok": true, "preset": {…}}`. A duplicate ICCID (digits, spaced or raw EF
+7 -7
View File
@@ -346,25 +346,25 @@
<p class="mb-2">Каждый принятый картой пакет <strong>расходует счётчик</strong>, который сохраняется в keyset пресета &mdash; проба отклоняется, если в разрешённом SPI1 нет проверки счётчика или в пресете нет такого номера keyset. На живой карте: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> и <code class="font-mono text-sm">B00001</code> зарегистрированы (приложения компактного формата), расширенный формат, USAT, мультиплексирование и CASD отклонены с <code class="font-mono text-sm">6200</code>.</p> <p class="mb-2">Каждый принятый картой пакет <strong>расходует счётчик</strong>, который сохраняется в keyset пресета &mdash; проба отклоняется, если в разрешённом SPI1 нет проверки счётчика или в пресете нет такого номера keyset. На живой карте: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> и <code class="font-mono text-sm">B00001</code> зарегистрированы (приложения компактного формата), расширенный формат, USAT, мультиплексирование и CASD отклонены с <code class="font-mono text-sm">6200</code>.</p>
<h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка &laquo;Карты&raquo;</h2> <h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка &laquo;Карты&raquo;</h2>
<p class="mb-2">Хранит пресеты карт на сервере (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; переопределяется <code class="font-mono text-sm">--card-presets</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, слушатель SCP81 HTTP OTA — находить PSK-ключи, а счётчики SCP80, израсходованные операцией, сохранялись самой операцией &mdash; закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Пресет содержит <strong>несколько keysets</strong> &mdash; номер keyset задаётся нибблом <code class="font-mono text-sm">b8..b5</code> его KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (01&ndash;0F; A.2 отвергает несовпадение), и у каждого keyset на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по keysets. В форме keysets редактируются строками &mdash; по одной на номер, с кнопками <strong>Добавить keyset</strong> / <strong>Удалить keyset</strong>; номер в строке вычисляется из байта KIc/KID, а <code class="font-mono text-sm">00</code> («без защиты») не принимается как keyset. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и пресеты старых сборок — плоские и с keysets). &laquo;Карты&raquo; — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх представлениях SCP80; если подключённая карта не совпадает <strong>ни с одним</strong> пресетом &mdash; или выбранный пресет принадлежит другой карте &mdash; все три представления SCP80 показывают жёлтое предупреждение над выбором пресета. В списке подсвечивается пресет подключённой карты. Поля формы сгруппированы в два блока с рамкой: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (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> <p class="mb-2">Хранит пресеты карт на сервере (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; переопределяется <code class="font-mono text-sm">--card-presets</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, слушатель SCP81 HTTP OTA — находить PSK-ключи, а счётчики SCP80, израсходованные операцией, сохранялись самой операцией &mdash; закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Пресет содержит <strong>несколько keysets</strong> &mdash; номер keyset задаётся нибблом <code class="font-mono text-sm">b8..b5</code> его KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (01&ndash;0F; A.2 отвергает несовпадение), и у каждого keyset на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по keysets. В форме keysets редактируются строками &mdash; по одной на номер, с кнопками <strong>Добавить keyset</strong> / <strong>Удалить keyset</strong>; номер в строке вычисляется из байта KIc/KID, а <code class="font-mono text-sm">00</code> («без защиты») не принимается как keyset. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и пресеты старых сборок — плоские и с keysets). &laquo;Карты&raquo; — верхнеуровневая вкладка: по умолчанию показывается <strong>список пресетов</strong>, а <strong>Добавить пресет</strong> / <strong>Редактировать</strong> открывают <strong>редактор</strong> (отдельное представление) — <strong>Сохранить</strong> сохраняет пресет и возвращает к списку, <strong>Отмена</strong> отменяет правки. При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх представлениях SCP80; если подключённая карта не совпадает <strong>ни с одним</strong> пресетом &mdash; или выбранный пресет принадлежит другой карте &mdash; все три представления SCP80 показывают жёлтое предупреждение над выбором пресета. В списке подсвечивается пресет подключённой карты. Поля редактора сгруппированы в блоки с рамкой: <strong>PIN/ADM</strong> (необязательные коды карты — PIN1/PUK1/PIN2/PUK2 по 4–8 десятичных цифр и ключ ADM — сохраняются для будущего использования), <strong>TAR</strong> (таблица TAR), <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (keysets и их счётчики) и <strong>SCP81 (HTTP OTA)</strong> (пара PSK identity/ключ с пояснением о выборе ключа). В шапке отображаются серые маркеры <strong>SCP80</strong>/<strong>SCP81</strong> и значок ключа на бейдже ADM, когда в пресете с ICCID подключённой карты эти поля заполнены.</p>
<table class="w-full text-sm mb-3 border-collapse"> <table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Поле</th><th class="text-left py-1 px-2">Описание</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Поле</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
<tbody> <tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Name</td><td class="py-1 px-2">Понятная метка (обязательна)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Name</td><td class="py-1 px-2">Понятная метка (обязательна)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Опциональный идентификатор карты. Кнопка <strong>С карты</strong> подставляет EF.ICCID подключённой карты (активна, только когда ICCID читается); при сохранении ICCID, уже присутствующий в списке, отклоняется (сравнение без пробелов и с учётом сырой hex-формы, конфликтующий пресет называется)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">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">PIN1 / PUK1 / PIN2 / PUK2 / ADM</td><td class="py-1 px-2">Необязательные коды карты, сохраняются для будущего использования (пока ничем не используются). PIN/PUK — строго 4&ndash;8 десятичных цифр (иначе сохранение отклоняется); ADM — администраторский ключ (hex или до 8 ASCII-цифр), используется файловым менеджером. Представлениями SCP80/SCP81 не используются</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Индикаторы ключа и алгоритма (например, 15 = индекс 1, 3DES-CBC2; x2 = AES); задаются вместе с ключами</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Индикаторы ключа и алгоритма (например, 15 = индекс 1, 3DES-CBC2; x2 = AES); задаются вместе с ключами</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TARs / MSL</td><td class="py-1 px-2">Таблица TAR: три обязательные роли (ISD / UICC RFM / ADF RFM &mdash; предзаполнены) и дополнительные TAR, у каждого свой MSL (минимальный SPI1, TS 102 226 &sect;8.2.1.3.2.4). SPI1 пакета берётся из MSL его TAR; у свободных записей есть необязательное описание</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR / MSL / Description</td><td class="py-1 px-2">Таблица TAR: одна строка на TAR с его MSL (минимальный SPI1, TS 102 226 &sect;8.2.1.3.2.4). Первые три строки — фиксированные роли (предзаполнены, не удаляются) с фиксированными описаниями; дополнительные TAR добавляются кнопкой <strong>Добавить TAR</strong>, несут необязательное описание и могут быть удалены. SPI1 пакета берётся из MSL его TAR</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ISD TAR</td><td class="py-1 px-2">TAR Issuer Security Domain (TS 101 220, приложение D); используется для операций RAM/GP (представление SCP80 RAM, цепочки RAM/GP); по умолчанию по спецификации <code class="font-mono text-sm">000000</code>, подставляется при пустом значении</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ISD compact format</td><td class="py-1 px-2">TAR Issuer Security Domain (TS 101 220, приложение D); используется для операций RAM/GP (представление SCP80 RAM, цепочки RAM/GP); по умолчанию по спецификации <code class="font-mono text-sm">000000</code>, подставляется при пустом значении</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">UICC RFM TAR</td><td class="py-1 px-2">TAR UICC Shared File System RFM (TS 102 226 &sect;7.2, TS 101 220 приложение D): MF выбирается неявно и доступны только файлы под MF (ADF недоступны); используется представлением SIM RFM; по умолчанию <code class="font-mono text-sm">B00000</code></td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">UICC shared FS RFM compact format</td><td class="py-1 px-2">TAR UICC Shared File System RFM (TS 102 226 &sect;7.2, TS 101 220 приложение D): MF выбирается неявно и доступны только файлы под MF (ADF недоступны); используется представлением SIM RFM; по умолчанию <code class="font-mono text-sm">B00000</code></td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADF RFM TAR</td><td class="py-1 px-2">TAR ADF RFM (TS 102 226 &sect;7.3, TS 101 220 приложение D): ADF выбирается неявно, TAR привязан к AID приложения &mdash; ADF.USIM в представлении USIM RFM; по умолчанию <code class="font-mono text-sm">B00001</code></td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADF FS RFM compact format</td><td class="py-1 px-2">TAR ADF RFM (TS 102 226 &sect;7.3, TS 101 220 приложение D): ADF выбирается неявно, TAR привязан к AID приложения &mdash; ADF.USIM в представлении USIM RFM; по умолчанию <code class="font-mono text-sm">B00001</code></td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter</td><td class="py-1 px-2">Счётчик повторов (5 байт); свой у каждого keyset, сервер увеличивает и сохраняет его после каждой операции, которая его расходует</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter</td><td class="py-1 px-2">Счётчик повторов (5 байт); свой у каждого keyset, сервер увеличивает и сохраняет его после каждой операции, которая его расходует</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex-символа (ключи 8/16/24 байта 3DES) или 32/48/64 hex-символа (ключи 16/24/32 байта AES)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex-символа (ключи 8/16/24 байта 3DES) или 32/48/64 hex-символа (ключи 16/24/32 байта AES)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">PSK identity</td><td class="py-1 px-2">SCP81 HTTP OTA: идентификатор, который карта присылает в TLS-рукопожатии (например, <code class="font-mono text-sm">89390100000129506903</code>)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">PSK identity</td><td class="py-1 px-2">SCP81 HTTP OTA: идентификатор, который карта присылает в TLS-рукопожатии (например, <code class="font-mono text-sm">89390100000129506903</code>)</td></tr>
<tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex-символа (16 байт) для PSK TLS-слушателя; ключ выбирается по идентификатору, который предъявляет карта</td></tr> <tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex-символа (16 байт) для PSK TLS-слушателя; ключ выбирается по идентификатору, который предъявляет карта</td></tr>
</tbody> </tbody>
</table> </table>
<p class="text-sm mb-3">Столбец <strong>SCP81</strong> показывает, задана ли в пресете рабочая пара PSK: <strong>&#10003;</strong> (зелёный) — идентификатор и ключ заданы; <strong>&#9888;</strong> (жёлтый) — задано только одно из двух, и слушатель такой пресет игнорирует; <strong>&mdash;</strong> — PSK нет. Идентификатор и ключ PSK задаются вместе. Кнопка <strong>Редактировать</strong> загружает пресет в форму (кнопка Add становится <strong>Сохранить</strong>; <strong>Отмена</strong> очищает форму), поэтому поля можно менять без повторного ввода карты; <strong>Удалить</strong> удаляет строку. Обмен пресетами: <strong>Экспорт в файл</strong> для выгрузки и <strong>Импорт из файла</strong> для загрузки. Выбранный пресет автоматически заполняет форму Secured Packet и устанавливает TAR для операции (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; ADF RFM TAR); TAR, введённый в форму Secured Packet вручную, сохраняется при отправке - счётчик, ключи и MSL этого TAR по-прежнему берутся из пресета; SPI1 берётся из MSL, а меньшее значение даёт предупреждение (сервер тоже возвращает его как <code class="font-mono text-sm">warning</code>); изменения сразу передаются работающему слушателю SCP81.</p> <p class="text-sm mb-3">Столбец <strong>SCP81</strong> показывает, задана ли в пресете рабочая пара PSK: <strong>&#10003;</strong> (зелёный) — идентификатор и ключ заданы; <strong>&#9888;</strong> (жёлтый) — задано только одно из двух, и слушатель такой пресет игнорирует; <strong>&mdash;</strong> — PSK нет. Идентификатор и ключ PSK задаются вместе. Кнопка <strong>Добавить пресет</strong> открывает редактор с новым пресетом; <strong>Редактировать</strong> загружает пресет строки в редактор, поэтому поля можно менять без повторного ввода карты; <strong>Сохранить</strong> сохраняет и возвращает к списку, <strong>Отмена</strong> отменяет правки; <strong>Удалить</strong> удаляет строку (только в списке). Обмен пресетами: <strong>Экспорт в файл</strong> для выгрузки и <strong>Импорт из файла</strong> для загрузки. Выбранный пресет автоматически заполняет форму Secured Packet и устанавливает TAR для операции (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; ADF RFM TAR); TAR, введённый в форму Secured Packet вручную, сохраняется при отправке - счётчик, ключи и MSL этого TAR по-прежнему берутся из пресета; SPI1 берётся из MSL, а меньшее значение даёт предупреждение (сервер тоже возвращает его как <code class="font-mono text-sm">warning</code>); изменения сразу передаются работающему слушателю SCP81.</p>
</section> </section>
<section class="mb-10"> <section class="mb-10">
+7 -7
View File
@@ -345,25 +345,25 @@
<p class="mb-2">Each packet the card accepts <strong>consumes a counter</strong>, which is saved into the preset's keyset &mdash; the probe is refused when the resolved SPI1 has no counter check or the named preset does not define the keyset number. Observed on a live card: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> and <code class="font-mono text-sm">B00001</code> registered (compact-format applications), the expanded-format TARs, USAT, multiplexing and CASD refused with <code class="font-mono text-sm">6200</code>.</p> <p class="mb-2">Each packet the card accepts <strong>consumes a counter</strong>, which is saved into the preset's keyset &mdash; the probe is refused when the resolved SPI1 has no counter check or the named preset does not define the keyset number. Observed on a live card: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> and <code class="font-mono text-sm">B00001</code> registered (compact-format applications), the expanded-format TARs, USAT, multiplexing and CASD refused with <code class="font-mono text-sm">6200</code>.</p>
<h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Cards tab</h2> <h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Cards tab</h2>
<p class="mb-2">Stores card presets on the server (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; <code class="font-mono text-sm">--card-presets</code> overrides it) so the Secured Packet view can auto-fill keys and parameters, the SCP81 HTTP OTA listener can look up PSK keys, and the SCP80 counters an operation consumed are persisted with the operation itself &mdash; a closed tab or a second browser window can no longer lose an increment. A preset carries <strong>several keysets</strong> &mdash; a keyset's number is the <code class="font-mono text-sm">b8..b5</code> nibble of its KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc and KID of a keyset must carry the same number (01&ndash;0F; A.2 rejects a mismatch), and each keyset has its own counter on the card (Annex A.1), so counters are tracked per keyset. In the form the keysets are edited as rows &mdash; one per number, with <strong>Add keyset</strong> / <strong>Remove keyset</strong>; each row shows the number derived from its KIc/KID byte, and <code class="font-mono text-sm">00</code> (no security) is not accepted as a keyset. The list is loaded when the PWA connects and can be exported/imported as JSON (the import also takes presets exported by older builds, flat or keyset-shaped). Cards is a top-level tab. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in all three SCP80 views; when the equipped card matches <strong>no</strong> preset &mdash; or the selected preset belongs to another card &mdash; all three SCP80 views show an amber warning above the preset selector. The list highlights the equipped card's preset. The form groups the fields into two bordered blocks: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (the keysets, the TAR table &mdash; the three mandatory role entries ISD / UICC RFM / ADF RFM, each with its MSL, plus further TARs with an optional description &mdash; and the counters) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The optional <strong>ADM</strong> field sits in the top row and is stored for later use by the file manager. The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p> <p class="mb-2">Stores card presets on the server (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; <code class="font-mono text-sm">--card-presets</code> overrides it) so the Secured Packet view can auto-fill keys and parameters, the SCP81 HTTP OTA listener can look up PSK keys, and the SCP80 counters an operation consumed are persisted with the operation itself &mdash; a closed tab or a second browser window can no longer lose an increment. A preset carries <strong>several keysets</strong> &mdash; a keyset's number is the <code class="font-mono text-sm">b8..b5</code> nibble of its KIc/KID (TS 102 225 &sect;5.1.2/A.2): KIc and KID of a keyset must carry the same number (01&ndash;0F; A.2 rejects a mismatch), and each keyset has its own counter on the card (Annex A.1), so counters are tracked per keyset. In the form the keysets are edited as rows &mdash; one per number, with <strong>Add keyset</strong> / <strong>Remove keyset</strong>; each row shows the number derived from its KIc/KID byte, and <code class="font-mono text-sm">00</code> (no security) is not accepted as a keyset. The list is loaded when the PWA connects and can be exported/imported as JSON (the import also takes presets exported by older builds, flat or keyset-shaped). Cards is a top-level tab: it shows the <strong>preset list</strong> by default, and <strong>Add preset</strong> / <strong>Edit</strong> open the <strong>editor</strong> (a separate view) &mdash; <strong>Save</strong> stores the preset and returns to the list, <strong>Cancel</strong> discards the edits. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in all three SCP80 views; when the equipped card matches <strong>no</strong> preset &mdash; or the selected preset belongs to another card &mdash; all three SCP80 views show an amber warning above the preset selector. The list highlights the equipped card's preset. The editor groups the fields into bordered blocks: <strong>PIN/ADM</strong> (the optional card codes &mdash; PIN1/PUK1/PIN2/PUK2 as 4&ndash;8 decimal digits and the ADM key &mdash; stored for later use), <strong>TAR</strong> (the TAR table), <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (the keysets and their counters) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p>
<table class="w-full text-sm mb-3 border-collapse"> <table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Description</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Description</th></tr></thead>
<tbody> <tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Name</td><td class="py-1 px-2">Human-readable label (required)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Name</td><td class="py-1 px-2">Human-readable label (required)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Optional card identifier. <strong>From card</strong> fills it with the equipped card's EF.ICCID (enabled only while a readable ICCID is known); saving refuses an ICCID that already exists in the list (compared ignoring spaces and the raw-hex form, and the conflicting preset is named)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">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">PIN1 / PUK1 / PIN2 / PUK2 / ADM</td><td class="py-1 px-2">Optional card codes, all stored for later use (nothing consumes them yet). PIN/PUK must be 4&ndash;8 decimal digits (saving refuses anything else); ADM is the administrator key (hex, or up to 8 ASCII digits), used by the file manager. None is used by the SCP80/SCP81 views</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Key and algorithm indicators (e.g. 15 = index 1, 3DES-CBC2; x2 = AES), required together with the keys</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Key and algorithm indicators (e.g. 15 = index 1, 3DES-CBC2; x2 = AES), required together with the keys</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TARs / MSL</td><td class="py-1 px-2">The TAR table: the three role entries (ISD / UICC RFM / ADF RFM &mdash; mandatory, pre-settled) plus further TARs, each with its MSL (Minimum SPI1, TS 102 226 &sect;8.2.1.3.2.4). A packet's SPI1 is derived from its TAR's MSL; free entries carry an optional description</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR / MSL / Description</td><td class="py-1 px-2">The TAR table: one uniform row per TAR with its MSL (Minimum SPI1, TS 102 226 &sect;8.2.1.3.2.4). The first three rows are the fixed role entries (pre-settled, not removable) with their fixed descriptions; further TARs are added with <strong>Add TAR</strong>, carry an optional description and can be removed. A packet's SPI1 is derived from its TAR's MSL</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ISD TAR</td><td class="py-1 px-2">Issuer Security Domain TAR (TS 101 220 Annex D); used for RAM/GP operations (SCP80 RAM view, RAM/GP chains); spec default <code class="font-mono text-sm">000000</code>, prefilled when empty</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ISD compact format</td><td class="py-1 px-2">Issuer Security Domain TAR (TS 101 220 Annex D); used for RAM/GP operations (SCP80 RAM view, RAM/GP chains); spec default <code class="font-mono text-sm">000000</code>, prefilled when empty</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">UICC RFM TAR</td><td class="py-1 px-2">UICC Shared File System RFM TAR (TS 102 226 &sect;7.2, TS 101 220 Annex D): the MF is implicitly selected and only files under the MF are accessible (no ADFs); used by the SIM RFM view; spec default <code class="font-mono text-sm">B00000</code></td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">UICC shared FS RFM compact format</td><td class="py-1 px-2">UICC Shared File System RFM TAR (TS 102 226 &sect;7.2, TS 101 220 Annex D): the MF is implicitly selected and only files under the MF are accessible (no ADFs); used by the SIM RFM view; spec default <code class="font-mono text-sm">B00000</code></td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADF RFM TAR</td><td class="py-1 px-2">ADF RFM TAR (TS 102 226 &sect;7.3, TS 101 220 Annex D): the ADF is implicitly selected and the TAR is linked to the application's AID &mdash; ADF.USIM in the USIM RFM view; spec default <code class="font-mono text-sm">B00001</code></td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADF FS RFM compact format</td><td class="py-1 px-2">ADF RFM TAR (TS 102 226 &sect;7.3, TS 101 220 Annex D): the ADF is implicitly selected and the TAR is linked to the application's AID &mdash; ADF.USIM in the USIM RFM view; spec default <code class="font-mono text-sm">B00001</code></td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter</td><td class="py-1 px-2">Replay counter (5 bytes); one per keyset, advanced and saved by the server after each operation that consumes it</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter</td><td class="py-1 px-2">Replay counter (5 bytes); one per keyset, advanced and saved by the server after each operation that consumes it</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex chars (8/16/24-byte 3DES) or 32/48/64 hex chars (16/24/32-byte AES) keys</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex chars (8/16/24-byte 3DES) or 32/48/64 hex chars (16/24/32-byte AES) keys</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">PSK identity</td><td class="py-1 px-2">SCP81 HTTP OTA: the identity the card sends in the TLS handshake (e.g. <code class="font-mono text-sm">89390100000129506903</code>)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">PSK identity</td><td class="py-1 px-2">SCP81 HTTP OTA: the identity the card sends in the TLS handshake (e.g. <code class="font-mono text-sm">89390100000129506903</code>)</td></tr>
<tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex chars (16 bytes) for the PSK TLS listener, picked by the identity the card presents</td></tr> <tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex chars (16 bytes) for the PSK TLS listener, picked by the identity the card presents</td></tr>
</tbody> </tbody>
</table> </table>
<p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair: <strong>&#10003;</strong> (green) — identity and key set; <strong>&#9888;</strong> (amber) — only one of the two, so the listener ignores the preset; <strong>&mdash;</strong> — no PSK. PSK identity and key must be set together. <strong>Edit</strong> loads a preset into the form (the Add button becomes <strong>Save</strong>; <strong>Cancel</strong> clears it) so fields can be changed without re-entering the card; <strong>Remove</strong> deletes the row. Presets can be shared with <strong>Export to file</strong> and restored with <strong>Import from file</strong>. The selected card preset auto-fills the Secured Packet form and sets the TAR for the operation (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; ADF RFM TAR); a TAR typed into the Secured Packet form by hand is kept when sending - the counter, keys and 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> <p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair: <strong>&#10003;</strong> (green) — identity and key set; <strong>&#9888;</strong> (amber) — only one of the two, so the listener ignores the preset; <strong>&mdash;</strong> — no PSK. PSK identity and key must be set together. <strong>Add preset</strong> opens the editor with a fresh preset; <strong>Edit</strong> loads the row's preset into it so fields can be changed without re-entering the card; <strong>Save</strong> stores it and returns to the list, <strong>Cancel</strong> discards the edits; <strong>Remove</strong> deletes the row (list only). Presets can be shared with <strong>Export to file</strong> and restored with <strong>Import from file</strong>. The selected card preset auto-fills the Secured Packet form and sets the TAR for the operation (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; ADF RFM TAR); a TAR typed into the Secured Packet form by hand is kept when sending - the counter, keys and the TAR's MSL are still re-read from the preset; the SPI1 is seeded from the TAR's MSL and a lower value warns (the server returns it as a <code class="font-mono text-sm">warning</code> too); edits are pushed into a running SCP81 listener automatically.</p>
</section> </section>
<section class="mb-10"> <section class="mb-10">
+194 -92
View File
@@ -1593,49 +1593,95 @@
<div id="tab-cards" class="tab-content hidden"> <div id="tab-cards" class="tab-content hidden">
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries several keysets, each with its own number and counter, 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> <!-- ===== preset list (default view) ===== -->
<div id="cards-list-view">
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored on the server. A preset carries the card codes (PIN/PUK/ADM), a TAR table (every TAR with its MSL) and several keysets, each with its own number and counter.">Card presets are stored on the server. A preset carries the card codes (PIN/PUK/ADM), a TAR table (every TAR with its MSL) and several keysets, each with its own number and counter.</p>
<div class="flex flex-wrap gap-2 mb-3">
<button id="cards-new-btn" onclick="cardsNew()" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add preset">Add preset</button>
<button onclick="cardsExportFile()" class="px-3 py-2 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Export to file">Export to file</button>
<button onclick="document.getElementById('cards-file').click()" class="px-3 py-2 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Import from file">Import from file</button>
</div>
<div id="cards-list" class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr>
<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" 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>
<tbody id="cards-tbody"></tbody>
</table>
</div>
<input type="file" id="cards-file" accept=".json,application/json" class="hidden" onchange="cardsImportFile(this)">
<div id="cards-io" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400"></div>
</div>
<!-- ===== preset editor ===== -->
<div id="cards-editor-view" class="hidden">
<div class="flex items-center justify-between mb-3">
<h3 id="cards-editor-title" class="text-lg font-semibold text-gray-800 dark:text-slate-200"></h3>
<button onclick="cardsCancelEdit()" class="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
</div>
<div class="flex gap-2 mb-3"> <div class="flex gap-2 mb-3">
<input id="cards-name" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Name (e.g. Foobar SIM)"> <div class="flex-1">
<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)"> <label for="cards-name" class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Name">Name</label>
<button id="cards-iccid-from-card" data-needs="card-iccid" onclick="cardsIccidFromCard()" class="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="From card">From card</button> <input id="cards-name" class="w-full 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-adm" class="w-40 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ADM (optional)"> </div>
<button id="cards-add-btn" onclick="cardsAdd()" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add">Add</button> <div class="flex-1">
<button id="cards-cancel-btn" onclick="cardsCancelEdit()" class="hidden px-4 py-2.5 bg-gray-500 text-white text-sm font-medium rounded hover:bg-gray-600 whitespace-nowrap" data-l10n="Cancel">Cancel</button> <label for="cards-iccid" class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="ICCID">ICCID</label>
<div class="flex gap-1">
<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)">
<button id="cards-iccid-from-card" data-needs="card-iccid" onclick="cardsIccidFromCard()" class="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="From card">From card</button>
</div>
</div>
</div> </div>
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3"> <fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="SCP80 (GSM 03.48, ETSI TS 102 225)">SCP80 (GSM 03.48, ETSI TS 102 225)</legend> <legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="PIN/ADM">PIN/ADM</legend>
<div class="flex gap-3 mb-2 flex-wrap items-end"> <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2 mb-2">
<div> <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> <label for="cards-pin1" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">PIN1</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"> <input id="cards-pin1" class="w-full 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="8" inputmode="numeric" placeholder="4-8 digits">
</div> </div>
<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> <label for="cards-puk1" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">PUK1</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)"> <input id="cards-puk1" class="w-full 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="8" inputmode="numeric" placeholder="4-8 digits">
</div> </div>
<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> <label for="cards-pin2" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">PIN2</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"> <input id="cards-pin2" class="w-full 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="8" inputmode="numeric" placeholder="4-8 digits">
</div> </div>
<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> <label for="cards-puk2" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">PUK2</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)"> <input id="cards-puk2" class="w-full 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="8" inputmode="numeric" placeholder="4-8 digits">
</div> </div>
<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> <label for="cards-adm" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">ADM</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"> <input id="cards-adm" class="w-full 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="8" placeholder="ADM (optional)">
</div>
<div>
<label for="cards-usim-tar-msl" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400" data-l10n="MSL">MSL</label>
<input id="cards-usim-tar-msl" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" maxlength="2" value="16" data-l10n-title="MSL (Minimum SPI1, one hex byte)">
</div> </div>
</div> </div>
<p class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="Each TAR carries its MSL (Minimum SPI1) — the card checks it before the security processing (TS 102 226 8.2.1.3.2.4); the packet SPI1 is derived from it.">Each TAR carries its MSL (Minimum SPI1) — the card checks it before the security processing (TS 102 226 8.2.1.3.2.4); the packet SPI1 is derived from it.</p> <p class="text-xs text-gray-500 dark:text-slate-400" data-l10n="All codes are optional: PIN/PUK are 4-8 decimal digits (stored for later use by the card operations — not consumed yet); ADM is the administrator key.">All codes are optional: PIN/PUK are 4-8 decimal digits (stored for later use by the card operations — not consumed yet); ADM is the administrator key.</p>
<div class="mb-1 flex items-center justify-between"> </fieldset>
<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> <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="TAR">TAR</legend>
<table class="w-full text-sm mb-2">
<thead><tr>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="TAR">TAR</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="MSL">MSL</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Description">Description</th>
<th class="w-8 border-b border-gray-200 dark:border-slate-700"></th>
</tr></thead>
<tbody id="cards-tars"></tbody>
</table>
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-600 dark:text-slate-400" data-l10n="The first three rows are the fixed role TARs; add further TARs as needed.">The first three rows are the fixed role TARs; add further TARs as needed.</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> <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>
<div id="cards-tars"></div> <p class="text-xs text-gray-500 dark:text-slate-400" 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>
</fieldset>
<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="mb-1 flex items-center justify-between"> <div class="mb-1 flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-slate-400" data-l10n="OTA keysets — each with its own number and counter">OTA keysets — each with its own number and counter</span> <span class="text-xs text-gray-600 dark:text-slate-400" data-l10n="OTA keysets — each with its own number and counter">OTA keysets — each with its own number and counter</span>
<button type="button" onclick="cardsKeysetAdd()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" data-l10n="Add keyset">Add keyset</button> <button type="button" onclick="cardsKeysetAdd()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" data-l10n="Add keyset">Add keyset</button>
@@ -1651,25 +1697,11 @@
</div> </div>
<p class="text-xs text-gray-500 dark:text-slate-400" data-l10n="SCP81 HTTP OTA: the listener picks the key by the PSK identity the card sends in the TLS handshake. Cards without a PSK are ignored by the listener.">SCP81 HTTP OTA: the listener picks the key by the PSK identity the card sends in the TLS handshake. Cards without a PSK are ignored by the listener.</p> <p class="text-xs text-gray-500 dark:text-slate-400" data-l10n="SCP81 HTTP OTA: the listener picks the key by the PSK identity the card sends in the TLS handshake. Cards without a PSK are ignored by the listener.">SCP81 HTTP OTA: the listener picks the key by the PSK identity the card sends in the TLS handshake. Cards without a PSK are ignored by the listener.</p>
</fieldset> </fieldset>
<div id="cards-list" class="overflow-x-auto"> <div class="flex gap-2">
<table class="w-full text-sm"> <button id="cards-add-btn" onclick="cardsAdd()" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Save">Save</button>
<thead><tr> <button onclick="cardsCancelEdit()" class="px-4 py-2.5 bg-gray-500 text-white text-sm font-medium rounded hover:bg-gray-600" data-l10n="Cancel">Cancel</button>
<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" 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>
<tbody id="cards-tbody"></tbody>
</table>
</div> </div>
<input type="file" id="cards-file" accept=".json,application/json" class="hidden" onchange="cardsImportFile(this)">
<div class="flex flex-wrap gap-2 mt-2">
<button onclick="cardsExportFile()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export to file">Export to file</button>
<button onclick="document.getElementById('cards-file').click()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import from file">Import from file</button>
</div> </div>
<div id="cards-io" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400"></div>
</div> </div>
</div> </div>
@@ -1816,7 +1848,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.10.0'; const SIMPLE_VERSION = '3.11.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -11855,46 +11887,72 @@ function cardsKeysetsFromForm() {
return out; return out;
} }
// ===== TAR table editor (Cards form) ===== // ===== TAR table editor (Cards editor) =====
// The three role entries have fixed inputs (their labels are the immutable // One uniform table: the first three rows are the fixed role entries - their
// descriptions); further TARs are free rows with an optional description. // descriptions are the immutable role labels (English fixed text) and they
// Every entry carries the TAR and its MSL (Minimum SPI1, one hex byte). // carry no remove button; further rows are free entries with an optional
// description. Every row carries the TAR and its MSL (Minimum SPI1, one hex
// byte).
let _cardsTarCount = 0; let _cardsTarCount = 0;
// The fixed role descriptions (spec role names; not translated).
const CARDS_TAR_ROLE_DESC = {
isd: 'ISD compact format',
uiccRfm: 'UICC shared FS RFM compact format',
usimRfm: 'ADF FS RFM compact format',
};
function cardsTarRowHtml(e, i) { 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'; 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">' + const role = CARDS_TAR_ROLES[i] || '';
'<input id="cards-ft-' + i + '-tar" class="w-20 ' + cls + ' text-center" maxlength="6" placeholder="TAR" value="' + esc(e.tar || '') + '">' + let cells = '<td class="py-1 px-1"><input id="cards-tr-' + i + '-tar" class="w-24 ' + cls + ' text-center" maxlength="6" placeholder="TAR" value="' + esc(e.tar || '') + '"></td>' +
'<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)')) + '">' + '<td class="py-1 px-1"><input id="cards-tr-' + i + '-msl" class="w-14 ' + cls + ' text-center" maxlength="2" placeholder="MSL" value="' + esc(e.msl || '') + '" title="' + esc(t('MSL (Minimum SPI1, one hex byte)')) + '"></td>';
'<input id="cards-ft-' + i + '-desc" class="flex-1 ' + cls + '" placeholder="' + esc(t('Description (optional)')) + '" value="' + esc(e.desc || '') + '">' + if (role) {
'<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>' + cells += '<td class="py-1 px-2 text-xs text-gray-600 dark:text-slate-400">' + esc(CARDS_TAR_ROLE_DESC[role] || '') + '</td>' +
'</div>'; '<td class="py-1 px-1"></td>';
} else {
cells += '<td class="py-1 px-1"><input id="cards-tr-' + i + '-desc" class="w-full ' + cls + '" placeholder="' + esc(t('Description (optional)')) + '" value="' + esc(e.desc || '') + '"></td>' +
'<td class="py-1 px-1"><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></td>';
}
return '<tr>' + cells + '</tr>';
} }
// Render the whole table from a preset's TAR list (roles first, then free
// rows); the role rows are always shown, with the pre-settled defaults when
// the list carries none.
function cardsTarRowsRender(entries) { function cardsTarRowsRender(entries) {
const box = document.getElementById('cards-tars'); const box = document.getElementById('cards-tars');
if (!box) return; if (!box) return;
const list = entries || []; const list = entries || [];
_cardsTarCount = list.length; const roles = CARDS_TAR_ROLES.map(role => {
box.innerHTML = list.map((e, i) => cardsTarRowHtml(e, i)).join(''); const e = list.find(x => x.role === role) || {};
return { tar: e.tar || cardsRoleDefault(role), msl: e.msl || CARDS_MSL_DEFAULT };
});
const free = list.filter(e => !e.role);
_cardsTarCount = free.length;
box.innerHTML = roles.map((e, i) => cardsTarRowHtml(e, i)).join('') +
free.map((e, i) => cardsTarRowHtml(e, CARDS_TAR_ROLES.length + i)).join('');
} }
function cardsTarAdd() { function cardsTarAdd() {
cardsTarRowsRender(cardsFreeTarsFromForm().concat([{ tar: '', msl: '', desc: '' }])); cardsTarRowsRender(cardsTarsFromForm().concat([{ tar: '', msl: '', desc: '' }]));
} }
// `i` is the table row index; the role rows are fixed and never removed.
function cardsTarRemove(i) { function cardsTarRemove(i) {
const rows = cardsFreeTarsFromForm(); if (i < CARDS_TAR_ROLES.length) return;
const rows = cardsTarsFromForm();
rows.splice(i, 1); rows.splice(i, 1);
cardsTarRowsRender(rows); cardsTarRowsRender(rows);
} }
// The free editor rows (fully empty rows are dropped). // The free table rows (fully empty rows are dropped).
function cardsFreeTarsFromForm() { function cardsFreeTarsFromForm() {
const out = []; const out = [];
for (let i = 0; i < _cardsTarCount; i++) { for (let i = 0; i < _cardsTarCount; i++) {
const idx = CARDS_TAR_ROLES.length + i;
const get = id => { const get = id => {
const el = document.getElementById('cards-ft-' + i + '-' + id); const el = document.getElementById('cards-tr-' + idx + '-' + id);
return el ? String(el.value).trim() : ''; return el ? String(el.value).trim() : '';
}; };
const e = { tar: get('tar').replace(/\s/g, '').toUpperCase(), const e = { tar: get('tar').replace(/\s/g, '').toUpperCase(),
@@ -11905,32 +11963,25 @@ function cardsFreeTarsFromForm() {
return out; return out;
} }
// The TAR table as preset entries: the three role entries from their fixed // The TAR table as preset entries: the three role entries from the first three
// inputs (the labels are the role descriptions) plus the free rows. // rows (an emptied field keeps the pre-settled default) plus the free rows.
function cardsTarsFromForm() { function cardsTarsFromForm() {
const roleInputs = { isd: 'cards-tar', uiccRfm: 'cards-uicc-tar', usimRfm: 'cards-usim-tar' }; const out = CARDS_TAR_ROLES.map((role, i) => {
const out = CARDS_TAR_ROLES.map(role => { const get = id => {
const tarEl = document.getElementById(roleInputs[role]); const el = document.getElementById('cards-tr-' + i + '-' + id);
const mslEl = document.getElementById(roleInputs[role] + '-msl'); return el ? String(el.value).trim() : '';
const tar = (tarEl ? String(tarEl.value).trim() : '').replace(/\s/g, '').toUpperCase(); };
const msl = (mslEl ? String(mslEl.value).trim() : '').replace(/\s/g, '').toUpperCase(); const tar = get('tar').replace(/\s/g, '').toUpperCase();
// An emptied role field keeps the pre-settled default: the three role const msl = get('msl').replace(/\s/g, '').toUpperCase();
// entries are mandatory with a non-empty TAR and MSL.
return { role: role, tar: tar || cardsRoleDefault(role), return { role: role, tar: tar || cardsRoleDefault(role),
msl: msl || CARDS_MSL_DEFAULT, desc: '' }; msl: msl || CARDS_MSL_DEFAULT, desc: '' };
}); });
return out.concat(cardsFreeTarsFromForm()); return out.concat(cardsFreeTarsFromForm());
} }
// The role inputs back to their pre-settled defaults (TAR + MSL 16). // The role rows back to their pre-settled defaults (TAR + MSL 16) and no free
// rows.
function cardsTarsReset() { 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([]); cardsTarRowsRender([]);
} }
@@ -12091,6 +12142,10 @@ function cardsFormValues() {
return { return {
name: val('name'), name: val('name'),
iccid: val('iccid'), iccid: val('iccid'),
pin1: val('pin1').replace(/\s/g, ''),
puk1: val('puk1').replace(/\s/g, ''),
pin2: val('pin2').replace(/\s/g, ''),
puk2: val('puk2').replace(/\s/g, ''),
adm: val('adm').replace(/\s/g, '').toUpperCase(), adm: val('adm').replace(/\s/g, '').toUpperCase(),
tars: cardsTarsFromForm(), tars: cardsTarsFromForm(),
pskIdentity: val('psk-id'), pskIdentity: val('psk-id'),
@@ -12099,19 +12154,49 @@ function cardsFormValues() {
}; };
} }
// Show the preset list (the Cards tab's default view).
function cardsShowList() {
const list = document.getElementById('cards-list-view');
const editor = document.getElementById('cards-editor-view');
if (list) list.classList.remove('hidden');
if (editor) editor.classList.add('hidden');
}
// Show the preset editor.
function cardsShowEditor() {
const list = document.getElementById('cards-list-view');
const editor = document.getElementById('cards-editor-view');
if (list) list.classList.add('hidden');
if (editor) editor.classList.remove('hidden');
}
function cardsSetFormMode() { function cardsSetFormMode() {
const title = document.getElementById('cards-editor-title');
if (title) {
title.textContent = _cardsEditIdx === null
? t('Add preset')
: t('Edit preset') + ': ' + ((cards[_cardsEditIdx] || {}).name || '');
}
const btn = document.getElementById('cards-add-btn'); const btn = document.getElementById('cards-add-btn');
if (btn) btn.textContent = _cardsEditIdx === null ? t('Add') : t('Save'); if (btn) btn.textContent = _cardsEditIdx === null ? t('Add') : t('Save');
const cancel = document.getElementById('cards-cancel-btn'); }
if (cancel) cancel.classList.toggle('hidden', _cardsEditIdx === null);
// "Add preset" in the list view: reset the form and open the editor.
function cardsNew() {
_cardsEditIdx = null;
cardsClearForm();
cardsSetFormMode();
cardsShowEditor();
const el = document.getElementById('cards-name');
if (el) el.focus();
} }
function cardsClearForm() { function cardsClearForm() {
['name','iccid','adm','psk-id','psk-key'].forEach(id => { ['name','iccid','pin1','puk1','pin2','puk2','adm','psk-id','psk-key'].forEach(id => {
const el = document.getElementById('cards-' + id); const el = document.getElementById('cards-' + id);
if (el) el.value = ''; if (el) el.value = '';
}); });
// The three role entries keep their pre-settled defaults (TAR + MSL 16) // The three role rows keep their pre-settled defaults (TAR + MSL 16)
cardsTarsReset(); cardsTarsReset();
cardsKeysetRowsRender([]); cardsKeysetRowsRender([]);
} }
@@ -12119,6 +12204,14 @@ function cardsClearForm() {
async function cardsAdd() { async function cardsAdd() {
const v = cardsFormValues(); const v = cardsFormValues();
if (!v.name) { alert(t('Name is required')); return; } if (!v.name) { alert(t('Name is required')); return; }
// The optional card codes: PIN/PUK are 4-8 decimal digits; ADM stays
// free-form (hex or ASCII digits)
for (const k of ['pin1', 'puk1', 'pin2', 'puk2']) {
if (v[k] && !/^[0-9]{4,8}$/.test(v[k])) {
alert(t('PIN/PUK codes must be 4-8 decimal digits') + ': ' + k.toUpperCase());
return;
}
}
if (!v.keysets.length) { if (!v.keysets.length) {
alert(t('Define at least one keyset (KIc/KID and their keys)')); alert(t('Define at least one keyset (KIc/KID and their keys)'));
return; return;
@@ -12191,24 +12284,24 @@ function cardsEdit(i) {
const el = document.getElementById('cards-' + id); const el = document.getElementById('cards-' + id);
if (el) el.value = value == null ? '' : value; if (el) el.value = value == null ? '' : value;
}; };
set('name', c.name); set('iccid', c.iccid); set('adm', c.adm); set('name', c.name); set('iccid', c.iccid);
set('tar', cardsRoleTar(c, 'isd')); set('pin1', c.pin1); set('puk1', c.puk1);
set('tar-msl', cardsRoleMsl(c, 'isd')); set('pin2', c.pin2); set('puk2', c.puk2);
set('uicc-tar', cardsRoleTar(c, 'uiccRfm')); set('adm', c.adm);
set('uicc-tar-msl', cardsRoleMsl(c, 'uiccRfm'));
set('usim-tar', cardsRoleTar(c, 'usimRfm'));
set('usim-tar-msl', cardsRoleMsl(c, 'usimRfm'));
set('psk-id', c.pskIdentity); set('psk-key', c.pskKey); set('psk-id', c.pskIdentity); set('psk-key', c.pskKey);
cardsKeysetRowsRender(spKeysetList(c)); cardsKeysetRowsRender(spKeysetList(c));
cardsTarRowsRender(cardsTarList(c).filter(e => !e.role)); cardsTarRowsRender(cardsTarList(c));
cardsSetFormMode(); cardsSetFormMode();
document.getElementById('cards-name').focus(); cardsShowEditor();
const el = document.getElementById('cards-name');
if (el) el.focus();
} }
function cardsCancelEdit() { function cardsCancelEdit() {
_cardsEditIdx = null; _cardsEditIdx = null;
cardsSetFormMode(); cardsSetFormMode();
cardsClearForm(); cardsClearForm();
cardsShowList();
} }
function ramCardIdxAfterRemove(idx, removedIdx) { function ramCardIdxAfterRemove(idx, removedIdx) {
@@ -20810,6 +20903,15 @@ const LANG_RU = {
'Duplicate TAR in the preset': 'Повторяющийся TAR в пресете', 'Duplicate TAR in the preset': 'Повторяющийся TAR в пресете',
'SPI1 is below the MSL of this TAR': 'SPI1 ниже MSL этого TAR', 'SPI1 is below the MSL of this TAR': 'SPI1 ниже MSL этого TAR',
'(empty)': '(пусто)', '(empty)': '(пусто)',
'Add preset': 'Добавить пресет',
'Edit preset': 'Редактирование пресета',
'PIN/ADM': 'PIN/ADM',
'TAR': 'TAR',
'Description': 'Описание',
'Card presets are stored on the server. A preset carries the card codes (PIN/PUK/ADM), a TAR table (every TAR with its MSL) and several keysets, each with its own number and counter.': 'Пресеты карт хранятся на сервере. Пресет содержит коды карты (PIN/PUK/ADM), таблицу TAR (у каждого TAR свой MSL) и несколько keysets — у каждого свой номер и счётчик.',
'All codes are optional: PIN/PUK are 4-8 decimal digits (stored for later use by the card operations — not consumed yet); ADM is the administrator key.': 'Все коды необязательны: PIN/PUK — 4–8 десятичных цифр (сохраняются для будущего использования операциями карты — пока не используются); ADM — администраторский ключ.',
'The first three rows are the fixed role TARs; add further TARs as needed.': 'Первые три строки — фиксированные роли TAR; дополнительные TAR добавляются по необходимости.',
'PIN/PUK codes must be 4-8 decimal digits': 'Коды PIN/PUK должны состоять из 4–8 десятичных цифр',
'Keysets (number: KIc/KID — counter)': 'Keysets (номер: KIc/KID — счётчик)', 'Keysets (number: KIc/KID — counter)': 'Keysets (номер: KIc/KID — счётчик)',
'must be one hex byte': 'должен быть одним hex-байтом', 'must be one hex byte': 'должен быть одним hex-байтом',
'KIc and KID must carry the same keyset number (TS 102 225 A.2)': 'KIc и KID должны содержать один и тот же номер keyset (TS 102 225 A.2)', 'KIc and KID must carry the same keyset number (TS 102 225 A.2)': 'KIc и KID должны содержать один и тот же номер keyset (TS 102 225 A.2)',
+8
View File
@@ -2221,6 +2221,10 @@ video {
.sm\:grid-cols-2 { .sm\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.sm\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
@@ -2239,6 +2243,10 @@ video {
.lg\:grid-cols-3 { .lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.lg\:grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}/* ===== Theme contrast adjustments ===== */ }/* ===== Theme contrast adjustments ===== */
/* Light theme: darken softer (muted) text one step for higher contrast */ /* Light theme: darken softer (muted) text one step for higher contrast */
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v360'; const CACHE = 'simple-v361';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+130 -51
View File
@@ -44,11 +44,13 @@ for (const fn of ['cardsFormValues', 'cardsClearForm', 'cardsEdit',
'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar', 'cardsRoleMsl', 'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar', 'cardsRoleMsl',
'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'spMslWarningText', 'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'spMslWarningText',
'cardsTarRowHtml', 'cardsTarRowsRender', 'cardsTarAdd', 'cardsTarRemove', 'cardsTarRowHtml', 'cardsTarRowsRender', 'cardsTarAdd', 'cardsTarRemove',
'cardsFreeTarsFromForm', 'cardsTarsFromForm', 'cardsTarsReset']) { 'cardsFreeTarsFromForm', 'cardsTarsFromForm', 'cardsTarsReset',
'cardsShowList', 'cardsShowEditor', 'cardsSetFormMode', 'cardsNew',
'cardsCancelEdit']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n'; code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
} }
code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES') code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
+ extractConst(html, 'CARDS_MSL_DEFAULT') + extractConst(html, 'CARDS_MSL_DEFAULT') + extractConst(html, 'CARDS_TAR_ROLE_DESC')
+ 'var _cardsKeysetCount = 0;\nvar _cardsTarCount = 0;\n' + 'var _cardsKeysetCount = 0;\nvar _cardsTarCount = 0;\n'
+ 'globalThis.spPresetWarningRender = function() {};\n' + 'globalThis.spPresetWarningRender = function() {};\n'
+ 'globalThis.spMslWarningRender = function() {};\n' + 'globalThis.spMslWarningRender = function() {};\n'
@@ -56,9 +58,8 @@ code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_
eval(code); eval(code);
globalThis.t = s => s; globalThis.t = s => s;
const CARD_IDS = ['cards-name','cards-iccid','cards-adm', const CARD_IDS = ['cards-name','cards-iccid','cards-pin1','cards-puk1','cards-pin2','cards-puk2','cards-adm',
'cards-tar','cards-tar-msl','cards-uicc-tar','cards-uicc-tar-msl', 'cards-tars','cards-list-view','cards-editor-view','cards-editor-title','cards-new-btn',
'cards-usim-tar','cards-usim-tar-msl','cards-tars',
'cards-psk-id','cards-psk-key','cards-keysets', 'cards-psk-id','cards-psk-key','cards-keysets',
'cards-add-btn','cards-cancel-btn', 'cards-add-btn','cards-cancel-btn',
// the keyset editor rows (two rows is enough for the tests) // the keyset editor rows (two rows is enough for the tests)
@@ -66,9 +67,11 @@ const CARD_IDS = ['cards-name','cards-iccid','cards-adm',
'cards-ks-0-kid-key','cards-ks-0-cntr', 'cards-ks-0-kid-key','cards-ks-0-cntr',
'cards-ks-1-kvn','cards-ks-1-kic','cards-ks-1-kid','cards-ks-1-kic-key', 'cards-ks-1-kvn','cards-ks-1-kic','cards-ks-1-kid','cards-ks-1-kic-key',
'cards-ks-1-kid-key','cards-ks-1-cntr', 'cards-ks-1-kid-key','cards-ks-1-cntr',
// the free TAR editor rows // the TAR table rows: 0-2 are the fixed role rows, 3+ the free rows
'cards-ft-0-tar','cards-ft-0-msl','cards-ft-0-desc', 'cards-tr-0-tar','cards-tr-0-msl','cards-tr-1-tar','cards-tr-1-msl',
'cards-ft-1-tar','cards-ft-1-msl','cards-ft-1-desc']; 'cards-tr-2-tar','cards-tr-2-msl',
'cards-tr-3-tar','cards-tr-3-msl','cards-tr-3-desc',
'cards-tr-4-tar','cards-tr-4-msl','cards-tr-4-desc'];
const SP_IDS = ['sp-card-sel','sp-tar','sp-spi1','sp-spi2','sp-spi2-sm','sp-spi2-hex', const SP_IDS = ['sp-card-sel','sp-tar','sp-spi1','sp-spi2','sp-spi2-sm','sp-spi2-hex',
'sp-kic-idx','sp-kic-alg','sp-kic-hex','sp-kid-idx','sp-kid-alg','sp-kid-hex', 'sp-kic-idx','sp-kic-alg','sp-kic-hex','sp-kid-idx','sp-kid-alg','sp-kid-hex',
'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex','sp-tar-list', 'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex','sp-tar-list',
@@ -86,7 +89,7 @@ function ksRow(els, i, ks) {
function tarRow(els, i, e) { function tarRow(els, i, e) {
for (const field of ['tar', 'msl', 'desc']) { for (const field of ['tar', 'msl', 'desc']) {
const id = 'cards-ft-' + i + '-' + field; const id = 'cards-tr-' + i + '-' + field;
if (!els[id]) els[id] = fakeEl(); if (!els[id]) els[id] = fakeEl();
els[id].value = e[field] || ''; els[id].value = e[field] || '';
} }
@@ -103,12 +106,22 @@ function presetFixture(extra) {
} }
function fakeEl() { function fakeEl() {
const classes = new Set();
return { return {
value: '', value: '',
textContent: '', textContent: '',
innerHTML: '', innerHTML: '',
focus() {}, focus() {},
classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, classList: {
add: (...cs) => cs.forEach(c => classes.add(c)),
remove: (...cs) => cs.forEach(c => classes.delete(c)),
toggle: (c, on) => {
const want = on === undefined ? !classes.has(c) : !!on;
if (want) classes.add(c); else classes.delete(c);
return want;
},
contains: c => classes.has(c),
},
setAttribute() {}, removeAttribute() {}, setAttribute() {}, removeAttribute() {},
}; };
} }
@@ -133,8 +146,6 @@ function setup() {
globalThis.cardsRender = () => {}; globalThis.cardsRender = () => {};
globalThis.cardsRebuildSelect = () => {}; globalThis.cardsRebuildSelect = () => {};
globalThis.scp81PushPskMap = () => {}; globalThis.scp81PushPskMap = () => {};
globalThis.cardsSetFormMode = () => {};
globalThis.cardsCancelEdit = () => {};
globalThis.cardsFindDuplicateIccid = () => -1; globalThis.cardsFindDuplicateIccid = () => -1;
globalThis.ioStatus = () => {}; globalThis.ioStatus = () => {};
globalThis.switchTab = () => {}; globalThis.switchTab = () => {};
@@ -226,62 +237,76 @@ test('preset completeness predicates used by the header markers', () => {
test('clearing the card form keeps the role TAR/MSL defaults', () => { test('clearing the card form keeps the role TAR/MSL defaults', () => {
const els = setup(); const els = setup();
els['cards-tar'].value = 'AABBCC'; els['cards-pin1'].value = '1234';
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-adm'].value = 'DEAD';
els['cards-name'].value = 'X'; els['cards-name'].value = 'X';
tarRow(els, 0, { tar: 'AF4D01', msl: '0A', desc: 'applet' }); // a free row from a previous edit
tarRow(els, 3, { tar: 'AF4D01', msl: '0A', desc: 'applet' });
_cardsTarCount = 1; _cardsTarCount = 1;
cardsClearForm(); cardsClearForm();
assert.strictEqual(els['cards-tar'].value, '000000'); assert.strictEqual(els['cards-pin1'].value, '');
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-adm'].value, '');
assert.strictEqual(els['cards-name'].value, ''); assert.strictEqual(els['cards-name'].value, '');
assert.strictEqual(_cardsTarCount, 0, 'the free TAR rows are cleared'); assert.strictEqual(_cardsTarCount, 0, 'the free TAR rows are cleared');
// the role rows render the pre-settled defaults
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-tar"[^>]*value="000000"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-msl"[^>]*value="16"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-1-tar"[^>]*value="B00000"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-2-tar"[^>]*value="B00001"/);
// ... and the table reads back as the three role entries
assert.deepStrictEqual(cardsTarsFromForm(), [
{ role: 'isd', tar: '000000', msl: '16', desc: '' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '' },
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '' },
]);
}); });
test('editing prefills the role TARs/MSLs and renders the free TAR rows', () => { test('editing prefills the code fields and renders the TAR table', () => {
const els = setup(); const els = setup();
globalThis.cards = [ globalThis.cards = [
{ name: 'Legacy', keysets: [] }, { name: 'Legacy', keysets: [] },
presetFixture({ name: 'Full', adm: '0011', tars: [ presetFixture({ name: 'Full', adm: '0011', pin1: '1234', puk1: '12345678',
pin2: '0000', puk2: '87654321', tars: [
{ role: 'isd', tar: '000001', msl: '0A' }, { role: 'isd', tar: '000001', msl: '0A' },
{ role: 'uiccRfm', tar: 'B0000C', msl: '16' }, { role: 'uiccRfm', tar: 'B0000C', msl: '16' },
{ role: 'usimRfm', tar: 'B0000D', msl: '16' }, { role: 'usimRfm', tar: 'B0000D', msl: '16' },
{ tar: 'AF4D02', msl: '1A', desc: 'applet' }] }), { tar: 'AF4D02', msl: '1A', desc: 'applet' }] }),
]; ];
cardsEdit(0); cardsEdit(0);
assert.strictEqual(els['cards-tar'].value, '000000'); // the editor opens; missing codes read as empty
assert.strictEqual(els['cards-tar-msl'].value, ''); assert.ok(!els['cards-editor-view'].classList.contains('hidden'));
assert.strictEqual(els['cards-uicc-tar'].value, 'B00000'); assert.ok(els['cards-list-view'].classList.contains('hidden'));
assert.strictEqual(els['cards-pin1'].value, '');
assert.strictEqual(els['cards-adm'].value, ''); assert.strictEqual(els['cards-adm'].value, '');
cardsEdit(1); cardsEdit(1);
assert.strictEqual(els['cards-tar'].value, '000001'); assert.strictEqual(els['cards-pin1'].value, '1234');
assert.strictEqual(els['cards-tar-msl'].value, '0A'); assert.strictEqual(els['cards-puk1'].value, '12345678');
assert.strictEqual(els['cards-uicc-tar'].value, 'B0000C'); assert.strictEqual(els['cards-pin2'].value, '0000');
assert.strictEqual(els['cards-usim-tar'].value, 'B0000D'); assert.strictEqual(els['cards-puk2'].value, '87654321');
assert.strictEqual(els['cards-usim-tar-msl'].value, '16');
assert.strictEqual(els['cards-adm'].value, '0011'); assert.strictEqual(els['cards-adm'].value, '0011');
assert.strictEqual(_cardsTarCount, 1); assert.strictEqual(_cardsTarCount, 1);
assert.match(els['cards-tars'].innerHTML, /cards-ft-0-tar"[^>]*value="AF4D02"/); assert.match(els['cards-tars'].innerHTML, /cards-tr-0-tar"[^>]*value="000001"/);
assert.match(els['cards-tars'].innerHTML, /cards-ft-0-desc"[^>]*value="applet"/); assert.match(els['cards-tars'].innerHTML, /cards-tr-1-tar"[^>]*value="B0000C"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-2-tar"[^>]*value="B0000D"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-3-tar"[^>]*value="AF4D02"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-3-desc"[^>]*value="applet"/);
// the role rows carry the fixed descriptions and no remove button
assert.match(els['cards-tars'].innerHTML, /ISD compact format/);
assert.match(els['cards-tars'].innerHTML, /UICC shared FS RFM compact format/);
assert.match(els['cards-tars'].innerHTML, /ADF FS RFM compact format/);
assert.doesNotMatch(els['cards-tars'].innerHTML, /cardsTarRemove\(0\)/);
assert.match(els['cards-tars'].innerHTML, /cardsTarRemove\(3\)/);
}); });
test('the form values carry the ADM and the TAR table', () => { test('the form values carry the codes and the TAR table', () => {
const els = setup(); const els = setup();
els['cards-tar'].value = ' 000001 '; els['cards-pin1'].value = ' 12 34 ';
els['cards-tar-msl'].value = '0a'; els['cards-puk1'].value = '12345678';
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 '; els['cards-adm'].value = ' 00 11 22 33 ';
tarRow(els, 0, { tar: 'af4d02', msl: '0A', desc: 'My applet' }); tarRow(els, 0, { tar: ' 000001 ', msl: '0a' });
tarRow(els, 1, { tar: '', msl: '' }); // uiccRfm -> spec default
tarRow(els, 2, { tar: 'B00003', msl: '1A' });
tarRow(els, 3, { tar: 'af4d02', msl: '0A', desc: 'My applet' });
_cardsTarCount = 1; _cardsTarCount = 1;
const v = cardsFormValues(); const v = cardsFormValues();
assert.deepStrictEqual(v.tars, [ assert.deepStrictEqual(v.tars, [
@@ -290,6 +315,8 @@ test('the form values carry the ADM and the TAR table', () => {
{ role: 'usimRfm', tar: 'B00003', msl: '1A', desc: '' }, { role: 'usimRfm', tar: 'B00003', msl: '1A', desc: '' },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' }, { tar: 'AF4D02', msl: '0A', desc: 'My applet' },
]); ]);
assert.strictEqual(v.pin1, '1234');
assert.strictEqual(v.puk1, '12345678');
assert.strictEqual(v.adm, '00112233'); assert.strictEqual(v.adm, '00112233');
}); });
@@ -322,14 +349,14 @@ test('saving refuses a free TAR row without its MSL or with a duplicate TAR', as
globalThis._cardsKeysetCount = 1; globalThis._cardsKeysetCount = 1;
const seen = []; const seen = [];
globalThis.alert = msg => seen.push(msg); globalThis.alert = msg => seen.push(msg);
tarRow(els, 0, { tar: 'AF4D02', msl: '', desc: '' }); tarRow(els, 3, { tar: 'AF4D02', msl: '', desc: '' });
_cardsTarCount = 1; _cardsTarCount = 1;
await cardsAdd(); await cardsAdd();
assert.strictEqual(apiCalls.length, 0); assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /MSL/); assert.match(seen[0], /MSL/);
// a TAR may appear once // a TAR may appear once
seen.length = 0; seen.length = 0;
tarRow(els, 0, { tar: '000000', msl: '16', desc: '' }); tarRow(els, 3, { tar: '000000', msl: '16', desc: '' });
await cardsAdd(); await cardsAdd();
assert.strictEqual(apiCalls.length, 0); assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /Duplicate TAR/); assert.match(seen[0], /Duplicate TAR/);
@@ -506,23 +533,75 @@ test('packing without a selected preset uses the spec default TAR', () => {
assert.strictEqual(els['sp-tar'].value, '000000'); assert.strictEqual(els['sp-tar'].value, '000000');
}); });
test('the TAR editor renders, adds and removes free rows', () => { test('the TAR table renders the role rows and adds/removes free rows', () => {
const els = setup(); const els = setup();
cardsTarRowsRender([{ tar: 'AF4D01', msl: '0A', desc: 'one' }, cardsTarRowsRender([{ tar: 'AF4D01', msl: '0A', desc: 'one' },
{ tar: 'AF4D02', msl: '0A', desc: 'two' }]); { tar: 'AF4D02', msl: '0A', desc: 'two' }]);
assert.strictEqual(_cardsTarCount, 2); assert.strictEqual(_cardsTarCount, 2);
assert.match(els['cards-tars'].innerHTML, /cards-ft-1-tar"[^>]*value="AF4D02"/); assert.match(els['cards-tars'].innerHTML, /cards-tr-0-tar"[^>]*value="000000"/);
assert.match(els['cards-tars'].innerHTML, /ISD compact format/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-3-tar"[^>]*value="AF4D01"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-4-tar"[^>]*value="AF4D02"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-4-desc"[^>]*value="two"/);
assert.match(els['cards-tars'].innerHTML, /cardsTarRemove\(4\)/);
// simulate the DOM the innerHTML produced // simulate the DOM the innerHTML produced
tarRow(els, 0, { tar: 'AF4D01', msl: '0A', desc: 'one' }); tarRow(els, 3, { tar: 'AF4D01', msl: '0A', desc: 'one' });
tarRow(els, 1, { tar: 'AF4D02', msl: '0A', desc: 'two' }); tarRow(els, 4, { tar: 'AF4D02', msl: '0A', desc: 'two' });
cardsTarRemove(0); cardsTarRemove(3);
assert.strictEqual(_cardsTarCount, 1); assert.strictEqual(_cardsTarCount, 1);
assert.match(els['cards-tars'].innerHTML, /value="AF4D02"/); assert.match(els['cards-tars'].innerHTML, /value="AF4D02"/);
assert.doesNotMatch(els['cards-tars'].innerHTML, /value="AF4D01"/); assert.doesNotMatch(els['cards-tars'].innerHTML, /value="AF4D01"/);
tarRow(els, 0, { tar: 'AF4D02', msl: '0A', desc: 'two' }); tarRow(els, 3, { tar: 'AF4D02', msl: '0A', desc: 'two' });
cardsTarAdd(); cardsTarAdd();
assert.strictEqual(_cardsTarCount, 2); assert.strictEqual(_cardsTarCount, 2);
// the new row renders empty // the new row renders empty
tarRow(els, 1, { tar: '', msl: '', desc: '' }); tarRow(els, 4, { tar: '', msl: '', desc: '' });
assert.deepStrictEqual(cardsFreeTarsFromForm(), [{ tar: 'AF4D02', msl: '0A', desc: 'two' }]); assert.deepStrictEqual(cardsFreeTarsFromForm(), [{ tar: 'AF4D02', msl: '0A', desc: 'two' }]);
// the role rows are never removable
cardsTarRemove(0);
assert.strictEqual(_cardsTarCount, 2);
});
test('the list view opens the editor for a new preset and Cancel returns', () => {
const els = setup();
globalThis.cards = [presetFixture()];
cardsNew();
assert.ok(!els['cards-editor-view'].classList.contains('hidden'), 'the editor is shown');
assert.ok(els['cards-list-view'].classList.contains('hidden'), 'the list is hidden');
assert.strictEqual(globalThis._cardsEditIdx, null);
assert.strictEqual(els['cards-editor-title'].textContent, 'Add preset');
assert.strictEqual(els['cards-add-btn'].textContent, 'Add');
assert.strictEqual(_cardsTarCount, 0);
cardsCancelEdit();
assert.ok(!els['cards-list-view'].classList.contains('hidden'), 'the list is back');
assert.ok(els['cards-editor-view'].classList.contains('hidden'), 'the editor is hidden');
});
test('editing opens the editor with the preset title', () => {
const els = setup();
globalThis.cards = [presetFixture({ name: 'Foo' })];
cardsEdit(0);
assert.strictEqual(globalThis._cardsEditIdx, 0);
assert.ok(!els['cards-editor-view'].classList.contains('hidden'));
assert.strictEqual(els['cards-editor-title'].textContent, 'Edit preset: Foo');
assert.strictEqual(els['cards-add-btn'].textContent, 'Save');
});
test('the editor refuses PIN/PUK codes that are not 4-8 digits', async () => {
const els = setup();
els['cards-name'].value = 'X';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
globalThis._cardsKeysetCount = 1;
const seen = [];
globalThis.alert = msg => seen.push(msg);
els['cards-pin1'].value = '123';
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /PIN\/PUK/);
seen.length = 0;
els['cards-pin1'].value = '1234';
els['cards-puk2'].value = '12A4';
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /PUK2/);
}); });
+52 -24
View File
@@ -129,45 +129,73 @@ test('cards list shows the SCP81 PSK column with blue/red row buttons', () => {
assert.match(fn[0], /cardsRemove\(' \+ i \+ '\)" class="[^"]*bg-red-600 text-white/); assert.match(fn[0], /cardsRemove\(' \+ i \+ '\)" class="[^"]*bg-red-600 text-white/);
}); });
test('the card form groups the SCP80 and SCP81 fields into labelled fieldsets', () => { test('the card editor groups its fields into labelled fieldsets', () => {
const fieldsets = [...html.matchAll(/<fieldset[\s\S]*?<\/fieldset>/g)].map(m => m[0]); const fieldsets = [...html.matchAll(/<fieldset[\s\S]*?<\/fieldset>/g)].map(m => m[0]);
const pinAdm = fieldsets.find(f => /data-l10n="PIN\/ADM"/.test(f));
const tar = fieldsets.find(f => /data-l10n="TAR"/.test(f));
const scp80 = fieldsets.find(f => /data-l10n="SCP80 \(GSM 03\.48, ETSI TS 102 225\)"/.test(f)); const scp80 = fieldsets.find(f => /data-l10n="SCP80 \(GSM 03\.48, ETSI TS 102 225\)"/.test(f));
const scp81 = fieldsets.find(f => /data-l10n="SCP81 \(HTTP OTA\)"/.test(f)); const scp81 = fieldsets.find(f => /data-l10n="SCP81 \(HTTP OTA\)"/.test(f));
assert.ok(pinAdm, 'PIN/ADM fieldset not found');
assert.ok(tar, 'TAR fieldset not found');
assert.ok(scp80, 'SCP80 card fieldset not found'); assert.ok(scp80, 'SCP80 card fieldset not found');
assert.ok(scp81, 'SCP81 card fieldset not found'); assert.ok(scp81, 'SCP81 card fieldset not found');
// the optional card codes (plain text; PIN/PUK are 4-8 decimal digits)
for (const id of ['cards-pin1', 'cards-puk1', 'cards-pin2', 'cards-puk2', 'cards-adm']) {
assert.ok(pinAdm.includes('id="' + id + '"'), id + ' not in the PIN/ADM fieldset');
}
// the TAR table: uniform rows rendered into the body, Add TAR below
assert.match(tar, /<tbody id="cards-tars">/);
assert.match(tar, /onclick="cardsTarAdd\(\)"/);
assert.match(tar, />TAR<\/th>/);
assert.match(tar, />MSL<\/th>/);
assert.match(tar, />Description<\/th>/);
// the keyset editor and the PSK pair stay as implemented
assert.match(scp80, /id="cards-keysets"/); assert.match(scp80, /id="cards-keysets"/);
assert.match(scp80, /onclick="cardsKeysetAdd\(\)"/); assert.match(scp80, /onclick="cardsKeysetAdd\(\)"/);
assert.match(scp80, /id="cards-tar"[^>]*value="000000"/);
assert.match(scp80, /id="cards-uicc-tar"[^>]*value="B00000"/);
assert.match(scp80, /id="cards-usim-tar"[^>]*value="B00001"/);
// the TAR fields are labelled and sit to the right of Counter
assert.match(scp80, />ISD TAR<\/label>/);
assert.match(scp80, />UICC RFM TAR<\/label>/);
assert.match(scp80, />ADF RFM TAR<\/label>/);
// 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-id"/);
assert.match(scp81, /id="cards-psk-key"/); assert.match(scp81, /id="cards-psk-key"/);
// the PSK explanation lives inside the SCP81 group, not outside it // the PSK explanation lives inside the SCP81 group, not outside it
assert.match(scp81, /data-l10n="SCP81 HTTP OTA: the listener picks the key/); assert.match(scp81, /data-l10n="SCP81 HTTP OTA: the listener picks the key/);
// the ICCID field is not in either group // the preset-wide SPI1/SPI2 inputs are gone: each TAR row carries its MSL
assert.ok(!html.includes('id="cards-spi1"'), 'the preset-wide SPI1 input is gone');
assert.ok(!html.includes('id="cards-spi2"'), 'the preset-wide SPI2 input is gone');
// the name/ICCID fields are in the editor but not inside the fieldsets
assert.ok(!fieldsets.some(f => f.includes('id="cards-iccid"'))); assert.ok(!fieldsets.some(f => f.includes('id="cards-iccid"')));
assert.ok(!fieldsets.some(f => f.includes('id="cards-name"')));
}); });
test('the ADM field sits between From card and Add, outside the card fieldsets', () => { test('the Cards tab splits the preset list and the editor into views', () => {
assert.match(html, /id="cards-list-view"/);
assert.match(html, /id="cards-editor-view" class="hidden"/);
assert.match(html, /id="cards-new-btn"[^>]*onclick="cardsNew\(\)"/);
assert.match(html, /id="cards-editor-title"/);
const listView = html.slice(html.indexOf('id="cards-list-view"'),
html.indexOf('id="cards-editor-view"'));
const editorView = html.slice(html.indexOf('id="cards-editor-view"'),
html.indexOf('id="event-send-modal"'));
// the list view holds the table, the Add preset button and export/import
assert.match(listView, /id="cards-tbody"/);
assert.match(listView, /id="cards-file"/);
assert.match(listView, /id="cards-io"/);
assert.match(listView, /cardsExportFile\(\)/);
assert.ok(!listView.includes('id="cards-keysets"'), 'the editor must not be in the list view');
// the editor view holds the fieldsets and the save/cancel buttons
assert.match(editorView, /id="cards-keysets"/);
assert.match(editorView, /id="cards-pin1"/);
assert.match(editorView, /id="cards-tars"/);
assert.match(editorView, /id="cards-add-btn"[^>]*onclick="cardsAdd\(\)"/);
assert.match(editorView, /onclick="cardsCancelEdit\(\)"/);
assert.ok(!editorView.includes('id="cards-tbody"'), 'the list table must not be in the editor');
});
test('the ADM field sits in the PIN/ADM fieldset of the editor', () => {
const fieldsets = [...html.matchAll(/<fieldset[\s\S]*?<\/fieldset>/g)].map(m => m[0]); const fieldsets = [...html.matchAll(/<fieldset[\s\S]*?<\/fieldset>/g)].map(m => m[0]);
assert.ok(!fieldsets.some(f => f.includes('id="cards-adm"'))); const pinAdm = fieldsets.find(f => /data-l10n="PIN\/ADM"/.test(f));
assert.match(html, /id="cards-adm"[^>]*placeholder="ADM \(optional\)"/); assert.ok(pinAdm, 'PIN/ADM fieldset not found');
const fromIdx = html.indexOf('id="cards-iccid-from-card"'); assert.match(pinAdm, /id="cards-adm"[^>]*placeholder="ADM \(optional\)"/);
const admIdx = html.indexOf('id="cards-adm"'); // the From card button sits in the editor header, above the code fields
const addIdx = html.indexOf('id="cards-add-btn"'); assert.ok(html.indexOf('id="cards-iccid-from-card"') < html.indexOf('id="cards-pin1"'),
assert.ok(fromIdx >= 0 && fromIdx < admIdx && admIdx < addIdx, 'the From card button must precede the code fields');
'ADM input must sit between the From card and Add buttons');
}); });
test('card-dependent profiler buttons show the ICCID and need a readable one', () => { test('card-dependent profiler buttons show the ICCID and need a readable one', () => {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.10.0" version = "3.11.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+17 -3
View File
@@ -20,6 +20,10 @@ 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 card-wide SPI1/SPI2 (SPI2 is an operation property: whether and how a Proof of
Receipt is requested). Receipt is requested).
The optional **card codes** (`pin1`/`puk1`/`pin2`/`puk2`/`adm`) are stored for
later use by the card operations (nothing consumes them yet); the PIN/PUK codes
are 4-8 decimal digits, the ADM key is hex or ASCII digits.
A preset holds **several keysets**; the b8..b5 nibble of KIc/KID numbers them A preset holds **several keysets**; the b8..b5 nibble of KIc/KID numbers them
(TS 102 225 5.1.2/A.2). Each keyset has its own KIc/KID keys and its own (TS 102 225 5.1.2/A.2). Each keyset has its own KIc/KID keys and its own
counter - "a dedicated counter shall be associated to each key version" counter - "a dedicated counter shall be associated to each key version"
@@ -59,18 +63,22 @@ from pathlib import Path
DEFAULT_DIR = '.pysim-simple-server' DEFAULT_DIR = '.pysim-simple-server'
DEFAULT_FILENAME = 'card_presets.json' DEFAULT_FILENAME = 'card_presets.json'
SCHEMA_VERSION = 3 SCHEMA_VERSION = 4
# The fields a preset carries: the localStorage shape plus the id. 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, # material lives in `keysets` - the b8..b5 nibble of KIc/KID numbers them,
# each with its own counter (TS 102 225 Annex A.1: "a dedicated counter shall # each with its own counter (TS 102 225 Annex A.1: "a dedicated counter shall
# be associated to each key version"); the TAR table lives in `tars` (see # be associated to each key version"); the TAR table lives in `tars` (see
# TAR_ROLES). # TAR_ROLES). The optional card codes (PIN/PUK/ADM) are stored for later use.
PRESET_FIELDS = ('name', 'iccid', 'adm', 'pskIdentity', 'pskKey') PRESET_FIELDS = ('name', 'iccid', 'pin1', 'puk1', 'pin2', 'puk2', 'adm',
'pskIdentity', 'pskKey')
# The v3.8.0 flat key fields: converted into a single keyset on load/import. # The v3.8.0 flat key fields: converted into a single keyset on load/import.
LEGACY_KEY_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr') LEGACY_KEY_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
KEYSET_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr') KEYSET_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
HEX_FIELDS = ('adm', 'pskKey') HEX_FIELDS = ('adm', 'pskKey')
# The card codes: PIN/PUK are 4-8 decimal digits (validated in _validate),
# ADM is hex or ASCII digits (HEX_FIELDS).
DIGIT_FIELDS = ('pin1', 'puk1', 'pin2', 'puk2')
KEYSET_HEX_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr') KEYSET_HEX_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
# The TAR table: the three mandatory role entries (their values are pre-settled # 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 # in the Cards form) and free entries with an optional description. Every
@@ -279,6 +287,8 @@ class PresetStore:
v = '' if v is None else str(v).strip() v = '' if v is None else str(v).strip()
if k in HEX_FIELDS: if k in HEX_FIELDS:
v = re.sub(r'\s', '', v).upper() v = re.sub(r'\s', '', v).upper()
elif k in DIGIT_FIELDS:
v = re.sub(r'\s', '', v)
out[k] = v out[k] = v
out['tars'] = self._normalise_tars(fields) out['tars'] = self._normalise_tars(fields)
# Keysets: a v3.8.0 preset (flat kic/kid/kicKey/kidKey/cntr) converts # Keysets: a v3.8.0 preset (flat kic/kid/kicKey/kidKey/cntr) converts
@@ -361,6 +371,10 @@ class PresetStore:
raise PresetError('PSK identity and PSK key must be set together') raise PresetError('PSK identity and PSK key must be set together')
if p['pskKey'] and not re.fullmatch(r'[0-9A-F]{32}', p['pskKey']): if p['pskKey'] and not re.fullmatch(r'[0-9A-F]{32}', p['pskKey']):
raise PresetError('PSK key must be 32 hex characters') raise PresetError('PSK key must be 32 hex characters')
for k in DIGIT_FIELDS:
if p[k] and not re.fullmatch(r'[0-9]{4,8}', p[k]):
raise PresetError('%s must be 4-8 decimal digits (or empty)'
% k.upper())
if not p['keysets']: if not p['keysets']:
raise PresetError('at least one keyset is required') raise PresetError('at least one keyset is required')
seen = set() seen = set()
+1 -1
View File
@@ -32,7 +32,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.10.0' VERSION = '3.11.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
+26
View File
@@ -211,6 +211,29 @@ class StoreCrudTests(unittest.TestCase):
with self.assertRaises(presets.PresetError): with self.assertRaises(presets.PresetError):
self.store.add(preset(name='')) self.store.add(preset(name=''))
def test_pin_and_puk_codes_are_optional_and_normalised(self):
p = self.store.add(preset())
# absent/empty: all four default to ''
self.assertEqual([p[k] for k in ('pin1', 'puk1', 'pin2', 'puk2')],
['', '', '', ''])
p = self.store.add(preset(name='Codes', pin1=' 12 34 ', puk1='12345678',
pin2='0000', puk2=' 8765 4321 '))
self.assertEqual(p['pin1'], '1234')
self.assertEqual(p['puk1'], '12345678')
self.assertEqual(p['pin2'], '0000')
self.assertEqual(p['puk2'], '87654321')
def test_pin_and_puk_codes_must_be_4_to_8_digits(self):
for field in ('pin1', 'puk1', 'pin2', 'puk2'):
for bad in ('123', '123456789', '12A4', 'abcd'):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(name='X', **{field: bad}))
self.assertIn(field.upper(), str(ctx.exception))
# 4 and 8 digits are the accepted bounds; empty stays optional
p = self.store.add(preset(name='Bounds', pin1='1234', puk1='12345678'))
self.assertEqual(p['pin1'], '1234')
self.assertEqual(p['puk1'], '12345678')
def test_duplicate_iccid_is_refused_across_stored_forms(self): def test_duplicate_iccid_is_refused_across_stored_forms(self):
self.store.add(preset(iccid=DIGITS)) self.store.add(preset(iccid=DIGITS))
with self.assertRaises(presets.PresetError) as ctx: with self.assertRaises(presets.PresetError) as ctx:
@@ -362,11 +385,14 @@ class StorePersistenceTests(unittest.TestCase):
def test_the_file_round_trips(self): def test_the_file_round_trips(self):
store = make_store(self.tmp.name) store = make_store(self.tmp.name)
p = store.add(preset(iccid=DIGITS, pskIdentity='id', pskKey='00' * 16, p = store.add(preset(iccid=DIGITS, pskIdentity='id', pskKey='00' * 16,
pin1='1234', puk1='12345678',
keysets=[keyset(kic='15', kid='15'), keysets=[keyset(kic='15', kid='15'),
keyset(kic='29', kid='29', cntr='0000000004')])) keyset(kic='29', kid='29', cntr='0000000004')]))
raw = json.loads(pathlib.Path(store.path).read_text(encoding='utf-8')) raw = json.loads(pathlib.Path(store.path).read_text(encoding='utf-8'))
self.assertEqual(raw['version'], presets.SCHEMA_VERSION) self.assertEqual(raw['version'], presets.SCHEMA_VERSION)
self.assertEqual(raw['presets'][0]['keysets'][1]['cntr'], '0000000004') self.assertEqual(raw['presets'][0]['keysets'][1]['cntr'], '0000000004')
self.assertEqual(raw['presets'][0]['pin1'], '1234')
self.assertEqual(raw['presets'][0]['puk1'], '12345678')
again = make_store(self.tmp.name) again = make_store(self.tmp.name)
self.assertEqual([x['id'] for x in again.list()], [p['id']]) self.assertEqual([x['id'] for x in again.list()], [p['id']])
self.assertEqual(again.find_keyset(p['id'], 2)['cntr'], '0000000004') self.assertEqual(again.find_keyset(p['id'], 2)['cntr'], '0000000004')