v3.12.0: the card preset carries the card's CAT (USAT) release

The release selector lives in the preset rather than a global chip: presets
already travel with the card (ICCID auto-selection, export/import), and the
operations that will consume the release are preset-aware.

- presets.py: the optional `release` field (4-18; empty = unspecified) joins
  PRESET_FIELDS; a value outside 4-18 is refused on save/import; the store
  schema version is 5.
- catrelease.py: DEFAULT_RELEASE = 12 and preset_release() - the effective
  release is the preset's own value, else Rel-12 (the safe level for cards
  whose release is not known; an unset release never filters anything away).
  The PWA mirrors both (CAT_RELEASE_DEFAULT + presetRelease()), drift-guarded.
- Cards tab: the editor's header row gains a compact CAT release select
  (unspecified / pre-Rel-6 stored as 5 / Rel-6 ... Rel-18); the empty option
  names the Rel-12 default and the tooltip explains the guard; the field is
  collected, cleared and restored with the other form fields.
- Nothing filters yet: the Simulator's event parameters (stage 3) will use
  the effective release.
- docs/api.md + help EN/RU updated; sw cache simple-v368.
This commit is contained in:
2026-10-02 11:28:33 +03:00
parent 2bb1528ca9
commit 3660a7e173
13 changed files with 149 additions and 13 deletions
+7 -1
View File
@@ -245,6 +245,12 @@ 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` decimal digits** when set (empty is fine; anything else is refused with `400`
naming the field); the ADM key is 4–16 hex digits (a decimal PIN is fine). naming the field); the ADM key is 4–16 hex digits (a decimal PIN is fine).
**CAT (USAT) release.** The optional `release` field (4–18) names the release
the card implements; the release guard hides CAT items introduced after it
(the Simulator's event parameters, see `catrelease`). Empty means unspecified
and resolves to Rel-12 (`catrelease.DEFAULT_RELEASE`) — an unset release never
filters anything away.
**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
@@ -256,7 +262,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": 4, "presets": [{…}]}` - `GET /api/presets` — `{"path": "…", "count": 2, "version": 5, "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
+1 -1
View File
@@ -346,7 +346,7 @@
<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; — верхнеуровневая вкладка: по умолчанию показывается <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> <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>релиз CAT (USAT)</strong> карты &mdash; по нему механизм релизов скрывает элементы CAT, появившиеся позже (параметры событий в Симуляторе); «не указан» означает Rel-12, и неуказанный релиз ничего не фильтрует. В шапке отображаются серые маркеры <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>
+1 -1
View File
@@ -345,7 +345,7 @@
<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: 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> <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 editor's header row also carries the card's <strong>CAT (USAT) release</strong> &mdash; the release guard hides CAT items introduced after it (the Simulator's event parameters); unspecified means Rel-12, and an unset release never filters anything away. 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>
+42 -2
View File
@@ -1636,6 +1636,26 @@
<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> <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>
<div class="w-36 shrink-0">
<label for="cards-release" class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="CAT release">CAT release</label>
<select id="cards-release" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" data-l10n-title="The card's CAT (USAT) release. The Simulator hides event parameters introduced after it. Unspecified = Rel-12, safe for most modern cards.">
<option value="" data-l10n="(unspecified — Rel-12)">(unspecified — Rel-12)</option>
<option value="5" data-l10n="pre-Rel-6">pre-Rel-6</option>
<option value="6">Rel-6</option>
<option value="7">Rel-7</option>
<option value="8">Rel-8</option>
<option value="9">Rel-9</option>
<option value="10">Rel-10</option>
<option value="11">Rel-11</option>
<option value="12">Rel-12</option>
<option value="13">Rel-13</option>
<option value="14">Rel-14</option>
<option value="15">Rel-15</option>
<option value="16">Rel-16</option>
<option value="17">Rel-17</option>
<option value="18">Rel-18</option>
</select>
</div>
</div> </div>
<div class="flex flex-col lg:flex-row gap-3 mb-3"> <div class="flex flex-col lg:flex-row gap-3 mb-3">
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 lg:w-56 shrink-0"> <fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 lg:w-56 shrink-0">
@@ -1857,7 +1877,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.11.0'; const SIMPLE_VERSION = '3.12.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -9154,6 +9174,20 @@ function catReleaseLabel(rel) {
return rel < 6 ? 'pre-Rel-6' : 'Rel-' + rel; return rel < 6 ? 'pre-Rel-6' : 'Rel-' + rel;
} }
// The release a preset's empty `release` resolves to: Rel-12 is the safe
// level for cards whose release is not known (the CAT items the tool sends
// stay within what a modern card implements). Mirrors
// catrelease.DEFAULT_RELEASE, drift-guarded by tests/test_catrelease.py.
const CAT_RELEASE_DEFAULT = 12;
// The effective CAT release of a card preset (pure): its own release when it
// names one (4-18), else the default. Empty or invalid values resolve to the
// default - an unset release must never filter anything away.
function presetRelease(preset) {
const rel = parseInt((preset || {}).release, 10);
return (rel >= 4 && rel <= 18) ? rel : CAT_RELEASE_DEFAULT;
}
// Normalise an AID (strip spacing, uppercase) and reject malformed input; // Normalise an AID (strip spacing, uppercase) and reject malformed input;
// '' when it cannot be an AID (5-16 bytes). // '' when it cannot be an AID (5-16 bytes).
function jcAidNorm(aid) { function jcAidNorm(aid) {
@@ -12157,6 +12191,7 @@ function cardsFormValues() {
return { return {
name: val('name'), name: val('name'),
iccid: val('iccid'), iccid: val('iccid'),
release: val('release'),
pin1: val('pin1').replace(/\s/g, ''), pin1: val('pin1').replace(/\s/g, ''),
puk1: val('puk1').replace(/\s/g, ''), puk1: val('puk1').replace(/\s/g, ''),
pin2: val('pin2').replace(/\s/g, ''), pin2: val('pin2').replace(/\s/g, ''),
@@ -12207,7 +12242,7 @@ function cardsNew() {
} }
function cardsClearForm() { function cardsClearForm() {
['name','iccid','pin1','puk1','pin2','puk2','adm','psk-id','psk-key'].forEach(id => { ['name','iccid','release','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 = '';
}); });
@@ -12300,6 +12335,7 @@ function cardsEdit(i) {
if (el) el.value = value == null ? '' : value; if (el) el.value = value == null ? '' : value;
}; };
set('name', c.name); set('iccid', c.iccid); set('name', c.name); set('iccid', c.iccid);
set('release', c.release);
set('pin1', c.pin1); set('puk1', c.puk1); set('pin1', c.pin1); set('puk1', c.puk1);
set('pin2', c.pin2); set('puk2', c.puk2); set('pin2', c.pin2); set('puk2', c.puk2);
set('adm', c.adm); set('adm', c.adm);
@@ -20925,6 +20961,10 @@ const LANG_RU = {
'Description': 'Описание', '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 — у каждого свой номер и счётчик.', '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': 'Все коды необязательны', 'All codes are optional': 'Все коды необязательны',
'CAT release': 'Релиз CAT',
'(unspecified — Rel-12)': '(не указан — Rel-12)',
'pre-Rel-6': 'до Rel-6',
'The card\'s CAT (USAT) release. The Simulator hides event parameters introduced after it. Unspecified = Rel-12, safe for most modern cards.': 'Релиз CAT (USAT) карты. Симулятор скрывает параметры событий, появившиеся после него. Не указан = Rel-12, безопасно для большинства современных карт.',
'The first three rows are the fixed role TARs; add further TARs as needed.': 'Первые три строки — фиксированные роли TAR; дополнительные TAR добавляются по необходимости.', '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 десятичных цифр', 'PIN/PUK codes must be 4-8 decimal digits': 'Коды PIN/PUK должны состоять из 4–8 десятичных цифр',
'KIc/KID — counter': 'KIc/KID — счётчик', 'KIc/KID — counter': 'KIc/KID — счётчик',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v367'; const CACHE = 'simple-v368';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+16 -1
View File
@@ -58,7 +58,7 @@ 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-pin1','cards-puk1','cards-pin2','cards-puk2','cards-adm', const CARD_IDS = ['cards-name','cards-iccid','cards-release','cards-pin1','cards-puk1','cards-pin2','cards-puk2','cards-adm',
'cards-tars','cards-list-view','cards-editor-view','cards-editor-title','cards-new-btn', 'cards-tars','cards-list-view','cards-editor-view','cards-editor-title','cards-new-btn',
'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',
@@ -319,6 +319,21 @@ test('the form values carry the codes and the TAR table', () => {
assert.strictEqual(v.adm, '00112233'); assert.strictEqual(v.adm, '00112233');
}); });
test('the release field is collected, cleared and restored', () => {
const els = setup();
els['cards-release'].value = '14';
assert.strictEqual(cardsFormValues().release, '14');
cardsClearForm();
assert.strictEqual(els['cards-release'].value, '');
globalThis.cards = [presetFixture({ name: 'Rel', release: '17' })];
cardsEdit(0);
assert.strictEqual(els['cards-release'].value, '17');
// a preset without a release reads as unspecified
globalThis.cards = [presetFixture({ name: 'Plain' })];
cardsEdit(0);
assert.strictEqual(els['cards-release'].value, '');
});
test('saving a preset posts the form with the pre-settled TAR table', async () => { test('saving a preset posts the form with the pre-settled TAR table', async () => {
const els = setup(); const els = setup();
els['cards-name'].value = 'New card'; els['cards-name'].value = 'New card';
+14
View File
@@ -32,6 +32,8 @@ function extractFunc(src, name) {
eval(extractBlock('const CAT_RELEASE = {', '// The display label of an introduction release') eval(extractBlock('const CAT_RELEASE = {', '// The display label of an introduction release')
.replace(/^const /gm, 'var ')); .replace(/^const /gm, 'var '));
eval(extractFunc(html, 'catReleaseLabel')); eval(extractFunc(html, 'catReleaseLabel'));
eval(extractBlock('const CAT_RELEASE_DEFAULT', 'function presetRelease').replace(/^const /gm, 'var '));
eval(extractFunc(html, 'presetRelease'));
eval(extractBlock('const EVENT_NAMES = {', 'const REJECTION_CAUSES = [').replace(/^const /gm, 'var ')); eval(extractBlock('const EVENT_NAMES = {', 'const REJECTION_CAUSES = [').replace(/^const /gm, 'var '));
test('CAT_RELEASE carries the introduction releases of the event list', () => { test('CAT_RELEASE carries the introduction releases of the event list', () => {
@@ -77,3 +79,15 @@ test('catReleaseLabel collapses everything below Rel-6', () => {
assert.strictEqual(catReleaseLabel(6), 'Rel-6'); assert.strictEqual(catReleaseLabel(6), 'Rel-6');
assert.strictEqual(catReleaseLabel(18), 'Rel-18'); assert.strictEqual(catReleaseLabel(18), 'Rel-18');
}); });
test('presetRelease resolves unspecified presets to the default', () => {
assert.strictEqual(CAT_RELEASE_DEFAULT, 12);
assert.strictEqual(presetRelease({ release: '14' }), 14);
assert.strictEqual(presetRelease({ release: 4 }), 4);
assert.strictEqual(presetRelease({ release: '18' }), 18);
// an unset or invalid release must never filter anything away
for (const p of [{}, { release: '' }, { release: '0' }, { release: '3' },
{ release: '99' }, { release: 'x' }, null, undefined]) {
assert.strictEqual(presetRelease(p), CAT_RELEASE_DEFAULT, JSON.stringify(p));
}
});
+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.11.0" version = "3.12.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.
+24 -1
View File
@@ -16,7 +16,9 @@ histories do not pin down - never filter on it.
The PWA mirrors this table (``CAT_RELEASE`` in frontend/index.html); the drift The PWA mirrors this table (``CAT_RELEASE`` in frontend/index.html); the drift
guard is tests/test_catrelease.py. Releases below 6 display as "pre-Rel-6" guard is tests/test_catrelease.py. Releases below 6 display as "pre-Rel-6"
(``release_label``). (``release_label``). ``DEFAULT_RELEASE`` (Rel-12) is the release a card
preset's empty ``release`` resolves to (``preset_release``) - the safe level
for cards whose release is not known.
""" """
# Keyed by: events - the event code (TS 102 223 8.25 / TS 31.111 7.5); # Keyed by: events - the event code (TS 102 223 8.25 / TS 31.111 7.5);
@@ -117,3 +119,24 @@ def release_label(rel):
def item_release(kind, key, default=None): def item_release(kind, key, default=None):
"""The introduction release of an item, or `default` when unknown.""" """The introduction release of an item, or `default` when unknown."""
return CAT_RELEASE.get(kind, {}).get(key, default) return CAT_RELEASE.get(kind, {}).get(key, default)
# The release a preset's empty `release` resolves to: Rel-12 is the safe level
# for cards whose release is not known (the CAT items the tool sends stay
# within what a modern card implements). Mirrored in the PWA
# (`CAT_RELEASE_DEFAULT`), drift-guarded by tests/test_catrelease.py.
DEFAULT_RELEASE = 12
def preset_release(preset):
"""The effective CAT release of a card preset.
The preset's own `release` when it names one (4-18), else the default.
Empty or invalid values resolve to the default - an unset release must
never filter anything away.
"""
try:
rel = int((preset or {}).get('release') or 0)
except (TypeError, ValueError):
rel = 0
return rel if 4 <= rel <= 18 else DEFAULT_RELEASE
+11 -2
View File
@@ -24,6 +24,12 @@ 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 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. are 4-8 decimal digits, the ADM key is hex or ASCII digits.
The optional **CAT (USAT) release** (`release`, 4-18) names the release the
card implements: the release guard hides CAT items introduced after it (the
Simulator's event parameters, see `catrelease`). Empty means unspecified and
resolves to Rel-12 (`catrelease.DEFAULT_RELEASE`) - an unset release never
filters anything away.
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"
@@ -63,14 +69,14 @@ 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 = 4 SCHEMA_VERSION = 5
# 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). The optional card codes (PIN/PUK/ADM) are stored for later use. # TAR_ROLES). The optional card codes (PIN/PUK/ADM) are stored for later use.
PRESET_FIELDS = ('name', 'iccid', 'pin1', 'puk1', 'pin2', 'puk2', 'adm', PRESET_FIELDS = ('name', 'iccid', 'release', 'pin1', 'puk1', 'pin2', 'puk2', 'adm',
'pskIdentity', 'pskKey') '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')
@@ -371,6 +377,9 @@ 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')
if p['release'] and (not re.fullmatch(r'[0-9]{1,2}', p['release'])
or not 4 <= int(p['release']) <= 18):
raise PresetError('release must be 4-18 (or empty for the Rel-12 default)')
for k in DIGIT_FIELDS: for k in DIGIT_FIELDS:
if p[k] and not re.fullmatch(r'[0-9]{4,8}', p[k]): 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)' raise PresetError('%s must be 4-8 decimal digits (or empty)'
+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.11.0' VERSION = '3.12.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
+15 -1
View File
@@ -21,7 +21,7 @@ class TestCatReleaseTable(unittest.TestCase):
def test_pwa_mirror_matches_the_module(self): def test_pwa_mirror_matches_the_module(self):
# the PWA's release guard uses the same table (drift guard, like # the PWA's release guard uses the same table (drift guard, like
# ETSI_TOOLKIT_RELEASE in test_cap_memory) # ETSI_TOOLKIT_RELEASE in test_cap_memory)
block, _ = _pwa_block() block, html = _pwa_block()
ev = re.search(r"events:\s*\{(.*?)\}", block, re.S) ev = re.search(r"events:\s*\{(.*?)\}", block, re.S)
self.assertIsNotNone(ev, 'events section not found') self.assertIsNotNone(ev, 'events section not found')
self.assertEqual( self.assertEqual(
@@ -34,6 +34,9 @@ class TestCatReleaseTable(unittest.TestCase):
self.assertEqual( self.assertEqual(
{k: int(v) for k, v in re.findall(r"'([^']+)':\s*(\d+)", m.group(1))}, {k: int(v) for k, v in re.findall(r"'([^']+)':\s*(\d+)", m.group(1))},
catrelease.CAT_RELEASE[section], section) catrelease.CAT_RELEASE[section], section)
d = re.search(r"const CAT_RELEASE_DEFAULT = (\d+);", html)
self.assertIsNotNone(d, 'CAT_RELEASE_DEFAULT not found in index.html')
self.assertEqual(int(d.group(1)), catrelease.DEFAULT_RELEASE)
def test_events_cover_the_pwa_event_list(self): def test_events_cover_the_pwa_event_list(self):
# every event the UI names through 0x1F except the Void 0x1A carries # every event the UI names through 0x1F except the Void 0x1A carries
@@ -60,6 +63,17 @@ class TestCatReleaseTable(unittest.TestCase):
self.assertIsNone(catrelease.item_release('events', 0x1A)) self.assertIsNone(catrelease.item_release('events', 0x1A))
self.assertIsNone(catrelease.item_release('nope', 'x')) self.assertIsNone(catrelease.item_release('nope', 'x'))
def test_preset_release_resolves_the_default(self):
self.assertEqual(catrelease.DEFAULT_RELEASE, 12)
self.assertEqual(catrelease.preset_release({'release': '14'}), 14)
self.assertEqual(catrelease.preset_release({'release': 4}), 4)
self.assertEqual(catrelease.preset_release({'release': '18'}), 18)
# an unset or invalid release must never filter anything away
for p in ({}, {'release': ''}, {'release': '0'}, {'release': '3'},
{'release': '99'}, {'release': 'x'}, None):
self.assertEqual(catrelease.preset_release(p),
catrelease.DEFAULT_RELEASE, p)
def test_table_sanity(self): def test_table_sanity(self):
for section, table in catrelease.CAT_RELEASE.items(): for section, table in catrelease.CAT_RELEASE.items():
self.assertTrue(table, section) self.assertTrue(table, section)
+15
View File
@@ -234,6 +234,21 @@ class StoreCrudTests(unittest.TestCase):
self.assertEqual(p['pin1'], '1234') self.assertEqual(p['pin1'], '1234')
self.assertEqual(p['puk1'], '12345678') self.assertEqual(p['puk1'], '12345678')
def test_release_is_optional_and_must_be_4_to_18(self):
p = self.store.add(preset())
self.assertEqual(p['release'], '')
p = self.store.add(preset(name='Rel', release='14'))
self.assertEqual(p['release'], '14')
# numbers arrive as strings from the form and as ints from the API
p = self.store.add(preset(name='Int', release=4))
self.assertEqual(p['release'], '4')
p = self.store.add(preset(name='Top', release=' 18 '))
self.assertEqual(p['release'], '18')
for bad in ('3', '19', '0', '20', 'abc', '1x', '012'):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(name='X', release=bad))
self.assertIn('release', str(ctx.exception))
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: