feat: one keyset per key version - indexed keys and counters (v3.8.0)

A card usually holds several OTA key sets, and the SCP80 counter is tracked
per key version on the card, so a preset now carries one keyset per
GlobalPlatform key version - the b8..b5 nibble of KIc/KID (TS 102 225
5.1.2/A.2), the version is implicit in the bytes.  The spec is explicit:
"a dedicated counter shall be associated to each key version" (Annex A.1) and
"the key version number indicated in the KIc and KID (bits b8..b5) fields
shall be identical when different from 0" - a mismatch is rejected by the card
with "Unidentified security error" (A.2).

Server (presets.py, schema 2):
- a preset carries `keysets: [{kic, kid, kicKey, kidKey, cntr}]`; validation
  refuses a KIc/KID version mismatch (A.2), a version outside 01-0F ('00'
  means "no security" and is a packet-level choice, not a keyset), duplicate
  versions and missing keys/counters; a v3.8.0 flat preset converts into one
  keyset on load and on import;
- `set_counter(..., kvn)` writes the counter of that keyset (monotonic per
  keyset), the audit line carries the kvn, and `find_keyset()` serves the
  lookups;
- the operations derive the key version from the request's KIc byte and
  persist into that keyset: /api/send-ota, the RAM install/install-app/compat
  steps (_ram_send_gp_apdu) and the test runner's SCP80 steps (which may name
  a `kvn` per step and keep one working counter per keyset);
- a non-zero key version the named preset does not define, or a KIc/KID
  mismatch, is refused before anything is built (_preset_keyset_check /
  _keyset_guard: 400, or a step error in the test runner).  Without a
  preset_id only the mismatch rule applies.

PWA:
- the Cards form's KIc/KID/counter/keys inputs become a keyset editor (one row
  per version: KIc, KID, both keys, that version's counter, Add/Remove; the
  version is shown derived from the bytes and a mismatch is flagged);
- the SCP80 and RAM views get a Key-version selector beside the preset
  selector, driving the KIc/KID bytes and loading that keyset's keys and
  counter; the packet builder refuses a version the preset does not define
  (spKeysetCheck/spKeysetGuard, pure and unit-tested);
- the counter syncs are per keyset (spCntrLocalSync/ramSaveCntr), the SCP80
  form's counter edit posts the keysets, and the test-run write-back syncs
  every counter the server reports (scp80_counters).

Docs/help/AGENTS updated; sw cache simple-v335.  Tests: 610 Python (store
keysets, endpoint refusals, the counter landing in the right keyset) and 726
Node (keysets.test.js plus the updated harnesses).
This commit is contained in:
2026-09-30 15:15:21 +03:00
parent 4ab133c266
commit cc2edb3d8a
16 changed files with 1240 additions and 249 deletions
+50 -20
View File
@@ -190,28 +190,56 @@ A blocked ADM (`6983`/`9804`) reports `{"ok": false, "sw": "9804",
### Card presets (server-side store) ### Card presets (server-side store)
Card presets (KIc/KID + keys, SPI, per-target TARs, counter, PSK pair, ADM) Card presets (keysets, SPI, per-target TARs, PSK pair, ADM) live on the server
live on the server in `~/.pysim-simple-server/card_presets.json` in `~/.pysim-simple-server/card_presets.json` (`--card-presets PATH` overrides
(`--card-presets PATH` overrides it; `Path.home()` resolves the same way on it; `Path.home()` resolves the same way on Linux, macOS and Windows). The file
Linux, macOS and Windows). The file is written atomically and every counter is written atomically and every counter change is appended to
change is appended to `card_presets.json.audit.jsonl`. `card_presets.json.audit.jsonl`.
The store is the source of truth for the SCP80 counter: the operations below **Keysets.** A preset carries one keyset per GlobalPlatform key version — the
accept a `preset_id` and **persist the counter they consumed themselves** `b8..b5` nibble of KIc/KID (TS 102 225 §5.1.2/A.2); the version is implicit in
(monotonic - a stale value never regresses it), so a closed tab, a lost the bytes, there is no separate field:
response or a second browser window can no longer lose an increment. The
Cards tab's export/import uses this API; the import also accepts presets
exported by the older localStorage-based builds (the store assigns ids and
applies the form defaults).
- `GET /api/presets` — `{"path": "…", "count": 2, "version": 1, "presets": [{…}]}` ```json
{"id": "…", "name": "Card A", "iccid": "…", "spi1": "16", "spi2": "01",
"tar": "000000", "uiccTar": "B00000", "usimTar": "B00001",
"pskIdentity": "", "pskKey": "", "adm": "",
"keysets": [
{"kic": "15", "kid": "15", "kicKey": "…", "kidKey": "…", "cntr": "0000000001"},
{"kic": "29", "kid": "29", "kicKey": "…", "kidKey": "…", "cntr": "0000000005"}
]}
```
Rules (all refused with `400`): KIc and KID must carry the **same** key version
(A.2 — the card rejects a mismatch with "Unidentified security error"), the
version must be `01`–`0F` (`00` means "no security" and is a packet-level
choice, not a preset keyset), no two keysets may share a version, both keys and
a counter (1–10 hex digits) are required, and the counters are stored in the
fixed-width 10-hex form. A v3.8.0 flat preset (`kic`/`kid`/`kicKey`/`kidKey`/
`cntr` at the top level) converts into a single keyset on load and on import,
so old files and exports keep working.
**Counters.** The store is the source of truth for the SCP80 counters, one per
key version ("a dedicated counter shall be associated to each key version",
Annex A.1). The operations below accept a `preset_id` and **persist the
counter they consumed themselves** (monotonic per keyset — a stale value never
regresses it), so a closed tab, a lost response or a second browser window can
no longer lose an increment. A request whose KIc/KID name a **non-zero key
version the preset does not define** is refused (`400 {"error": "key version N
is not defined in preset '…' - add it in the Cards tab"}`), and so is a
KIc/KID version mismatch. The Cards tab's export/import uses this API; the
import also accepts presets exported by the older localStorage-based builds.
- `GET /api/presets` — `{"path": "…", "count": 2, "version": 2, "presets": [{…}]}`
(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; returns `{"ok": true, "preset": {…}}`. - `POST /api/presets` — body = the preset fields (`keysets` included); returns
A duplicate ICCID (digits, spaced or raw EF hex are normalised to one form) `{"ok": true, "preset": {…}}`. A duplicate ICCID (digits, spaced or raw EF
is refused with `400 {"error": "card with this ICCID already exists: …"}`. hex are normalised to one form) is refused with
`400 {"error": "card with this ICCID already exists: …"}`.
- `POST /api/presets/update` — `{"id": "…", "fields": {…}}` (partial update; - `POST /api/presets/update` — `{"id": "…", "fields": {…}}` (partial update;
a plain `{id, name, …}` body works too). The counter is written as given - a plain `{id, name, …}` body works too, and `keysets` replaces the list).
this is the deliberate human edit; unknown ids answer `404`. A counter is written as given — this is the deliberate human edit; unknown
ids answer `404`.
- `POST /api/presets/delete` — `{"id": "…"}` → `{"ok": true, "removed": true}`. - `POST /api/presets/delete` — `{"id": "…"}` → `{"ok": true, "removed": true}`.
- `POST /api/presets/import` — `{"presets": […], "mode": "merge"}` (or - `POST /api/presets/import` — `{"presets": […], "mode": "merge"}` (or
`"replace"`) → `{"ok": true, "added": N, "skipped": M, "errors": […]}`; `"replace"`) → `{"ok": true, "added": N, "skipped": M, "errors": […]}`;
@@ -355,8 +383,10 @@ scripts) must not be wrapped.
the counter that was sent, advanced by one when the card accepted the packet. the counter that was sent, advanced by one when the card accepted the packet.
A request without a `cntr` (nothing to advance) simply reports no A request without a `cntr` (nothing to advance) simply reports no
`final_cntr` — it never fails the send. With `preset_id` the server persists `final_cntr` — it never fails the send. With `preset_id` the server persists
the counter into the card preset store (see *Card presets*), so a lost the counter into the keyset of the key version the packet used (see *Card
response or a closed tab cannot lose the increment. presets*), so a lost response or a closed tab cannot lose the increment; the
request is refused when KIc/KID carry different key versions (TS 102 225 A.2)
or a non-zero key version the preset does not define.
**Request body:** **Request body:**
```json ```json
+1 -1
View File
@@ -340,7 +340,7 @@
</section> </section>
<section class="mb-10"> <section class="mb-10">
<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; закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и предустановки, выгруженные старыми сборками с хранением в браузере). &laquo;Карты&raquo; — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и предустановка с тем же ICCID автоматически выбирается в обоих представлениях SCP80. Поля формы сгруппированы в два блока с рамкой: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, TAR-ы ISD/UICC RFM/ADF RFM, счётчик и ключи) и <strong>SCP81 (HTTP OTA)</strong> (пара PSK identity/ключ с пояснением о выборе ключа). Необязательное поле <strong>ADM</strong> находится в верхней строке и сохраняется для последующего использования файловым менеджером. В шапке отображаются серые маркеры <strong>SCP80</strong>/<strong>SCP81</strong> и значок ключа на бейдже ADM, когда в предустановке с ICCID подключённой карты эти поля заполнены.</p> <p class="mb-2">Хранит предустановки карт на сервере (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; переопределяется <code class="font-mono text-sm">--card-presets</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, слушатель SCP81 HTTP OTA — находить PSK-ключи, а счётчики SCP80, израсходованные операцией, сохранялись самой операцией &mdash; закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Предустановка содержит <strong>один набор ключей на версию ключа</strong> (ниббл <code class="font-mono text-sm">b8..b5</code> KIc/KID, TS 102 225 &sect;5.1.2/A.2): KIc и KID набора должны содержать одну версию (01&ndash;0F; A.2 отвергает несовпадение), и у каждой версии ключа на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по наборам. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и предустановки старых сборок — плоские и с наборами ключей). &laquo;Карты&raquo; — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и предустановка с тем же ICCID автоматически выбирается в обоих представлениях SCP80. Поля формы сгруппированы в два блока с рамкой: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, TAR-ы ISD/UICC RFM/ADF RFM, счётчик и ключи) и <strong>SCP81 (HTTP OTA)</strong> (пара PSK identity/ключ с пояснением о выборе ключа). Необязательное поле <strong>ADM</strong> находится в верхней строке и сохраняется для последующего использования файловым менеджером. В шапке отображаются серые маркеры <strong>SCP80</strong>/<strong>SCP81</strong> и значок ключа на бейдже ADM, когда в предустановке с ICCID подключённой карты эти поля заполнены.</p>
<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
@@ -339,7 +339,7 @@
</section> </section>
<section class="mb-10"> <section class="mb-10">
<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 counter an operation consumed is persisted with the operation itself &mdash; a closed tab or a second browser window can no longer lose an increment. The list is loaded when the PWA connects and can be exported/imported as JSON (the import also takes presets exported by older browser-storage builds). Cards is a top-level tab. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in both SCP80 views. The form groups the fields into two bordered blocks: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, the ISD/UICC RFM/ADF RFM TARs, counter and keys) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The optional <strong>ADM</strong> field sits in the top row and is stored for later use by the file manager. The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p> <p class="mb-2">Stores card presets on the server (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; <code class="font-mono text-sm">--card-presets</code> overrides it) so the Secured Packet view can auto-fill keys and parameters, the SCP81 HTTP OTA listener can look up PSK keys, and the SCP80 counters an operation consumed are persisted with the operation itself &mdash; a closed tab or a second browser window can no longer lose an increment. A preset carries <strong>one keyset per key version</strong> (the <code class="font-mono text-sm">b8..b5</code> nibble of KIc/KID, TS 102 225 &sect;5.1.2/A.2): KIc and KID of a keyset must carry the same version (01&ndash;0F; A.2 rejects a mismatch), and each key version has its own counter on the card (Annex A.1), so counters are tracked per keyset. The list is loaded when the PWA connects and can be exported/imported as JSON (the import also takes presets exported by older builds, flat or keyset-shaped). Cards is a top-level tab. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in both SCP80 views. The form groups the fields into two bordered blocks: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, the ISD/UICC RFM/ADF RFM TARs, counter and keys) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The optional <strong>ADM</strong> field sits in the top row and is stored for later use by the file manager. The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p>
<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>
+379 -46
View File
@@ -527,6 +527,8 @@
<select id="sp-card-sel" onchange="cardsApply(this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"> <select id="sp-card-sel" onchange="cardsApply(this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="" data-l10n="— Select card —">— Select card —</option> <option value="" data-l10n="— Select card —">— Select card —</option>
</select> </select>
<label class="block mt-2 mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Key version (keyset)">Key version (keyset)</label>
<select id="sp-keyset-sel" onchange="spKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"></select>
</div> </div>
<div class="flex gap-2 mb-3"> <div class="flex gap-2 mb-3">
@@ -690,6 +692,10 @@
<option value="" data-l10n="— Select card —">— Select card —</option> <option value="" data-l10n="— Select card —">— Select card —</option>
</select> </select>
</div> </div>
<div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Key version (keyset)">Key version (keyset)</label>
<select id="ram-keyset-sel" onchange="ramKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"></select>
</div>
<div class="flex-1"> <div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Operation">Operation</label> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Operation">Operation</label>
<select id="ram-op" onchange="ramOpChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"> <select id="ram-op" onchange="ramOpChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
@@ -1536,7 +1542,7 @@
<div id="tab-cards" class="tab-content hidden"> <div id="tab-cards" class="tab-content hidden">
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored locally in your browser. JSON export/import buttons are below the card list.">Card presets are stored locally in your browser. JSON export/import buttons are below the card list.</p> <p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries one keyset per key version, each with its own counter.">Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries one keyset per key version, each with its own counter.</p>
<div class="flex gap-2 mb-3"> <div class="flex gap-2 mb-3">
<input id="cards-name" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Name (e.g. Foobar SIM)"> <input id="cards-name" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Name (e.g. Foobar SIM)">
<input id="cards-iccid" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ICCID (optional)"> <input id="cards-iccid" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ICCID (optional)">
@@ -1548,11 +1554,8 @@
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3"> <fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="SCP80 (GSM 03.48, ETSI TS 102 225)">SCP80 (GSM 03.48, ETSI TS 102 225)</legend> <legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="SCP80 (GSM 03.48, ETSI TS 102 225)">SCP80 (GSM 03.48, ETSI TS 102 225)</legend>
<div class="flex gap-2 mb-2 flex-wrap items-end"> <div class="flex gap-2 mb-2 flex-wrap items-end">
<input id="cards-kic" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="KIc" maxlength="2">
<input id="cards-kid" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="KID" maxlength="2">
<input id="cards-spi1" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI1" maxlength="2"> <input id="cards-spi1" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI1" maxlength="2">
<input id="cards-spi2" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI2" maxlength="2"> <input id="cards-spi2" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI2" maxlength="2">
<input id="cards-cntr" class="w-24 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" placeholder="Counter" maxlength="10">
<div class="flex gap-2"> <div class="flex gap-2">
<div> <div>
<label for="cards-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">ISD TAR</label> <label for="cards-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">ISD TAR</label>
@@ -1568,10 +1571,12 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="mb-1 flex items-center justify-between">
<input id="cards-kic-key" 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="KIc key (32 hex chars)"> <span class="text-xs text-gray-600 dark:text-slate-400" data-l10n="OTA keysets — one per key version, each with its own counter">OTA keysets — one per key version, each with its own counter</span>
<input id="cards-kid-key" 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="KID key (32 hex chars)"> <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>
</div> </div>
<div id="cards-keysets"></div>
<p class="text-xs text-gray-500 dark:text-slate-400" data-l10n="KIc and KID must carry the same key version (their high nibble) in the range 01-0F — TS 102 225 A.2; each key version has its own counter on the card (Annex A.1).">KIc and KID must carry the same key version (their high nibble) in the range 01-0F — TS 102 225 A.2; each key version has its own counter on the card (Annex A.1).</p>
</fieldset> </fieldset>
<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="SCP81 (HTTP OTA)">SCP81 (HTTP OTA)</legend> <legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="SCP81 (HTTP OTA)">SCP81 (HTTP OTA)</legend>
@@ -1586,12 +1591,10 @@
<thead><tr> <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="Name">Name</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="ICCID">ICCID</th> <th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="ICCID">ICCID</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KIc</th> <th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Keysets (key version: KIc/KID — counter)">Keysets (key version: KIc/KID \u2014 counter)</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KID</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI1</th> <th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI1</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI2</th> <th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI2</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">TAR (ISD/UICC RFM/ADF RFM)</th> <th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">TAR (ISD/UICC RFM/ADF RFM)</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Cntr">Cntr</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="SCP81">SCP81</th> <th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="SCP81">SCP81</th>
<th class="border-b border-gray-200 dark:border-slate-700"></th> <th class="border-b border-gray-200 dark:border-slate-700"></th>
</tr></thead> </tr></thead>
@@ -4898,6 +4901,10 @@ function crc32Bytes(data) {
// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig (CC 8, RC 4). // CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig (CC 8, RC 4).
// CPI (0x70) and CHI (null) are NOT part of the SMS packet data. // CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
function genSp() { function genSp() {
// The packet's key version must be defined in the selected preset
// (TS 102 225 A.2 / the "keyset must exist" rule).
const ksErr = spKeysetGuard();
if (ksErr) { alert(ksErr); return; }
_genSpBuild(); _genSpBuild();
spShowSizeInfo(); spShowSizeInfo();
} }
@@ -7093,6 +7100,8 @@ function testStepRender() {
{v: 'apdu', l: t('C-APDU (built with the preset)')}, {v: 'sp', l: t('Pre-built secured packet')}], 'testStepKindChanged()')); {v: 'apdu', l: t('C-APDU (built with the preset)')}, {v: 'sp', l: t('Pre-built secured packet')}], 'testStepKindChanged()'));
if (src === 'sp') html += testFormRow('Secured packet (hex)', testFormInput('test-f-sp', p.sp, '01 00 ...')); if (src === 'sp') html += testFormRow('Secured packet (hex)', testFormInput('test-f-sp', p.sp, '01 00 ...'));
else html += testFormRow('C-APDU (hex)', testFormInput('test-f-apdu', p.apdu, '80E2900000')); else html += testFormRow('C-APDU (hex)', testFormInput('test-f-apdu', p.apdu, '80E2900000'));
html += testFormRow('Key version (kvn, optional)',
testFormInput('test-f-kvn', p.kvn === undefined ? '' : p.kvn, t('empty = the preset\'s first keyset')));
html += testFormRow('TAR override (hex, optional)', testFormInput('test-f-tar', p.tar, 'B00000')); html += testFormRow('TAR override (hex, optional)', testFormInput('test-f-tar', p.tar, 'B00000'));
html += testFormRow('SPI1 override (hex, optional)', testFormInput('test-f-spi1', p.spi1, '16')); html += testFormRow('SPI1 override (hex, optional)', testFormInput('test-f-spi1', p.spi1, '16'));
html += testFormRow('SPI2 override (hex, optional)', testFormInput('test-f-spi2', p.spi2, '01')); html += testFormRow('SPI2 override (hex, optional)', testFormInput('test-f-spi2', p.spi2, '01'));
@@ -7272,6 +7281,8 @@ function testStepCollect() {
else p.apdu = prev.apdu || ''; else p.apdu = prev.apdu || '';
p.source = src; p.source = src;
['tar', 'spi1', 'spi2'].forEach(k => { const v = hexNoMask('test-f-' + k); if (v) p[k] = v; }); ['tar', 'spi1', 'spi2'].forEach(k => { const v = hexNoMask('test-f-' + k); if (v) p[k] = v; });
const kvn = (document.getElementById('test-f-kvn') || {}).value;
if (kvn !== undefined && String(kvn).trim() !== '') p.kvn = parseInt(String(kvn).trim(), 10);
} }
else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); } else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); }
step.params = p; step.params = p;
@@ -7489,8 +7500,13 @@ async function testPollRun() {
function testWriteBackCounter() { function testWriteBackCounter() {
const st = _testRunState; const st = _testRunState;
if (!st || st.running || !st.scp80_counter) return; if (!st || st.running) return;
const next = String(st.scp80_counter).toUpperCase(); // The server persists every counter the run consumed (one per key version);
// this only keeps the local copies in step with what it reports.
const counters = (st.scp80_counters && typeof st.scp80_counters === 'object')
? st.scp80_counters : null;
const next = st.scp80_counter ? String(st.scp80_counter).toUpperCase() : '';
if (!counters && !next) return;
let idx = _testLastPresetIdx; let idx = _testLastPresetIdx;
if (idx < 0 && st.preset) { if (idx < 0 && st.preset) {
// A run started elsewhere (or observed after a page reload): the run // A run started elsewhere (or observed after a page reload): the run
@@ -7506,6 +7522,21 @@ function testWriteBackCounter() {
const preset = cards[idx]; const preset = cards[idx];
if (!preset) return; if (!preset) return;
_testLastPresetIdx = idx; _testLastPresetIdx = idx;
if (counters) {
let changed = false;
Object.keys(counters).forEach(kvn => {
const ks = spKeysetFor(preset, parseInt(kvn, 10));
const value = String(counters[kvn] || '').toUpperCase();
if (ks && value && String(ks.cntr || '').toUpperCase() !== value) {
ks.cntr = value;
changed = true;
}
});
if (!changed) return;
cardsRender();
ioStatus('test-io-status', t('SCP80 counters saved to the preset.'));
return;
}
if (String(preset.cntr || '').toUpperCase() === next) return; if (String(preset.cntr || '').toUpperCase() === next) return;
preset.cntr = next; // the server persisted it with the run (v3.8.0) preset.cntr = next; // the server persisted it with the run (v3.8.0)
cardsRender(); cardsRender();
@@ -8124,14 +8155,17 @@ function spCntrSyncPreset() {
if (idx < 0) return false; if (idx < 0) return false;
const preset = cards[idx]; const preset = cards[idx];
const value = (document.getElementById('sp-cntr').value || '').trim().toUpperCase(); const value = (document.getElementById('sp-cntr').value || '').trim().toUpperCase();
preset.cntr = value; const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
const ks = spKeysetFor(preset, kvn);
if (ks) ks.cntr = value;
cardsRender(); cardsRender();
if (preset.id) { if (preset.id && ks) {
pysimFetch('/api/presets/update', { id: preset.id, fields: { cntr: value } }) pysimFetch('/api/presets/update', { id: preset.id, fields: { keysets: spKeysetList(preset) } })
.then(res => { .then(res => {
if (res && res.error) throw new Error(res.error); if (res && res.error) throw new Error(res.error);
// the store normalises the counter (fixed 10-hex form) // the store normalises the counter (fixed 10-hex form)
if (res && res.preset) { preset.cntr = res.preset.cntr; cardsRender(); } if (res && res.preset) { cards[idx] = res.preset; cardsRender(); spKeysetSync(); }
}) })
.catch(e => ioStatus('cards-io', t('Counter not saved:') + ' ' + e.message)); .catch(e => ioStatus('cards-io', t('Counter not saved:') + ' ' + e.message));
} }
@@ -8143,7 +8177,10 @@ function spCntrSyncPreset() {
function spCntrLocalSync(value) { function spCntrLocalSync(value) {
const idx = spPresetIdx(); const idx = spPresetIdx();
if (idx < 0) return; if (idx < 0) return;
cards[idx].cntr = value; const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
const ks = spKeysetFor(cards[idx], kvn);
if (ks) ks.cntr = value;
cardsRender(); cardsRender();
} }
@@ -8205,6 +8242,8 @@ async function pysimSendOta() {
// (push/link triggers, applet OTA) - the preset sets it only when a card // (push/link triggers, applet OTA) - the preset sets it only when a card
// is picked or a chain is packed (v3.6.21). // is picked or a chain is packed (v3.6.21).
spRefreshFromPreset('sp-card-sel', true); spRefreshFromPreset('sp-card-sel', true);
const ksErr = spKeysetGuard();
if (ksErr) { alert(ksErr); return; }
const sp = document.getElementById('sp-result').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase(); const sp = document.getElementById('sp-result').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!sp) { alert('No secured packet to send. Generate a secure packet first.'); return; } if (!sp) { alert('No secured packet to send. Generate a secure packet first.'); return; }
const sendResultEl = document.getElementById('sp-send-result'); const sendResultEl = document.getElementById('sp-send-result');
@@ -8760,7 +8799,10 @@ function ramSaveCntr(cntr) {
if (el) el.value = cntr; if (el) el.value = cntr;
const selIdx = parseInt((document.getElementById('ram-card-sel') || {}).value, 10); const selIdx = parseInt((document.getElementById('ram-card-sel') || {}).value, 10);
if (!isNaN(selIdx) && cards[selIdx]) { if (!isNaN(selIdx) && cards[selIdx]) {
cards[selIdx].cntr = cntr; const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
const ks = spKeysetFor(cards[selIdx], kvn);
if (ks) ks.cntr = cntr;
cardsRender(); cardsRender();
ramRender(); ramRender();
} }
@@ -9867,6 +9909,8 @@ async function ramDeleteFromExplorer(aid, withCascade) {
// the operation so a stale form copy cannot send an already-consumed // the operation so a stale form copy cannot send an already-consumed
// counter. // counter.
spRefreshFromPreset('ram-card-sel'); spRefreshFromPreset('ram-card-sel');
const ksErr = ramKeysetGuard();
if (ksErr) { alert(ksErr); return; }
const sp = getRamSpParams(); const sp = getRamSpParams();
if (!sp.kicKey || !sp.kidKey) { if (!sp.kicKey || !sp.kidKey) {
alert(t('Select a card preset with keys first (RAM subtab → Card preset)')); alert(t('Select a card preset with keys first (RAM subtab → Card preset)'));
@@ -10478,7 +10522,16 @@ function ramGoToPreset(idx) {
switchTab('cards'); switchTab('cards');
const i = Number(idx); const i = Number(idx);
if (Number.isInteger(i) && i >= 0 && cards[i]) cardsEdit(i); if (Number.isInteger(i) && i >= 0 && cards[i]) cardsEdit(i);
const el = document.getElementById('cards-cntr'); // Focus the counter of the key version the failed operation used (the
// keyset rows are keyed by their version; the first row otherwise).
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
let row = 0;
if (Number.isInteger(i) && i >= 0 && cards[i] && kvn) {
const at = spKeysetList(cards[i]).findIndex(ks => spKeysetKvnOf(ks.kic) === kvn);
if (at >= 0) row = at;
}
const el = document.getElementById('cards-ks-' + row + '-cntr');
if (el) { if (el) {
if (el.scrollIntoView) el.scrollIntoView({ block: 'center' }); if (el.scrollIntoView) el.scrollIntoView({ block: 'center' });
el.focus(); el.focus();
@@ -10947,6 +11000,234 @@ function cardsTarValue(key, value) {
return value || defaults[key] || ''; return value || defaults[key] || '';
} }
// ===== OTA keysets (one per GlobalPlatform key version) =====
// A preset carries one keyset per key version: the b8..b5 nibble of KIc/KID
// (TS 102 225 5.1.2/A.2). The version is implicit in the bytes - the "key
// index" - and each key version has its own counter on the card ("a dedicated
// counter shall be associated to each key version", Annex A.1). A packet may
// use any key version the preset defines; '00'/'00' means no security (A.2)
// and needs no keyset.
// The key version of a KIc/KID byte, or null when it is not a hex byte.
function spKeysetKvnOf(hex) {
const s = String(hex || '').trim().toUpperCase();
if (!/^[0-9A-F]{2}$/.test(s)) return null;
return (parseInt(s, 16) >> 4) & 0x0F;
}
// The keysets of a preset (a v3.8.0 flat preset converts to a single one).
function spKeysetList(preset) {
if (!preset) return [];
if (Array.isArray(preset.keysets) && preset.keysets.length) return preset.keysets;
if (preset.kic || preset.kid) {
return [{ kic: preset.kic, kid: preset.kid, kicKey: preset.kicKey,
kidKey: preset.kidKey, cntr: preset.cntr }];
}
return [];
}
// The keyset of a preset for a key version, or null.
function spKeysetFor(preset, kvn) {
return spKeysetList(preset).find(ks => spKeysetKvnOf(ks.kic) === kvn) || null;
}
// The packet's key-version rules (pure): '' when the pair is usable, else the
// reason. With a preset, the (non-zero) key version must be defined in it -
// a packet may use any key version, but only one the preset defines.
function spKeysetCheck(preset, kicHex, kidHex) {
const kic = String(kicHex || '').trim().toUpperCase();
const kid = String(kidHex || '').trim().toUpperCase();
for (const pair of [['KIc', kic], ['KID', kid]]) {
if (pair[1] && !/^[0-9A-F]{2}$/.test(pair[1])) {
return t(pair[0]) + ' ' + t('must be one hex byte') + ': ' + pair[1];
}
}
const kicV = spKeysetKvnOf(kic), kidV = spKeysetKvnOf(kid);
if (kicV && kidV && kicV !== kidV) {
return t('KIc and KID must carry the same key version (TS 102 225 A.2)') + ': ' + kic + ' / ' + kid;
}
const kvn = kicV || kidV || 0;
if (!kvn) return ''; // '00'/'00': no security
if (preset && !spKeysetFor(preset, kvn)) {
return t('Key version') + ' ' + kvn + ' ' + t('is not defined in the preset') +
(preset.name ? ' \u2014 ' + preset.name : '');
}
return '';
}
// The <option> list of a keyset selector: the preset's keysets, plus the key
// version currently in the form when the preset does not define it.
function spKeysetOptionsHtml(preset, kicHex, kidHex) {
const kvn = spKeysetKvnOf(kicHex) || spKeysetKvnOf(kidHex) || 0;
let html = '';
let found = false;
spKeysetList(preset).forEach(ks => {
const v = spKeysetKvnOf(ks.kic);
if (v === null) return;
if (v === kvn) found = true;
html += '<option value="' + v + '"' + (v === kvn ? ' selected' : '') + '>' +
v + ' \u2014 ' + esc(ks.kic || '??') + '/' + esc(ks.kid || '??') +
' \u2014 ' + esc(t('counter')) + ' ' + esc(ks.cntr || '') + '</option>';
});
if (kvn && !found) {
html += '<option value="' + kvn + '" selected>' + kvn + ' \u2014 ' +
esc(t('not defined in the preset')) + '</option>';
}
if (!html) html = '<option value="">' + esc(t('\u2014 no keyset \u2014')) + '</option>';
return html;
}
// ===== Keyset editor (Cards form) =====
let _cardsKeysetCount = 0;
function cardsKeysetRowHtml(ks, 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 kvn = spKeysetKvnOf(ks.kic);
return '<div class="flex gap-2 items-center mb-1">' +
'<span id="cards-ks-' + i + '-kvn" class="w-10 text-xs font-mono text-center text-gray-500 dark:text-slate-400" title="' + esc(t('Key version (b8..b5 of KIc/KID)')) + '">' + (kvn === null ? '?' : kvn) + '</span>' +
'<input id="cards-ks-' + i + '-kic" class="w-12 ' + cls + ' text-center" maxlength="2" placeholder="KIc" value="' + esc(ks.kic || '') + '" oninput="cardsKeysetKvnUpdate(' + i + ')">' +
'<input id="cards-ks-' + i + '-kid" class="w-12 ' + cls + ' text-center" maxlength="2" placeholder="KID" value="' + esc(ks.kid || '') + '" oninput="cardsKeysetKvnUpdate(' + i + ')">' +
'<input id="cards-ks-' + i + '-kic-key" class="flex-1 ' + cls + '" placeholder="KIc key (hex)" value="' + esc(ks.kicKey || '') + '">' +
'<input id="cards-ks-' + i + '-kid-key" class="flex-1 ' + cls + '" placeholder="KID key (hex)" value="' + esc(ks.kidKey || '') + '">' +
'<input id="cards-ks-' + i + '-cntr" class="w-24 ' + cls + '" maxlength="10" placeholder="Counter" value="' + esc(ks.cntr || '') + '">' +
'<button type="button" onclick="cardsKeysetRemove(' + i + ')" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-red-100 dark:hover:bg-red-900/40" title="' + esc(t('Remove keyset')) + '">\u2715</button>' +
'</div>';
}
function cardsKeysetRowsRender(keysets) {
const box = document.getElementById('cards-keysets');
if (!box) return;
const list = (keysets && keysets.length)
? keysets
: [{ kic: '', kid: '', kicKey: '', kidKey: '', cntr: '' }];
_cardsKeysetCount = list.length;
box.innerHTML = list.map((ks, i) => cardsKeysetRowHtml(ks, i)).join('');
}
function cardsKeysetAdd() {
cardsKeysetRowsRender(cardsKeysetsFromForm().concat([
{ kic: '', kid: '', kicKey: '', kidKey: '', cntr: '' }]));
}
function cardsKeysetRemove(i) {
const rows = cardsKeysetsFromForm();
rows.splice(i, 1);
cardsKeysetRowsRender(rows);
}
function cardsKeysetKvnUpdate(i) {
const el = document.getElementById('cards-ks-' + i + '-kvn');
if (!el) return;
const kic = (document.getElementById('cards-ks-' + i + '-kic') || {}).value;
const kid = (document.getElementById('cards-ks-' + i + '-kid') || {}).value;
const a = spKeysetKvnOf(kic), b = spKeysetKvnOf(kid);
const bad = (a !== null && b !== null && a !== b) || a === 0 || b === 0;
el.textContent = a === null ? '?' : a;
el.classList.toggle('text-red-500', !!bad);
el.title = bad ? t('KIc and KID must carry the same key version (TS 102 225 A.2)')
: t('Key version (b8..b5 of KIc/KID)');
}
// The keyset rows as preset entries (empty rows are dropped).
function cardsKeysetsFromForm() {
const out = [];
for (let i = 0; i < _cardsKeysetCount; i++) {
const get = id => {
const el = document.getElementById('cards-ks-' + i + '-' + id);
return el ? String(el.value).trim() : '';
};
const ks = { kic: get('kic').toUpperCase(), kid: get('kid').toUpperCase(),
kicKey: get('kic-key').toUpperCase(), kidKey: get('kid-key').toUpperCase(),
cntr: get('cntr').toUpperCase() };
if (!ks.kic && !ks.kid && !ks.kicKey && !ks.kidKey) continue;
out.push(ks);
}
return out;
}
// ===== Keyset selectors (SCP80 / RAM) =====
// The selector follows the KIc/KID bytes: a keyset of the selected preset that
// matches their key version is selected; a version the preset does not define
// shows as "not defined" (and the packet actions refuse it).
function spKeysetSync() {
const spIdx = spPresetIdx();
const spPreset = spIdx >= 0 ? cards[spIdx] : null;
const kic = (document.getElementById('sp-kic-hex') || {}).value;
const kid = (document.getElementById('sp-kid-hex') || {}).value;
const spSel = document.getElementById('sp-keyset-sel');
if (spSel) spSel.innerHTML = spKeysetOptionsHtml(spPreset, kic, kid);
const ramSel = document.getElementById('ram-keyset-sel');
if (ramSel) {
const ramIdx = ramPresetIdx();
const ramPreset = ramIdx >= 0 ? cards[ramIdx] : null;
ramSel.innerHTML = spKeysetOptionsHtml(ramPreset, kic, kid);
}
}
function spKeysetApply(ks) {
if (!ks) return;
const set = (id, value) => {
const el = document.getElementById(id);
if (el) el.value = value;
};
const kic = parseInt(ks.kic, 16), kid = parseInt(ks.kid, 16);
set('sp-kic-idx', ((kic >> 4) & 0x0F).toString(16).toUpperCase());
set('sp-kic-alg', (kic & 0x0F).toString(16).padStart(2, '0').toUpperCase());
set('sp-kid-idx', ((kid >> 4) & 0x0F).toString(16).toUpperCase());
set('sp-kid-alg', (kid & 0x0F).toString(16).padStart(2, '0').toUpperCase());
updateSpKic();
updateSpKid();
set('sp-kic-key', ks.kicKey || '');
set('sp-kid-key', ks.kidKey || '');
set('sp-cntr', ks.cntr || '');
spInvalidate();
updateSp();
}
function spKeysetChanged() {
const sel = document.getElementById('sp-keyset-sel');
if (!sel) return;
const kvn = parseInt(sel.value, 10);
const idx = spPresetIdx();
const ks = (idx >= 0 && !isNaN(kvn)) ? spKeysetFor(cards[idx], kvn) : null;
if (ks) spKeysetApply(ks);
spKeysetSync();
}
function ramKeysetChanged() {
const sel = document.getElementById('ram-keyset-sel');
if (!sel) return;
const kvn = parseInt(sel.value, 10);
const idx = ramPresetIdx();
const ks = (idx >= 0 && !isNaN(kvn)) ? spKeysetFor(cards[idx], kvn) : null;
if (ks) spKeysetApply(ks);
spKeysetSync();
}
// The RAM view's preset index.
function ramPresetIdx() {
const sel = document.getElementById('ram-card-sel');
const idx = sel ? parseInt(sel.value, 10) : NaN;
return (!isNaN(idx) && cards[idx]) ? idx : -1;
}
// '' when the SCP80 form may build a packet, else the reason. With a preset
// selected the key version must be one of its keysets; without one only the
// byte rules apply (hand-entered keys stay possible, a workbench flow).
function spKeysetGuard() {
const idx = spPresetIdx();
const kic = (document.getElementById('sp-kic-hex') || {}).value;
const kid = (document.getElementById('sp-kid-hex') || {}).value;
return spKeysetCheck(idx >= 0 ? cards[idx] : null, kic, kid);
}
function ramKeysetGuard() {
const idx = ramPresetIdx();
const kic = (document.getElementById('sp-kic-hex') || {}).value;
const kid = (document.getElementById('sp-kid-hex') || {}).value;
return spKeysetCheck(idx >= 0 ? cards[idx] : null, kic, kid);
}
function cardsFormValues() { function cardsFormValues() {
const val = id => { const val = id => {
const el = document.getElementById('cards-' + id); const el = document.getElementById('cards-' + id);
@@ -10956,18 +11237,14 @@ function cardsFormValues() {
name: val('name'), name: val('name'),
iccid: val('iccid'), iccid: val('iccid'),
adm: val('adm').replace(/\s/g, '').toUpperCase(), adm: val('adm').replace(/\s/g, '').toUpperCase(),
kic: val('kic'),
kid: val('kid'),
spi1: val('spi1'), spi1: val('spi1'),
spi2: val('spi2'), spi2: val('spi2'),
tar: val('tar'), tar: val('tar'),
uiccTar: val('uicc-tar'), uiccTar: val('uicc-tar'),
usimTar: val('usim-tar'), usimTar: val('usim-tar'),
cntr: val('cntr'),
kicKey: val('kic-key'),
kidKey: val('kid-key'),
pskIdentity: val('psk-id'), pskIdentity: val('psk-id'),
pskKey: val('psk-key').replace(/\s/g, '').toUpperCase(), pskKey: val('psk-key').replace(/\s/g, '').toUpperCase(),
keysets: cardsKeysetsFromForm(),
}; };
} }
@@ -10979,8 +11256,7 @@ function cardsSetFormMode() {
} }
function cardsClearForm() { function cardsClearForm() {
['name','iccid','adm','kic','kid','spi1','spi2','cntr','kic-key','kid-key', ['name','iccid','adm','spi1','spi2','psk-id','psk-key'].forEach(id => {
'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 = '';
}); });
@@ -10989,6 +11265,7 @@ function cardsClearForm() {
const el = document.getElementById('cards-' + id); const el = document.getElementById('cards-' + id);
if (el) el.value = cardsTarValue(key, ''); if (el) el.value = cardsTarValue(key, '');
}); });
cardsKeysetRowsRender([]);
} }
async function cardsAdd() { async function cardsAdd() {
@@ -10998,8 +11275,27 @@ async function cardsAdd() {
v.uiccTar = cardsTarValue('uiccTar', v.uiccTar); v.uiccTar = cardsTarValue('uiccTar', v.uiccTar);
v.usimTar = cardsTarValue('usimTar', v.usimTar); v.usimTar = cardsTarValue('usimTar', v.usimTar);
if (!v.name) { alert(t('Name is required')); return; } if (!v.name) { alert(t('Name is required')); return; }
if (!v.kic || !v.kid) { alert('KIc and KID are required'); return; } if (!v.keysets.length) {
if (!v.kicKey || !v.kidKey) { alert('KIc key and KID key are required'); return; } alert(t('Define at least one keyset (KIc/KID and their keys)'));
return;
}
for (const ks of v.keysets) {
if (!ks.kic || !ks.kid || !ks.kicKey || !ks.kidKey) {
alert(t('Each keyset needs KIc, KID and both keys'));
return;
}
const ksErr = spKeysetCheck(null, ks.kic, ks.kid);
if (ksErr) { alert(ksErr); return; }
if (!spKeysetKvnOf(ks.kic) || !spKeysetKvnOf(ks.kid)) {
alert(t('Key version 00 means "no security" and is not a preset keyset'));
return;
}
}
const kvns = v.keysets.map(ks => spKeysetKvnOf(ks.kic));
if (new Set(kvns).size !== kvns.length) {
alert(t('Duplicate key version in the keysets'));
return;
}
if ((v.pskKey && !v.pskIdentity) || (!v.pskKey && v.pskIdentity)) { if ((v.pskKey && !v.pskIdentity) || (!v.pskKey && v.pskIdentity)) {
alert(t('PSK identity and PSK key must be set together')); alert(t('PSK identity and PSK key must be set together'));
return; return;
@@ -11035,13 +11331,13 @@ 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('kic', c.kic); set('kid', c.kid); set('name', c.name); set('iccid', c.iccid); set('adm', c.adm);
set('spi1', c.spi1); set('spi2', c.spi2); set('cntr', c.cntr); set('spi1', c.spi1); set('spi2', c.spi2);
set('tar', cardsTarValue('tar', c.tar)); set('tar', cardsTarValue('tar', c.tar));
set('uicc-tar', cardsTarValue('uiccTar', c.uiccTar)); set('uicc-tar', cardsTarValue('uiccTar', c.uiccTar));
set('usim-tar', cardsTarValue('usimTar', c.usimTar)); set('usim-tar', cardsTarValue('usimTar', c.usimTar));
set('kic-key', c.kicKey); set('kid-key', c.kidKey);
set('psk-id', c.pskIdentity); set('psk-key', c.pskKey); set('psk-id', c.pskIdentity); set('psk-key', c.pskKey);
cardsKeysetRowsRender(spKeysetList(c));
cardsSetFormMode(); cardsSetFormMode();
document.getElementById('cards-name').focus(); document.getElementById('cards-name').focus();
} }
@@ -11092,12 +11388,15 @@ function cardsRender() {
html += '<tr class="border-b border-gray-100 dark:border-slate-700">'; html += '<tr class="border-b border-gray-100 dark:border-slate-700">';
html += '<td class="py-1 px-2 font-medium">' + esc(c.name) + '</td>'; html += '<td class="py-1 px-2 font-medium">' + esc(c.name) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.iccid) + '</td>'; html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.iccid) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.kic) + '</td>'; const ksText = spKeysetList(c).map(ks => {
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.kid) + '</td>'; const kvn = spKeysetKvnOf(ks.kic);
return (kvn === null ? '?' : kvn) + ': ' + esc(ks.kic || '??') + '/' + esc(ks.kid || '??') +
' \u2014 ' + esc(ks.cntr || '');
}).join(' \u00b7 ') || '\u2014';
html += '<td class="py-1 px-2 font-mono text-xs">' + ksText + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi1) + '</td>'; html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi1) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi2) + '</td>'; html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi2) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs" title="ISD / UICC RFM / ADF RFM">' + esc(cardsTarValue('tar', c.tar)) + ' ' + esc(cardsTarValue('uiccTar', c.uiccTar)) + ' ' + esc(cardsTarValue('usimTar', c.usimTar)) + '</td>'; html += '<td class="py-1 px-2 font-mono text-xs" title="ISD / UICC RFM / ADF RFM">' + esc(cardsTarValue('tar', c.tar)) + ' ' + esc(cardsTarValue('uiccTar', c.uiccTar)) + ' ' + esc(cardsTarValue('usimTar', c.usimTar)) + '</td>';
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.cntr) + '</td>';
html += '<td class="py-1 px-2 text-center">' + ota + '</td>'; html += '<td class="py-1 px-2 text-center">' + ota + '</td>';
html += '<td class="py-1 px-2 whitespace-nowrap"><button onclick="cardsEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + t('Edit') + '</button> ' html += '<td class="py-1 px-2 whitespace-nowrap"><button onclick="cardsEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + t('Edit') + '</button> '
+ '<button onclick="cardsRemove(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + t('Remove') + '</button></td>'; + '<button onclick="cardsRemove(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + t('Remove') + '</button></td>';
@@ -11224,26 +11523,36 @@ function cardsApplyFields(idx, keepTar) {
const smSel = document.getElementById('sp-spi2-sm'); const smSel = document.getElementById('sp-spi2-sm');
if (smSel) smSel.value = String((spi2Val >> 5) & 1); if (smSel) smSel.value = String((spi2Val >> 5) & 1);
document.getElementById('sp-spi2-hex').value = c.spi2; document.getElementById('sp-spi2-hex').value = c.spi2;
const kicByte = parseInt(c.kic, 16); // The keyset: keep the key version the form carries when this preset
document.getElementById('sp-kic-idx').value = ((kicByte >> 4) & 0x0F).toString(16).toUpperCase(); // defines it, otherwise start from the preset's first keyset.
document.getElementById('sp-kic-alg').value = (kicByte & 0x0F).toString(16).padStart(2, '0'); const kicNow = (document.getElementById('sp-kic-hex') || {}).value;
updateSpKic(); const kidNow = (document.getElementById('sp-kid-hex') || {}).value;
const kidByte = parseInt(c.kid, 16); const kvnNow = spKeysetKvnOf(kicNow) || spKeysetKvnOf(kidNow) || 0;
document.getElementById('sp-kid-idx').value = ((kidByte >> 4) & 0x0F).toString(16).toUpperCase(); const ks = (kvnNow && spKeysetFor(c, kvnNow)) || spKeysetList(c)[0] || null;
document.getElementById('sp-kid-alg').value = (kidByte & 0x0F).toString(16).padStart(2, '0'); if (ks) {
updateSpKid(); const kicByte = parseInt(ks.kic, 16);
document.getElementById('sp-kic-idx').value = ((kicByte >> 4) & 0x0F).toString(16).toUpperCase();
document.getElementById('sp-kic-alg').value = (kicByte & 0x0F).toString(16).padStart(2, '0');
updateSpKic();
const kidByte = parseInt(ks.kid, 16);
document.getElementById('sp-kid-idx').value = ((kidByte >> 4) & 0x0F).toString(16).toUpperCase();
document.getElementById('sp-kid-alg').value = (kidByte & 0x0F).toString(16).padStart(2, '0');
updateSpKid();
document.getElementById('sp-cntr').value = ks.cntr || '';
document.getElementById('sp-kic-key').value = ks.kicKey || '';
document.getElementById('sp-kid-key').value = ks.kidKey || '';
}
if (!keepTar) document.getElementById('sp-tar').value = cardsTarValue(_spTarKey, c[_spTarKey]); if (!keepTar) document.getElementById('sp-tar').value = cardsTarValue(_spTarKey, c[_spTarKey]);
document.getElementById('sp-cntr').value = c.cntr;
document.getElementById('sp-kic-key').value = c.kicKey;
document.getElementById('sp-kid-key').value = c.kidKey;
spInvalidate(); spInvalidate();
updateSp(); updateSp();
spKeysetSync();
return true; return true;
} }
function cardsApply(idx, keepTar) { function cardsApply(idx, keepTar) {
if (!cardsApplyFields(idx, keepTar)) return; if (!cardsApplyFields(idx, keepTar)) return;
genSp(); _genSpBuild();
spShowSizeInfo();
} }
// SCP80/RAM operations re-read the selected preset before starting: the // SCP80/RAM operations re-read the selected preset before starting: the
@@ -19096,6 +19405,30 @@ const LANG_RU = {
'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: слушатель выбирает ключ по PSK identity, которую карта присылает в TLS-рукопожатии. Карты без PSK слушателем игнорируются.', '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: слушатель выбирает ключ по PSK identity, которую карта присылает в TLS-рукопожатии. Карты без PSK слушателем игнорируются.',
'Card with this ICCID already exists': 'Карта с таким ICCID уже есть в списке', 'Card with this ICCID already exists': 'Карта с таким ICCID уже есть в списке',
'Card equipped but its ICCID is not readable': 'Карта подключена, но её ICCID не читается', 'Card equipped but its ICCID is not readable': 'Карта подключена, но её ICCID не читается',
'OTA keysets — one per key version, each with its own counter': 'Наборы ключей OTA — по одному на версию ключа, у каждой свой счётчик',
'Add keyset': 'Добавить набор ключей',
'Remove keyset': 'Удалить набор ключей',
'Key version (keyset)': 'Версия ключа (набор ключей)',
'Key version': 'Версия ключа',
'Key version (b8..b5 of KIc/KID)': 'Версия ключа (биты b8..b5 KIc/KID)',
'Keysets (key version: KIc/KID — counter)': 'Наборы ключей (версия: KIc/KID — счётчик)',
'must be one hex byte': 'должен быть одним hex-байтом',
'KIc and KID must carry the same key version (TS 102 225 A.2)': 'KIc и KID должны содержать одну и ту же версию ключа (TS 102 225 A.2)',
'is not defined in the preset': 'не определена в предустановке',
'not defined in the preset': 'не определена в предустановке',
'— no keyset —': '— нет набора ключей —',
'counter': 'счётчик',
'Define at least one keyset (KIc/KID and their keys)': 'Определите хотя бы один набор ключей (KIc/KID и их ключи)',
'Each keyset needs KIc, KID and both keys': 'Каждому набору нужны KIc, KID и оба ключа',
'Key version 00 means "no security" and is not a preset keyset': 'Версия ключа 00 означает «без защиты» и не является набором ключей предустановки',
'Duplicate key version in the keysets': 'Дублирующаяся версия ключа в наборах',
'Select a card preset - its keysets define the key versions': 'Выберите предустановку карты — её наборы ключей определяют версии ключей',
'SCP80 counters saved to the preset.': 'Счётчики SCP80 сохранены в предустановке.',
'Key version (kvn, optional)': 'Версия ключа (kvn, опционально)',
"empty = the preset's first keyset": 'пусто = первый набор ключей предустановки',
'Keysets (key version: KIc/KID — counter)': 'Наборы ключей (версия: KIc/KID — счётчик)',
'KIc and KID must carry the same key version (their high nibble) in the range 01-0F — TS 102 225 A.2; each key version has its own counter on the card (Annex A.1).': 'KIc и KID должны содержать одну и ту же версию ключа (старший ниббл) в диапазоне 01-0F — TS 102 225 A.2; у каждой версии ключа свой счётчик на карте (Annex A.1).',
'Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries one keyset per key version, each with its own counter.': 'Предустановки карт хранятся на сервере. Кнопки экспорта/импорта JSON — под списком карт; предустановка содержит один набор ключей на версию, у каждой свой счётчик.',
'Save error': 'Ошибка сохранения', 'Save error': 'Ошибка сохранения',
'Delete error': 'Ошибка удаления', 'Delete error': 'Ошибка удаления',
'Counter not saved:': 'Счётчик не сохранён:', 'Counter not saved:': 'Счётчик не сохранён:',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v334'; const CACHE = 'simple-v335';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+16 -1
View File
@@ -23,10 +23,22 @@ function extractFunc(src, name) {
let code = ''; let code = '';
for (const fn of ['cardsTarValue', 'cardsApplyFields', 'cardsApply', for (const fn of ['cardsTarValue', 'cardsApplyFields', 'cardsApply',
'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr']) { 'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr', 'spPresetIdx',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard',
'ramKeysetGuard',
]) {
code += extractFunc(html, fn) + '\n'; code += extractFunc(html, fn) + '\n';
} }
code = 'var _cardsKeysetCount = 0;\n' + code;
code = 'var _genSpBuildStub = function() {};\n' + code;
eval(code); eval(code);
globalThis._genSpBuild = () => {};
globalThis.spShowSizeInfo = () => {};
globalThis.spKeysetSync = globalThis.spKeysetSync || (() => {});
// The SP form is a working copy: the preset is the source of truth, and // The SP form is a working copy: the preset is the source of truth, and
// every SCP80/RAM operation re-reads it before starting (v3.6.3). The plain // every SCP80/RAM operation re-reads it before starting (v3.6.3). The plain
@@ -49,6 +61,9 @@ function fakeEnv(selValue, selId, presetCntr) {
globalThis.updateSp = () => {}; globalThis.updateSp = () => {};
let gen = 0; let gen = 0;
globalThis.genSp = () => { gen++; }; globalThis.genSp = () => { gen++; };
// cardsApply refreshes the preview directly (the guarded genSp() is the
// Generate button's path), so the counter stub is _genSpBuild
globalThis._genSpBuild = () => { gen++; };
return { els, gen: () => gen }; return { els, gen: () => gen };
} }
+47 -19
View File
@@ -28,21 +28,42 @@ for (const fn of ['cardsTarValue', 'cardsFormValues', 'cardsClearForm', 'cardsEd
'spPresetIdx', 'spCntrSyncPreset', 'spCntrLocalSync', 'ramSaveCntr', 'spPresetIdx', 'spCntrSyncPreset', 'spCntrLocalSync', 'ramSaveCntr',
'cardsApplyFields', 'cardsApply', 'ramApplyCard', 'cardsApplyFields', 'cardsApply', 'ramApplyCard',
'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent', 'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent',
'spTarKeyForPack', 'spPresetTar', 'packToSp']) { 'spTarKeyForPack', 'spPresetTar', 'packToSp',
'esc', 'escHtml',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard', 'ramKeysetGuard']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n'; code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
} }
code = 'var _cardsKeysetCount = 0;\n' + code;
eval(code); eval(code);
const CARD_IDS = ['cards-name','cards-iccid','cards-adm','cards-kic','cards-kid', const CARD_IDS = ['cards-name','cards-iccid','cards-adm',
'cards-spi1','cards-spi2','cards-tar','cards-uicc-tar','cards-usim-tar', 'cards-spi1','cards-spi2','cards-tar','cards-uicc-tar','cards-usim-tar',
'cards-cntr','cards-kic-key','cards-kid-key','cards-psk-id','cards-psk-key', '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)
'cards-ks-0-kvn','cards-ks-0-kic','cards-ks-0-kid','cards-ks-0-kic-key',
'cards-ks-0-kid-key','cards-ks-0-cntr',
'cards-ks-1-kvn','cards-ks-1-kic','cards-ks-1-kid','cards-ks-1-kic-key',
'cards-ks-1-kid-key','cards-ks-1-cntr'];
const SP_IDS = ['sp-card-sel','sp-tar','sp-spi1','sp-spi2','sp-spi2-sm','sp-spi2-hex', const SP_IDS = ['sp-card-sel','sp-tar','sp-spi1','sp-spi2','sp-spi2-sm','sp-spi2-hex',
'sp-kic-idx','sp-kic-alg','sp-kic-hex','sp-kid-idx','sp-kid-alg','sp-kid-hex', 'sp-kic-idx','sp-kic-alg','sp-kic-hex','sp-kid-idx','sp-kid-alg','sp-kid-hex',
'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex']; 'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex',
'sp-keyset-sel','ram-keyset-sel'];
const CHAIN_IDS = ['chain-sim-preview','chain-usim-preview','chain-ram-preview', const CHAIN_IDS = ['chain-sim-preview','chain-usim-preview','chain-ram-preview',
'ber-result','hota-preview']; 'ber-result','hota-preview'];
function ksRow(els, i, ks) {
for (const field of ['kic', 'kid', 'kic-key', 'kid-key', 'cntr']) {
const id = 'cards-ks-' + i + '-' + field;
if (!els[id]) els[id] = fakeEl();
els[id].value = ks[field === 'kic-key' ? 'kicKey' : (field === 'kid-key' ? 'kidKey' : field)] || '';
}
}
function fakeEl() { function fakeEl() {
return { return {
value: '', value: '',
@@ -63,8 +84,11 @@ function setup() {
globalThis.cards = []; globalThis.cards = [];
globalThis._cardsEditIdx = null; globalThis._cardsEditIdx = null;
globalThis._ramCardIdx = null; globalThis._ramCardIdx = null;
globalThis._cardsKeysetCount = 1;
globalThis._spTarKey = 'tar'; globalThis._spTarKey = 'tar';
globalThis.t = s => s; globalThis.t = s => s;
globalThis.esc = s => s;
globalThis.escHtml = s => s;
globalThis.alert = () => {}; globalThis.alert = () => {};
globalThis.cardsSave = () => {}; globalThis.cardsSave = () => {};
globalThis.cardsRender = () => {}; globalThis.cardsRender = () => {};
@@ -81,7 +105,10 @@ function setup() {
globalThis.spInvalidate = () => {}; globalThis.spInvalidate = () => {};
globalThis.updateSp = () => {}; globalThis.updateSp = () => {};
globalThis.genSp = () => {}; globalThis.genSp = () => {};
globalThis._genSpBuild = () => {};
globalThis.spShowSizeInfo = () => {};
globalThis.ramRender = () => {}; globalThis.ramRender = () => {};
globalThis.switchTab = () => {};
// the server store is the source of truth (v3.8.0): the Cards tab talks to // the server store is the source of truth (v3.8.0): the Cards tab talks to
// /api/presets* and reloads through cardsFetch() // /api/presets* and reloads through cardsFetch()
apiCalls = []; apiCalls = [];
@@ -174,10 +201,8 @@ test('the form values carry the ADM and per-target TAR fields', () => {
test('saving a preset posts the form with spec-default TARs', async () => { test('saving a preset posts the form with spec-default TARs', async () => {
const els = setup(); const els = setup();
els['cards-name'].value = 'New card'; els['cards-name'].value = 'New card';
els['cards-kic'].value = '15'; ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
els['cards-kid'].value = '15'; globalThis._cardsKeysetCount = 1;
els['cards-kic-key'].value = 'AA';
els['cards-kid-key'].value = 'BB';
els['cards-tar'].value = ''; els['cards-tar'].value = '';
els['cards-uicc-tar'].value = ''; els['cards-uicc-tar'].value = '';
els['cards-usim-tar'].value = ''; els['cards-usim-tar'].value = '';
@@ -186,6 +211,8 @@ test('saving a preset posts the form with spec-default TARs', async () => {
assert.strictEqual(apiCalls.length, 1, JSON.stringify(apiCalls)); assert.strictEqual(apiCalls.length, 1, JSON.stringify(apiCalls));
assert.strictEqual(apiCalls[0].path, '/api/presets'); assert.strictEqual(apiCalls[0].path, '/api/presets');
assert.strictEqual(apiCalls[0].body.name, 'New card'); assert.strictEqual(apiCalls[0].body.name, 'New card');
assert.deepStrictEqual(apiCalls[0].body.keysets, [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '' }]);
assert.strictEqual(apiCalls[0].body.tar, '000000'); assert.strictEqual(apiCalls[0].body.tar, '000000');
assert.strictEqual(apiCalls[0].body.uiccTar, 'B00000'); assert.strictEqual(apiCalls[0].body.uiccTar, 'B00000');
assert.strictEqual(apiCalls[0].body.usimTar, 'B00001'); assert.strictEqual(apiCalls[0].body.usimTar, 'B00001');
@@ -194,14 +221,12 @@ test('saving a preset posts the form with spec-default TARs', async () => {
test('editing a preset updates it by its store id', async () => { test('editing a preset updates it by its store id', async () => {
const els = setup(); const els = setup();
globalThis.cards = [{ id: 'abc123', name: 'Old', kic: '15', kid: '15', globalThis.cards = [{ id: 'abc123', name: 'Old', keysets: [
kicKey: 'AA', kidKey: 'BB' }]; { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
globalThis._cardsEditIdx = 0; globalThis._cardsEditIdx = 0;
els['cards-name'].value = 'Renamed'; els['cards-name'].value = 'Renamed';
els['cards-kic'].value = '15'; ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
els['cards-kid'].value = '15'; globalThis._cardsKeysetCount = 1;
els['cards-kic-key'].value = 'AA';
els['cards-kid-key'].value = 'BB';
await cardsAdd(); await cardsAdd();
assert.strictEqual(apiCalls[0].path, '/api/presets/update'); assert.strictEqual(apiCalls[0].path, '/api/presets/update');
assert.strictEqual(apiCalls[0].body.id, 'abc123'); assert.strictEqual(apiCalls[0].body.id, 'abc123');
@@ -235,19 +260,22 @@ test('import posts the entries to the server store', async () => {
test('the SCP80 counter edit is written to the store, a reported counter only locally', () => { test('the SCP80 counter edit is written to the store, a reported counter only locally', () => {
const els = setup(); const els = setup();
globalThis.cards = [{ id: 'abc123', name: 'C', cntr: '0000000001' }]; globalThis.cards = [{ id: 'abc123', name: 'C', keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
els['sp-card-sel'].value = '0'; els['sp-card-sel'].value = '0';
els['sp-kic-hex'].value = '15';
els['sp-kid-hex'].value = '15';
els['sp-cntr'].value = '0000000009'; els['sp-cntr'].value = '0000000009';
assert.strictEqual(spCntrSyncPreset(), true); assert.strictEqual(spCntrSyncPreset(), true);
assert.strictEqual(cards[0].cntr, '0000000009'); assert.strictEqual(cards[0].keysets[0].cntr, '0000000009');
assert.strictEqual(apiCalls.length, 1); assert.strictEqual(apiCalls.length, 1);
assert.strictEqual(apiCalls[0].path, '/api/presets/update'); assert.strictEqual(apiCalls[0].path, '/api/presets/update');
assert.strictEqual(apiCalls[0].body.id, 'abc123'); assert.strictEqual(apiCalls[0].body.id, 'abc123');
assert.deepStrictEqual(apiCalls[0].body.fields, { cntr: '0000000009' }); assert.strictEqual(apiCalls[0].body.fields.keysets[0].cntr, '0000000009');
// a counter the server already persisted: display only, no second write // a counter the server already persisted: display only, no second write
apiCalls = []; apiCalls = [];
spCntrLocalSync('000000000A'); spCntrLocalSync('000000000A');
assert.strictEqual(cards[0].cntr, '000000000A'); assert.strictEqual(cards[0].keysets[0].cntr, '000000000A');
assert.strictEqual(apiCalls.length, 0); assert.strictEqual(apiCalls.length, 0);
// no preset selected: nothing to write // no preset selected: nothing to write
els['sp-card-sel'].value = ''; els['sp-card-sel'].value = '';
+4 -4
View File
@@ -135,8 +135,8 @@ test('the card form groups the SCP80 and SCP81 fields into labelled fieldsets',
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(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');
assert.match(scp80, /id="cards-kic"/); assert.match(scp80, /id="cards-keysets"/);
assert.match(scp80, /id="cards-kid-key"/); assert.match(scp80, /onclick="cardsKeysetAdd\(\)"/);
assert.match(scp80, /id="cards-tar"[^>]*value="000000"/); assert.match(scp80, /id="cards-tar"[^>]*value="000000"/);
assert.match(scp80, /id="cards-uicc-tar"[^>]*value="B00000"/); assert.match(scp80, /id="cards-uicc-tar"[^>]*value="B00000"/);
assert.match(scp80, /id="cards-usim-tar"[^>]*value="B00001"/); assert.match(scp80, /id="cards-usim-tar"[^>]*value="B00001"/);
@@ -144,8 +144,8 @@ test('the card form groups the SCP80 and SCP81 fields into labelled fieldsets',
assert.match(scp80, />ISD TAR<\/label>/); assert.match(scp80, />ISD TAR<\/label>/);
assert.match(scp80, />UICC RFM TAR<\/label>/); assert.match(scp80, />UICC RFM TAR<\/label>/);
assert.match(scp80, />ADF RFM TAR<\/label>/); assert.match(scp80, />ADF RFM TAR<\/label>/);
assert.ok(scp80.indexOf('id="cards-cntr"') < scp80.indexOf('id="cards-tar"'), assert.ok(scp80.indexOf('id="cards-spi1"') < scp80.indexOf('id="cards-tar"'),
'TAR fields must follow the Counter field'); 'TAR fields must follow the SPI fields');
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
+122
View File
@@ -0,0 +1,122 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = '';
for (const fn of ['spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'esc', 'escHtml']) {
code += extractFunc(html, fn) + '\n';
}
code += 'function t(s){return s;}\n';
eval(code);
const PRESET = {
name: 'Card A',
keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' },
{ kic: '29', kid: '29', kicKey: 'CC', kidKey: 'DD', cntr: '0000000005' },
],
};
test('spKeysetKvnOf reads the key version from the high nibble', () => {
assert.strictEqual(spKeysetKvnOf('15'), 1);
assert.strictEqual(spKeysetKvnOf('29'), 2);
assert.strictEqual(spKeysetKvnOf('3A'), 3);
assert.strictEqual(spKeysetKvnOf('05'), 0);
assert.strictEqual(spKeysetKvnOf('ff'), 15);
// not a byte: no key version
assert.strictEqual(spKeysetKvnOf(''), null);
assert.strictEqual(spKeysetKvnOf('5'), null);
assert.strictEqual(spKeysetKvnOf('ZZ'), null);
assert.strictEqual(spKeysetKvnOf(null), null);
});
test('spKeysetList converts a v3.8.0 flat preset into one keyset', () => {
assert.deepStrictEqual(spKeysetList({ kic: '15', kid: '15', kicKey: 'A', kidKey: 'B',
cntr: '0000000001' }), [{ kic: '15', kid: '15', kicKey: 'A', kidKey: 'B',
cntr: '0000000001' }]);
assert.deepStrictEqual(spKeysetList(PRESET).length, 2);
assert.deepStrictEqual(spKeysetList(null), []);
assert.deepStrictEqual(spKeysetList({}), []);
});
test('spKeysetFor finds a keyset by key version', () => {
assert.strictEqual(spKeysetFor(PRESET, 1).cntr, '0000000001');
assert.strictEqual(spKeysetFor(PRESET, 2).cntr, '0000000005');
assert.strictEqual(spKeysetFor(PRESET, 3), null);
assert.strictEqual(spKeysetFor(null, 1), null);
});
test('spKeysetCheck enforces the key-version rules', () => {
// a defined key version passes
assert.strictEqual(spKeysetCheck(PRESET, '15', '15'), '');
assert.strictEqual(spKeysetCheck(PRESET, '29', '29'), '');
// '00'/'00' means no security: no keyset needed (TS 102 225 A.2)
assert.strictEqual(spKeysetCheck(PRESET, '00', '00'), '');
// ... and one zero byte with a defined version passes too
assert.strictEqual(spKeysetCheck(PRESET, '15', '00'), '');
// KIc/KID versions must agree when both are non-zero (A.2)
assert.match(spKeysetCheck(PRESET, '15', '25'), /same key version/);
// the key version must be defined in the preset
assert.match(spKeysetCheck(PRESET, '35', '35'), /3 .*not defined/);
// one zero byte: the other byte's version applies (keyset 2 is defined)
assert.strictEqual(spKeysetCheck(PRESET, '00', '25'), '');
assert.match(spKeysetCheck(PRESET, '00', '35'), /3 .*not defined/);
// without a preset only the byte rules apply (hand-entered keys)
assert.strictEqual(spKeysetCheck(null, '35', '35'), '');
assert.match(spKeysetCheck(null, '15', '25'), /15 \/ 25/);
// malformed bytes are named
assert.match(spKeysetCheck(PRESET, '5', '15'), /KIc/);
assert.match(spKeysetCheck(PRESET, '15', 'ZZ'), /KID/);
});
test('spKeysetOptionsHtml lists the keysets and marks an undefined version', () => {
const html1 = spKeysetOptionsHtml(PRESET, '15', '15');
assert.match(html1, /value="1" selected/);
assert.match(html1, /value="2"/);
assert.ok(html1.indexOf('0000000005') > 0, 'the counters are shown');
// a version the preset does not define is offered but marked
const html2 = spKeysetOptionsHtml(PRESET, '35', '35');
assert.match(html2, /value="3" selected/);
assert.match(html2, /not defined in the preset/);
// a preset without keysets: the form's version shows as undefined
const html3 = spKeysetOptionsHtml({ keysets: [] }, '15', '15');
assert.match(html3, /not defined in the preset/);
// no key version in the form and no keysets at all
assert.match(spKeysetOptionsHtml({}, '', ''), /no keyset/);
});
test('the Cards form wires the keyset editor', () => {
assert.match(html, /id="cards-keysets"/);
assert.match(html, /onclick="cardsKeysetAdd\(\)"/);
assert.match(html, /oninput="cardsKeysetKvnUpdate\(/);
assert.match(html, /data-l10n="Add keyset"/);
// the keyset rows are built with the KIc/KID/key/counter inputs
assert.match(html, /cards-ks-' \+ i \+ '-kic-key/);
assert.match(html, /cards-ks-' \+ i \+ '-cntr/);
// the selectors exist in both SCP80 views
assert.match(html, /id="sp-keyset-sel" onchange="spKeysetChanged\(\)"/);
assert.match(html, /id="ram-keyset-sel" onchange="ramKeysetChanged\(\)"/);
// the packet actions run the guard
assert.ok(html.includes('const ksErr = spKeysetGuard();'));
assert.ok(html.includes('const ksErr = ramKeysetGuard();'));
});
+4 -1
View File
@@ -27,6 +27,9 @@ const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamB
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries',
'ramExpandedReport', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramElfAppletCandidate', 'ramExpandedReport', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramElfAppletCandidate',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow', 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard', 'ramKeysetGuard',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText', 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText',
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml']; 'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
let code = ''; let code = '';
@@ -806,7 +809,7 @@ test('every SCP80/RAM flow checks the low counter and stops', () => {
const go = extractFunc(html, 'ramGoToPreset'); const go = extractFunc(html, 'ramGoToPreset');
assert.ok(go.includes("switchTab('cards')"), go); assert.ok(go.includes("switchTab('cards')"), go);
assert.ok(go.includes('cardsEdit('), go); assert.ok(go.includes('cardsEdit('), go);
assert.ok(go.includes("getElementById('cards-cntr')"), go); assert.ok(go.includes("getElementById('cards-ks-' + row + '-cntr')"), go);
}); });
test('the Explore detects the expanded query form and reports it', () => { test('the Explore detects the expanded query form and reports it', () => {
+21 -10
View File
@@ -31,9 +31,16 @@ for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress', 'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
'ramHideProgress', 'ramDeleteFromExplorer', 'ramHideProgress', 'ramDeleteFromExplorer',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow', 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow',
'escHtml', 'esc']) { 'escHtml', 'esc',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard',
'ramKeysetGuard',
]) {
code += extractFunc(html, fn) + '\n'; code += extractFunc(html, fn) + '\n';
} }
code = 'var _cardsKeysetCount = 0;\n' + code;
eval(code); eval(code);
function fakeEnv(por, success) { function fakeEnv(por, success) {
@@ -49,8 +56,11 @@ function fakeEnv(por, success) {
els['sp-kic-key'].value = 'AA'; els['sp-kic-key'].value = 'AA';
els['sp-kid-key'].value = 'BB'; els['sp-kid-key'].value = 'BB';
els['sp-spi2-hex'].value = '01'; els['sp-spi2-hex'].value = '01';
els['sp-kic-hex'].value = '15';
els['sp-kid-hex'].value = '15';
globalThis.document = { getElementById: id => els[id] || null }; globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [{ id: 'preset-1', name: 'C', cntr: '0000000005', kicKey: 'AA', kidKey: 'BB' }]; globalThis.cards = [{ id: 'preset-1', name: 'C', keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000005' }] }];
const calls = { saved: 0, explored: null, removed: null, sent: null }; const calls = { saved: 0, explored: null, removed: null, sent: null };
globalThis.cardsSave = () => { calls.saved++; }; globalThis.cardsSave = () => { calls.saved++; };
globalThis.cardsRender = () => {}; globalThis.cardsRender = () => {};
@@ -59,8 +69,9 @@ function fakeEnv(por, success) {
globalThis.alert = () => {}; globalThis.alert = () => {};
globalThis.confirm = () => true; globalThis.confirm = () => true;
globalThis.spRefreshFromPreset = () => { globalThis.spRefreshFromPreset = () => {
els['sp-cntr'].value = cards[0].cntr; const cntr = cards[0].keysets[0].cntr;
return cards[0].cntr; els['sp-cntr'].value = cntr;
return cntr;
}; };
globalThis.ramSendOta = async (apdu, sp) => { globalThis.ramSendOta = async (apdu, sp) => {
calls.sent = { apdu: apdu, cntr: sp.cntr, spi2: sp.spi2, preset_id: sp.preset_id }; calls.sent = { apdu: apdu, cntr: sp.cntr, spi2: sp.spi2, preset_id: sp.preset_id };
@@ -81,7 +92,7 @@ test('accepted delete persists the consumed counter and drops the record', async
assert.strictEqual(calls.sent.cntr, '0000000005'); assert.strictEqual(calls.sent.cntr, '0000000005');
assert.strictEqual(calls.sent.apdu, '80E40000094F07F0414C46416101'); assert.strictEqual(calls.sent.apdu, '80E40000094F07F0414C46416101');
assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used'); assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used');
assert.strictEqual(cards[0].cntr, '0000000006', assert.strictEqual(cards[0].keysets[0].cntr, '0000000006',
'the preset must carry the counter the card consumed'); 'the preset must carry the counter the card consumed');
assert.strictEqual(els['sp-cntr'].value, '0000000006'); assert.strictEqual(els['sp-cntr'].value, '0000000006');
assert.strictEqual(calls.sent.preset_id, 'preset-1', assert.strictEqual(calls.sent.preset_id, 'preset-1',
@@ -94,7 +105,7 @@ test('accepted delete persists the consumed counter and drops the record', async
test('a delete with no PoR is accepted (the envelope 9000 is the result)', async () => { test('a delete with no PoR is accepted (the envelope 9000 is the result)', async () => {
const { els, calls } = fakeEnv(undefined); const { els, calls } = fakeEnv(undefined);
await ramDeleteFromExplorer('F0414C46416101', false); await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].cntr, '0000000006'); assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false }); assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
assert.strictEqual(els['ram-result'].textContent, 'OK'); assert.strictEqual(els['ram-result'].textContent, 'OK');
assert.ok(els['ram-steps'].textContent.includes('no PoR'), els['ram-steps'].textContent); assert.ok(els['ram-steps'].textContent.includes('no PoR'), els['ram-steps'].textContent);
@@ -103,7 +114,7 @@ test('a delete with no PoR is accepted (the envelope 9000 is the result)', async
test('cntr_low leaves the preset untouched (the card did not consume the packet)', async () => { test('cntr_low leaves the preset untouched (the card did not consume the packet)', async () => {
const { calls } = fakeEnv({ response_status: 'cntr_low' }); const { calls } = fakeEnv({ response_status: 'cntr_low' });
await ramDeleteFromExplorer('F0414C46416101', false); await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].cntr, '0000000005'); assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
assert.strictEqual(calls.saved, 0); assert.strictEqual(calls.saved, 0);
assert.strictEqual(calls.removed, null); assert.strictEqual(calls.removed, null);
}); });
@@ -112,20 +123,20 @@ test('a refused DELETE still advances the counter but drops nothing', async () =
const { calls } = fakeEnv({ response_status: 'por_ok', const { calls } = fakeEnv({ response_status: 'por_ok',
decoded: { last_status_word: '6A88' } }); decoded: { last_status_word: '6A88' } });
await ramDeleteFromExplorer('F0414C46416101', true); await ramDeleteFromExplorer('F0414C46416101', true);
assert.strictEqual(cards[0].cntr, '0000000006'); assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
assert.strictEqual(calls.removed, null); assert.strictEqual(calls.removed, null);
}); });
test('a delete accepted via actual_response_sms_submit advances and drops the record', async () => { test('a delete accepted via actual_response_sms_submit advances and drops the record', async () => {
const { calls } = fakeEnv({ response_status: 'actual_response_sms_submit' }); const { calls } = fakeEnv({ response_status: 'actual_response_sms_submit' });
await ramDeleteFromExplorer('F0414C46416101', false); await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].cntr, '0000000006'); assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false }); assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
}); });
test('a send failure leaves the preset untouched', async () => { test('a send failure leaves the preset untouched', async () => {
const { calls } = fakeEnv({ response_status: 'por_ok' }, false); const { calls } = fakeEnv({ response_status: 'por_ok' }, false);
await ramDeleteFromExplorer('F0414C46416101', false); await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].cntr, '0000000005'); assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
assert.strictEqual(calls.removed, null); assert.strictEqual(calls.removed, null);
}); });
+4 -1
View File
@@ -29,7 +29,10 @@ function extractFunc(src, name) {
const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt', const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt',
'xorBytes', 'zeroPad', 'crc32Bytes', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys', 'xorBytes', 'zeroPad', 'crc32Bytes', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
'aesCmac', '_genSpBuild', 'genSp', 'spNextCntr', 'scp80SegmentInfo', 'spSizeInfoText', 'aesCmac', '_genSpBuild', 'genSp', 'spNextCntr', 'scp80SegmentInfo', 'spSizeInfoText',
'spShowSizeInfo']; 'spShowSizeInfo',
// the key-version guard genSp runs before building (TS 102 225 A.2)
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spPresetIdx', 'spKeysetGuard'];
let code = ''; let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n'; for (const f of FNS) code += extractFunc(html, f) + '\n';
for (const c of ['SCP80_MAX_SMS', 'SCP80_SINGLE_BYTES', 'SCP80_FIRST_BYTES', 'SCP80_NEXT_BYTES']) { for (const c of ['SCP80_MAX_SMS', 'SCP80_SINGLE_BYTES', 'SCP80_FIRST_BYTES', 'SCP80_NEXT_BYTES']) {
+164 -53
View File
@@ -9,6 +9,14 @@ owns the store: an accepted counter is persisted in the same operation, and a
closed tab, a lost response or a second browser window can no longer lose an closed tab, a lost response or a second browser window can no longer lose an
increment. increment.
A preset holds one **keyset per GlobalPlatform key version** (the b8..b5 nibble
of KIc/KID, TS 102 225 5.1.2/A.2), each with its own KIc/KID keys and its own
counter - "a dedicated counter shall be associated to each key version"
(TS 102 225 Annex A.1). The two bytes of a keyset must carry the same key
version (A.2: the card rejects a mismatch with "Unidentified security error");
key version '00' is reserved and not a keyset (it means "no security" in a
packet, which is a form-level choice, not a preset).
Layout (``--card-presets`` overrides the file; ``Path.home()`` resolves the Layout (``--card-presets`` overrides the file; ``Path.home()`` resolves the
same way on Linux, macOS and Windows):: same way on Linux, macOS and Windows)::
@@ -21,9 +29,11 @@ store. Presets carry a stable uuid - never array indices. The JSON is
deliberately human-readable: hand-editing a counter or moving the file between deliberately human-readable: hand-editing a counter or moving the file between
machines is part of the workbench workflow, and the Cards tab's export/import machines is part of the workbench workflow, and the Cards tab's export/import
uses the same shape (which is also how old localStorage presets move over - uses the same shape (which is also how old localStorage presets move over -
there is no automatic migration). there is no automatic migration). v3.8.0's flat ``kic/kid/kicKey/kidKey/cntr``
shape is converted into a single keyset on load and on import.
""" """
import copy
import json import json
import os import os
import re import re
@@ -36,18 +46,24 @@ 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 = 1 SCHEMA_VERSION = 2
# The fields a preset carries: the localStorage shape plus the id. Defaults # The fields a preset carries: the localStorage shape plus the id. Defaults
# mirror the Cards form (spi1/spi2/counter and the per-target TARs). # mirror the Cards form (spi1/spi2 and the per-target TARs). The OTA key
PRESET_FIELDS = ('name', 'iccid', 'adm', 'kic', 'kid', 'spi1', 'spi2', # material lives in `keysets` - one entry per GlobalPlatform key version (the
'tar', 'uiccTar', 'usimTar', 'cntr', 'kicKey', 'kidKey', # b8..b5 nibble of KIc/KID), each with its own counter (TS 102 225 Annex A.1:
'pskIdentity', 'pskKey') # "a dedicated counter shall be associated to each key version").
HEX_FIELDS = ('adm', 'kic', 'kid', 'spi1', 'spi2', 'tar', 'uiccTar', 'usimTar', PRESET_FIELDS = ('name', 'iccid', 'adm', 'spi1', 'spi2',
'cntr', 'kicKey', 'kidKey', 'pskKey') 'tar', 'uiccTar', 'usimTar', 'pskIdentity', 'pskKey')
# The v3.8.0 flat key fields: converted into a single keyset on load/import.
LEGACY_KEY_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
KEYSET_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
HEX_FIELDS = ('adm', 'spi1', 'spi2', 'tar', 'uiccTar', 'usimTar', 'pskKey')
KEYSET_HEX_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
TAR_DEFAULTS = {'tar': '000000', 'uiccTar': 'B00000', 'usimTar': 'B00001'} TAR_DEFAULTS = {'tar': '000000', 'uiccTar': 'B00000', 'usimTar': 'B00001'}
COUNTER_BITS = 40 # the SCP80 counter is 5 bytes (TS 31.115) COUNTER_BITS = 40 # the SCP80 counter is 5 bytes (TS 31.115)
COUNTER_WIDTH = 10 # ... shown as 10 hex digits COUNTER_WIDTH = 10 # ... shown as 10 hex digits
KVN_MAX = 0x0F # key version '00' is reserved: no key set, no counter
def default_path(): def default_path():
@@ -55,6 +71,16 @@ def default_path():
return Path.home() / DEFAULT_DIR / DEFAULT_FILENAME return Path.home() / DEFAULT_DIR / DEFAULT_FILENAME
def keyset_kvn(keyset):
"""The key version of a keyset: the high nibble of KIc (bits b8..b5 of
KIc/KID, TS 102 225 5.1.2/A.2 - both bytes must agree, so KIc is enough).
None when the byte is unusable."""
kic = str((keyset or {}).get('kic') or '').strip().upper()
if not re.fullmatch(r'[0-9A-F]{2}', kic):
return None
return int(kic, 16) >> 4
def _swap_nibbles(s): def _swap_nibbles(s):
"""Nibble-swap a hex string; an odd trailing nibble becomes 'F' (the JS """Nibble-swap a hex string; an odd trailing nibble becomes 'F' (the JS
``swapNibbles`` the PWA uses).""" ``swapNibbles`` the PWA uses)."""
@@ -183,12 +209,14 @@ class PresetStore:
except OSError: except OSError:
pass pass
def _audit(self, pid, name, old, new, source): def _audit(self, pid, name, old, new, source, kvn=None):
"""One JSONL line per counter change: the forensic trail for "did an """One JSONL line per counter change (the key version included - each
increment get lost?" questions.""" keyset has its own counter): the forensic trail for "did an increment
get lost?" questions."""
try: try:
line = json.dumps({'ts': time.strftime('%Y-%m-%dT%H:%M:%S'), line = json.dumps({'ts': time.strftime('%Y-%m-%dT%H:%M:%S'),
'id': pid, 'name': name, 'old': old, 'new': new, 'id': pid, 'name': name, 'kvn': kvn,
'old': old, 'new': new,
'source': source}, ensure_ascii=False) 'source': source}, ensure_ascii=False)
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
with open(self.audit_path, 'a', encoding='utf-8') as fh: with open(self.audit_path, 'a', encoding='utf-8') as fh:
@@ -208,29 +236,51 @@ class PresetStore:
out[k] = v out[k] = v
out['spi1'] = out['spi1'] or '16' out['spi1'] = out['spi1'] or '16'
out['spi2'] = out['spi2'] or '01' out['spi2'] = out['spi2'] or '01'
# the counter is kept as typed here and validated + padded in
# _validate(): a bad value must be refused, never silently masked
out['cntr'] = out['cntr'] or '1'
for k, d in TAR_DEFAULTS.items(): for k, d in TAR_DEFAULTS.items():
out[k] = out[k] or d out[k] = out[k] or d
# Keysets: a v3.8.0 preset (flat kic/kid/kicKey/kidKey/cntr) converts
# into a single keyset, so old files and exports keep working.
raw = fields.get('keysets')
if not isinstance(raw, list):
raw = []
if any(str(fields.get(k) or '').strip() for k in LEGACY_KEY_FIELDS):
raw = [{k: fields.get(k) for k in LEGACY_KEY_FIELDS}]
out['keysets'] = [self._normalise_keyset(ks) for ks in raw
if isinstance(ks, dict)]
pid = str(fields.get('id') or '').strip().lower() pid = str(fields.get('id') or '').strip().lower()
out['id'] = pid or uuid.uuid4().hex out['id'] = pid or uuid.uuid4().hex
return out return out
def _normalise_keyset(self, ks):
"""One keyset, cleaned. The counter is kept as typed here and
validated + padded in _validate_keyset() - a bad value must be
refused, never silently masked."""
out = {}
for k in KEYSET_FIELDS:
v = ks.get(k)
v = '' if v is None else str(v).strip()
if k in KEYSET_HEX_FIELDS:
v = re.sub(r'\s', '', v).upper()
out[k] = v
out['cntr'] = out['cntr'] or '1'
return out
def _validate(self, p, skip_id=None): def _validate(self, p, skip_id=None):
if not p['name']: if not p['name']:
raise PresetError('name is required') raise PresetError('name is required')
for key in ('kic', 'kid', 'kicKey', 'kidKey'):
if not p[key]:
raise PresetError('%s is required' % key)
if bool(p['pskIdentity']) != bool(p['pskKey']): if bool(p['pskIdentity']) != bool(p['pskKey']):
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 not re.fullmatch(r'[0-9A-F]{1,10}', p['cntr']): if not p['keysets']:
raise PresetError('counter must be 1-10 hex digits') raise PresetError('at least one keyset is required')
# validated: store the protocol's fixed-width form seen = set()
p['cntr'] = counter_hex(p['cntr']) for ks in p['keysets']:
self._validate_keyset(ks)
kvn = keyset_kvn(ks)
if kvn in seen:
raise PresetError('duplicate key version %02X in this preset' % kvn)
seen.add(kvn)
norm = normalize_iccid(p['iccid']) norm = normalize_iccid(p['iccid'])
if norm: if norm:
for other in self._presets: for other in self._presets:
@@ -241,6 +291,30 @@ class PresetStore:
% (other['name'] or other['id'])) % (other['name'] or other['id']))
return p return p
def _validate_keyset(self, ks):
"""One keyset: KIc/KID carry the key version in b8..b5 and must agree
(TS 102 225 A.2 - a mismatch is rejected by the card), the version is
01-0F ('00' means "no security" and has no keys to define, so it is
not a preset keyset), both keys are present and the counter is
1-10 hex digits (the dedicated counter of that key version, A.1)."""
for key in ('kic', 'kid'):
if not re.fullmatch(r'[0-9A-F]{2}', ks[key]):
raise PresetError('%s must be one hex byte' % key)
kic_v, kid_v = int(ks['kic'], 16) >> 4, int(ks['kid'], 16) >> 4
if kic_v != kid_v:
raise PresetError('KIc and KID must carry the same key version '
'(TS 102 225 A.2): %s / %s' % (ks['kic'], ks['kid']))
if not 1 <= kic_v <= KVN_MAX:
raise PresetError('key version must be 01-0F: %s' % ks['kic'])
for key in ('kicKey', 'kidKey'):
if not ks[key]:
raise PresetError('%s is required' % key)
if not re.fullmatch(r'[0-9A-F]{1,10}', ks['cntr']):
raise PresetError('counter must be 1-10 hex digits')
# validated: store the protocol's fixed-width form
ks['cntr'] = counter_hex(ks['cntr'])
return ks
def _find(self, pid): def _find(self, pid):
want = str(pid or '').strip().lower() want = str(pid or '').strip().lower()
if not want: if not want:
@@ -253,12 +327,12 @@ class PresetStore:
# -------------------------------------------------------------- public # -------------------------------------------------------------- public
def list(self): def list(self):
with self._lock: with self._lock:
return [dict(p) for p in self._presets] return [copy.deepcopy(p) for p in self._presets]
def get(self, pid): def get(self, pid):
with self._lock: with self._lock:
p = self._find(pid) p = self._find(pid)
return dict(p) if p else None return copy.deepcopy(p) if p else None
def find_by_iccid(self, iccid): def find_by_iccid(self, iccid):
norm = normalize_iccid(iccid) norm = normalize_iccid(iccid)
@@ -267,7 +341,7 @@ class PresetStore:
with self._lock: with self._lock:
for p in self._presets: for p in self._presets:
if normalize_iccid(p['iccid']) == norm: if normalize_iccid(p['iccid']) == norm:
return dict(p) return copy.deepcopy(p)
return None return None
def add(self, fields): def add(self, fields):
@@ -281,31 +355,36 @@ class PresetStore:
self._presets.append(p) self._presets.append(p)
self._save() self._save()
sys.stderr.write('PRESETS: added %s (%s)\n' % (p['name'], p['id'])) sys.stderr.write('PRESETS: added %s (%s)\n' % (p['name'], p['id']))
return dict(p) return copy.deepcopy(p)
def update(self, pid, fields): def update(self, pid, fields):
"""Partial or full update (the human edit path): the counter is written """Partial or full update (the human edit path): a counter in a keyset
as given - the Cards tab is the place to resync it deliberately. The is written as given - the Cards tab is the place to resync it
counter an *operation* accepted goes through set_counter().""" deliberately. The counter an *operation* accepted goes through
set_counter()."""
with self._lock: with self._lock:
cur = self._find(pid) cur = self._find(pid)
if cur is None: if cur is None:
return None return None
merged = dict(cur) merged = copy.deepcopy(cur)
for k in PRESET_FIELDS: for k in PRESET_FIELDS:
if isinstance(fields, dict) and k in fields: if isinstance(fields, dict) and k in fields:
merged[k] = fields[k] merged[k] = fields[k]
if isinstance(fields, dict) and 'keysets' in fields:
merged['keysets'] = fields['keysets']
new = self._normalise(merged) new = self._normalise(merged)
new['id'] = cur['id'] new['id'] = cur['id']
self._validate(new, skip_id=cur['id']) self._validate(new, skip_id=cur['id'])
old_cntr = cur['cntr']
self._presets[self._presets.index(cur)] = new self._presets[self._presets.index(cur)] = new
self._save() self._save()
if new['cntr'] != old_cntr: for old_ks, new_ks in zip(cur['keysets'], new['keysets']):
self._audit(new['id'], new['name'], old_cntr, new['cntr'], 'edit') if old_ks['cntr'] != new_ks['cntr']:
sys.stderr.write('PRESETS: counter %s %s -> %s (edit)\n' kvn = keyset_kvn(new_ks)
% (new['name'], old_cntr, new['cntr'])) self._audit(new['id'], new['name'], old_ks['cntr'],
return dict(new) new_ks['cntr'], 'edit', kvn)
sys.stderr.write('PRESETS: counter %s kvn=%02X %s -> %s (edit)\n'
% (new['name'], kvn, old_ks['cntr'], new_ks['cntr']))
return copy.deepcopy(new)
def remove(self, pid): def remove(self, pid):
with self._lock: with self._lock:
@@ -317,33 +396,65 @@ class PresetStore:
sys.stderr.write('PRESETS: removed %s (%s)\n' % (p['name'], p['id'])) sys.stderr.write('PRESETS: removed %s (%s)\n' % (p['name'], p['id']))
return True return True
def set_counter(self, pid, cntr, source='operation'): def find_keyset(self, pid, kvn):
"""Persist the counter an operation accepted. Monotonic: the counter """The keyset of a preset for a key version (the b8..b5 nibble of
only moves forward, so a stale caller can never regress it. Returns KIc/KID), or None when the preset or the key version is unknown."""
the stored preset (or None when the id is unknown)."""
with self._lock: with self._lock:
p = self._find(pid) p = self._find(pid)
if p is None: if p is None:
return None return None
for ks in p['keysets']:
if keyset_kvn(ks) == kvn:
return copy.deepcopy(ks)
return None
def set_counter(self, pid, cntr, source='operation', kvn=None):
"""Persist the counter an operation accepted, into the keyset of the
key version it used (each key version has its own dedicated counter,
TS 102 225 A.1). Monotonic per keyset: the counter only moves
forward, so a stale caller can never regress it. `kvn=None` picks the
preset's only keyset (a convenience for single-keyset callers).
Returns the stored preset (or None when the id is unknown)."""
with self._lock:
p = self._find(pid)
if p is None:
return None
ks = None
if kvn is not None:
for cand in p['keysets']:
if keyset_kvn(cand) == kvn:
ks = cand
break
if ks is None:
sys.stderr.write('PRESETS: no key version %s in %s (%s)\n'
% (kvn, p['name'], source))
return copy.deepcopy(p)
elif len(p['keysets']) == 1:
ks = p['keysets'][0]
else:
sys.stderr.write('PRESETS: %s has several key versions - the '
'counter needs one (%s)\n' % (p['name'], source))
return copy.deepcopy(p)
new = str(cntr or '').strip().upper() new = str(cntr or '').strip().upper()
if not re.fullmatch(r'[0-9A-F]{1,10}', new): if not re.fullmatch(r'[0-9A-F]{1,10}', new):
sys.stderr.write('PRESETS: ignoring malformed counter %r (%s)\n' sys.stderr.write('PRESETS: ignoring malformed counter %r (%s)\n'
% (cntr, source)) % (cntr, source))
return dict(p) return copy.deepcopy(p)
new = counter_hex(new) new = counter_hex(new)
if new == p['cntr']: if new == ks['cntr']:
return dict(p) return copy.deepcopy(p)
if not counter_ahead(p['cntr'], new): if not counter_ahead(ks['cntr'], new):
sys.stderr.write('PRESETS: ignoring backwards counter %s -> %s (%s)\n' sys.stderr.write('PRESETS: ignoring backwards counter kvn=%02X '
% (p['cntr'], new, source)) '%s -> %s (%s)\n'
return dict(p) % (keyset_kvn(ks), ks['cntr'], new, source))
old = p['cntr'] return copy.deepcopy(p)
p['cntr'] = new old = ks['cntr']
ks['cntr'] = new
self._save() self._save()
self._audit(p['id'], p['name'], old, new, source) self._audit(p['id'], p['name'], old, new, source, keyset_kvn(ks))
sys.stderr.write('PRESETS: counter %s %s -> %s (%s)\n' sys.stderr.write('PRESETS: counter %s kvn=%02X %s -> %s (%s)\n'
% (p['name'], old, new, source)) % (p['name'], keyset_kvn(ks), old, new, source))
return dict(p) return copy.deepcopy(p)
def import_presets(self, items, mode='merge'): def import_presets(self, items, mode='merge'):
"""Import exported (or old localStorage) presets: entries are validated """Import exported (or old localStorage) presets: entries are validated
+198 -35
View File
@@ -1419,20 +1419,62 @@ def _counter_valid(cntr):
return bool(re.fullmatch(r'[0-9A-Fa-f]{1,10}', str(cntr or ''))) return bool(re.fullmatch(r'[0-9A-Fa-f]{1,10}', str(cntr or '')))
def _preset_counter_persist(server, preset_id, cntr, source): def _kvn_of(kic_hex, kid_hex):
"""The key version a packet uses: the b8..b5 nibble of KIc/KID (TS 102 225
5.1.2/A.2). Returns ``(kvn, error)``: kvn is 0 when neither byte carries
one ('00' = no security, legal per A.2), error names a mismatch - A.2: the
versions shall be identical when different from 0, else the card rejects
the message - or a malformed byte."""
kic = str(kic_hex or '').strip().upper()
kid = str(kid_hex or '').strip().upper()
for name, value in (('KIc', kic), ('KID', kid)):
if value and not re.fullmatch(r'[0-9A-F]{2}', value):
return None, '%s must be one hex byte: %r' % (name, value)
kic_v = int(kic, 16) >> 4 if kic else 0
kid_v = int(kid, 16) >> 4 if kid else 0
if kic_v and kid_v and kic_v != kid_v:
return None, ('KIc and KID must carry the same key version '
'(TS 102 225 A.2): %s / %s' % (kic, kid))
return (kic_v or kid_v), None
def _preset_keyset_check(server, preset_id, kic, kid):
"""The "the key version must be defined in the preset" rule: an error
string when the packet's (non-zero) key version has no keyset in the named
preset, else None. Without a preset_id (a raw API caller) there is
nothing to check; '00'/'00' (no security) needs no keyset either."""
if not preset_id:
return None
kvn, err = _kvn_of(kic, kid)
if err:
return err
if not kvn:
return None
store = getattr(server, 'card_presets', None)
if store is None:
return None
if store.find_keyset(preset_id, kvn) is None:
preset = store.get(preset_id) or {}
return ('key version %d is not defined in preset %s - add it in the '
'Cards tab' % (kvn, preset.get('name') or preset_id))
return None
def _preset_counter_persist(server, preset_id, cntr, source, kvn=None):
"""Persist the counter an operation accepted into the server-side preset """Persist the counter an operation accepted into the server-side preset
store (v3.8.0). The counter is card state, so the server writes it with store (v3.8.0), into the keyset of the key version it used (each key
the operation that consumed it - a closed tab, a lost response or a second version has its own dedicated counter, TS 102 225 A.1). The counter is
browser window can no longer lose an increment. The store itself is card state, so the server writes it with the operation that consumed it - a
monotonic (a stale value never regresses it). Best effort: a store closed tab, a lost response or a second browser window can no longer lose
failure must never fail the card operation.""" an increment. The store itself is monotonic (a stale value never regresses
it). Best effort: a store failure must never fail the card operation."""
if not preset_id or not cntr: if not preset_id or not cntr:
return return
store = getattr(server, 'card_presets', None) store = getattr(server, 'card_presets', None)
if store is None: if store is None:
return return
try: try:
store.set_counter(preset_id, cntr, source) store.set_counter(preset_id, cntr, source, kvn)
except Exception as e: except Exception as e:
sys.stderr.write('PRESETS: counter persist failed (%s): %s\n' % (source, e)) sys.stderr.write('PRESETS: counter persist failed (%s): %s\n' % (source, e))
@@ -1635,7 +1677,8 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex, silent=False)
# crash mid-install cannot leave the preset behind the card (v3.8.0). # crash mid-install cannot leave the preset behind the card (v3.8.0).
if step.get('por_status') in ('por_ok', 'no_por'): if step.get('por_status') in ('por_ok', 'no_por'):
_preset_counter_persist(server, state.get('preset_id'), _preset_counter_persist(server, state.get('preset_id'),
state['cntr'], 'ram-%s' % step_name.split()[0].lower()) state['cntr'], 'ram-%s' % step_name.split()[0].lower(),
state.get('kvn'))
if step_error: if step_error:
if not silent: if not silent:
state['failure']['error'] = '%s: %s' % (step_name, step_error) state['failure']['error'] = '%s: %s' % (step_name, step_error)
@@ -5108,7 +5151,7 @@ _TEST_RUNNING = False
_TEST_RUN = { _TEST_RUN = {
'running': False, 'stop': False, 'name': None, 'status': None, 'running': False, 'stop': False, 'name': None, 'status': None,
'session': None, 'index': 0, 'total': 0, 'steps': [], 'session': None, 'index': 0, 'total': 0, 'steps': [],
'preset': None, 'scp80_counter': None, 'preset': None, 'scp80_counter': None, 'scp80_counters': {},
'started': None, 'finished': None, 'error': None, 'started': None, 'finished': None, 'error': None,
} }
_TEST_LOCK = threading.Lock() _TEST_LOCK = threading.Lock()
@@ -5173,19 +5216,41 @@ def _increment_counter_hex(counter_hex):
return '%0*X' % (width, (int(c, 16) + 1) & ((1 << (4 * width)) - 1)) return '%0*X' % (width, (int(c, 16) + 1) & ((1 << (4 * width)) - 1))
def _test_preset_error(script, preset): def _preset_keysets(preset):
"""The SCP80 steps need a complete card preset; steps may override """The keysets of a preset dict - a v3.8.0 flat preset (kic/kid/kicKey/
TAR/SPI1/SPI2 only (KIc/KID and the counter stay preset-owned).""" kidKey/counter) counts as a single keyset, so old data keeps working."""
preset = preset or {} preset = preset or {}
for key in ('kic', 'kid', 'kicKey', 'kidKey'): keysets = [ks for ks in (preset.get('keysets') or []) if isinstance(ks, dict)]
if not preset.get(key): if not keysets and (preset.get('kic') or preset.get('kid')):
return 'SCP80 preset is incomplete: %s is missing' % key keysets = [{'kic': preset.get('kic'), 'kid': preset.get('kid'),
if not (preset.get('counter') or preset.get('cntr')): 'kicKey': preset.get('kicKey'), 'kidKey': preset.get('kidKey'),
return 'SCP80 preset has no counter' 'cntr': preset.get('counter') or preset.get('cntr') or ''}]
return keysets
def _test_preset_error(script, preset):
"""The SCP80 steps need at least one complete keyset in the card preset;
a step may override the keyset (kvn), the TAR, SPI1 and SPI2 only - the
KIc/KID keys and the counters stay preset-owned."""
preset = preset or {}
keysets = _preset_keysets(preset)
if not keysets:
return 'SCP80 preset is incomplete: no keyset is defined'
if not [ks for ks in keysets if _keyset_complete(ks)]:
return ('SCP80 preset is incomplete: a keyset needs KIc, KID, both keys '
'and a counter')
for i, step in enumerate(script['steps']): for i, step in enumerate(script['steps']):
if step['type'] != 'action' or step['kind'] != 'scp80': if step['type'] != 'action' or step['kind'] != 'scp80':
continue continue
p = step['params'] p = step['params']
if p.get('kvn') is not None:
want = int(p['kvn'])
ks = next((k for k in keysets if presets.keyset_kvn(k) == want), None)
if ks is None:
return 'step %d: key version %d is not defined in the preset' % (i + 1, want)
if not _keyset_complete(ks):
return ('step %d: key version %d is incomplete (KIc, KID, both '
'keys and a counter)' % (i + 1, want))
if not (p.get('tar') or preset.get('tar')): if not (p.get('tar') or preset.get('tar')):
return 'step %d: no TAR (neither in the step nor in the preset)' % (i + 1) return 'step %d: no TAR (neither in the step nor in the preset)' % (i + 1)
if not (p.get('spi1') or preset.get('spi1')) or not (p.get('spi2') or preset.get('spi2')): if not (p.get('spi1') or preset.get('spi1')) or not (p.get('spi2') or preset.get('spi2')):
@@ -5193,6 +5258,12 @@ def _test_preset_error(script, preset):
return None return None
def _keyset_complete(ks):
"""A usable keyset: both key bytes, both keys and a counter."""
return bool(ks.get('kic') and ks.get('kid') and ks.get('kicKey')
and ks.get('kidKey') and ks.get('cntr'))
def _test_check_result(label, ok, expected, actual, level, detail=None): def _test_check_result(label, ok, expected, actual, level, detail=None):
res = {'label': label, 'ok': bool(ok), 'expected': str(expected), res = {'label': label, 'ok': bool(ok), 'expected': str(expected),
'actual': str(actual), 'level': level} 'actual': str(actual), 'level': level}
@@ -5308,17 +5379,40 @@ def _test_run_scp80(server, step, ctx):
scc = server.scc scc = server.scc
preset = ctx['preset'] preset = ctx['preset']
p = step['params'] p = step['params']
# The keyset: a step may name a key version (`kvn`), otherwise the preset's
# first keyset is used (a preset carries one keyset per key version, each
# with its own keys and counter - TS 102 225 Annex A.1).
keysets = _preset_keysets(preset)
keyset = None
if p.get('kvn') is not None:
want = int(p['kvn'])
keyset = next((ks for ks in keysets if presets.keyset_kvn(ks) == want), None)
if keyset is None:
raise testscript.ScriptError('key version %d is not defined in the '
'selected card preset' % want)
elif keysets:
keyset = keysets[0]
kic = p.get('kic') or (keyset or {}).get('kic') or ''
kid = p.get('kid') or (keyset or {}).get('kid') or ''
kic_key = p.get('kicKey') or (keyset or {}).get('kicKey') or ''
kid_key = p.get('kidKey') or (keyset or {}).get('kidKey') or ''
kvn, kvn_err = _kvn_of(kic, kid)
if kvn_err:
raise testscript.ScriptError(kvn_err)
spi1 = p.get('spi1') or preset.get('spi1') or '16' spi1 = p.get('spi1') or preset.get('spi1') or '16'
spi2 = p.get('spi2') or preset.get('spi2') or '01' spi2 = p.get('spi2') or preset.get('spi2') or '01'
tar = p.get('tar') or preset.get('tar') or '' tar = p.get('tar') or preset.get('tar') or ''
counter = ctx['counter'] or '00000000' counters = ctx.setdefault('counters', {})
if kvn and kvn not in counters:
counters[kvn] = (keyset or {}).get('cntr') or '00000000'
counter = counters.get(kvn) if kvn else (ctx.get('counter') or '00000000')
counter = counter or '00000000'
if p.get('sp'): if p.get('sp'):
sp_hex = p['sp'] sp_hex = p['sp']
source = 'sp' source = 'sp'
else: else:
sp_hex, _ = _build_secured_packet(spi1, spi2, preset.get('kic', ''), preset.get('kid', ''), sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, counter,
tar, counter, p['apdu'], p['apdu'], kic_key, kid_key)
preset.get('kicKey', ''), preset.get('kidKey', ''))
source = 'apdu' source = 'apdu'
result = _send_secured_packet(scc, sp_hex, server.sms_oa, sm_sc=server.sms_sc, result = _send_secured_packet(scc, sp_hex, server.sms_oa, sm_sc=server.sms_sc,
handle_proactive=False) handle_proactive=False)
@@ -5326,18 +5420,23 @@ def _test_run_scp80(server, step, ctx):
data = result.get('response_data') or '' data = result.get('response_data') or ''
por = None por = None
if data: if data:
por = _decode_por(spi1, spi2, preset.get('kic', ''), preset.get('kid', ''), por = _decode_por(spi1, spi2, kic, kid, counter, kic_key, kid_key, data)
counter, preset.get('kicKey', ''), preset.get('kidKey', ''), data) sent = 'SCP80 %s TAR=%s SPI1=%s SPI2=%s cntr=%s%s' % (
sent = 'SCP80 %s TAR=%s SPI1=%s SPI2=%s cntr=%s' % ( source, tar or '-', spi1, spi2, counter,
source, tar or '-', spi1, spi2, counter) (' kvn=%d' % kvn) if kvn else '')
if sw: if sw:
# The card answered, so the SCP80 counter was consumed: advance the # The card answered, so the SCP80 counter was consumed: advance the
# working value and persist it into the server-side preset (v3.8.0 - # working value of this key version and persist it into the server-side
# the server owns the counter, so the run survives a lost response and # preset (v3.8.0 - the server owns the counters, so the run survives a
# the PWA's counter write-back dance is gone). # lost response and the PWA's counter write-back dance is gone).
ctx['counter'] = _increment_counter_hex(counter) if kvn:
_preset_counter_persist(server, (preset or {}).get('id'), counters[kvn] = _increment_counter_hex(counter)
ctx['counter'], 'test-run') ctx['counter'] = counters[kvn]
_preset_counter_persist(server, preset.get('id'), counters[kvn],
'test-run', kvn)
else:
ctx['counter'] = _increment_counter_hex(counter)
_preset_counter_persist(server, preset.get('id'), ctx['counter'], 'test-run')
return data, sw, sent, por, counter return data, sw, sent, por, counter
@@ -5475,6 +5574,7 @@ def _test_run_execute(server, script, preset):
scc = server.scc scc = server.scc
ctx = {'preset': dict(preset or {}), ctx = {'preset': dict(preset or {}),
'counter': str((preset or {}).get('counter') or (preset or {}).get('cntr') or '').upper(), 'counter': str((preset or {}).get('counter') or (preset or {}).get('cntr') or '').upper(),
'counters': {},
'uses_scp80': any(s['type'] == 'action' and s['kind'] == 'scp80' 'uses_scp80': any(s['type'] == 'action' and s['kind'] == 'scp80'
for s in script['steps'])} for s in script['steps'])}
if ctx['uses_scp80']: if ctx['uses_scp80']:
@@ -5546,6 +5646,7 @@ def _test_run_execute(server, script, preset):
_TEST_RUN['finished'] = time.time() _TEST_RUN['finished'] = time.time()
if ctx['uses_scp80']: if ctx['uses_scp80']:
_TEST_RUN['scp80_counter'] = ctx['counter'] _TEST_RUN['scp80_counter'] = ctx['counter']
_TEST_RUN['scp80_counters'] = dict(ctx.get('counters') or {})
if _TEST_RUN['status'] is None: if _TEST_RUN['status'] is None:
levels = [s.get('status') for s in _TEST_RUN['steps']] levels = [s.get('status') for s in _TEST_RUN['steps']]
if stopped: if stopped:
@@ -5588,6 +5689,7 @@ def _test_run_start(server, script, preset):
'session': getattr(server, 'card_session', 0), 'index': 0, 'session': getattr(server, 'card_session', 0), 'index': 0,
'total': len(script['steps']), 'steps': [], 'started': time.time(), 'total': len(script['steps']), 'steps': [], 'started': time.time(),
'finished': None, 'error': None, 'scp80_counter': None, 'finished': None, 'error': None, 'scp80_counter': None,
'scp80_counters': {},
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'), 'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
}) })
_TEST_RUNNING = True _TEST_RUNNING = True
@@ -5653,6 +5755,19 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 500) self._send_json(resp, 500)
self._log_resp(resp) self._log_resp(resp)
def _keyset_guard(self, body, kic, kid):
"""The key-version checks shared by the RAM operations: the KIc/KID
versions must agree (TS 102 225 A.2 - the card rejects a mismatch) and a
named preset must define the key version ("the keyset must exist").
Returns ``(kvn, error)``: kvn is None when the packet carries no key
version ('00'/'00' = no security, legal per A.2), error an error string
the caller answers with 400."""
kvn, err = _kvn_of(kic, kid)
if err:
return None, err
err = _preset_keyset_check(self.server, body.get('preset_id'), kic, kid)
return (kvn or None), err
def _log_req(self, body=None): def _log_req(self, body=None):
if self.server.log_requests: if self.server.log_requests:
if body is not None: if body is not None:
@@ -6732,6 +6847,24 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json({'error': _err('reader_not_init', lang)}, 503) self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)}) self._log_resp({'error': _err('reader_not_init', lang)})
return return
# The packet's key version (b8..b5 of KIc/KID, TS 102 225 A.2) must
# be consistent - a mismatch is invalid input (the card rejects it
# with "Unidentified security error") - and, when a preset is named,
# defined in it. Both are refused before anything is built (and
# before the format probe consumes a counter).
kvn, kvn_err = _kvn_of(body.get('kic', ''), body.get('kid', ''))
if kvn_err:
resp = {'error': kvn_err}
self._send_json(resp, 400)
self._log_resp(resp)
return
keyset_err = _preset_keyset_check(self.server, body.get('preset_id'),
body.get('kic', ''), body.get('kid', ''))
if keyset_err:
resp = {'error': keyset_err}
self._send_json(resp, 400)
self._log_resp(resp)
return
include_cpi = body.get('includeCpi', True) include_cpi = body.get('includeCpi', True)
ram_format = None ram_format = None
try: try:
@@ -6881,7 +7014,8 @@ class PysimHandler(BaseHTTPRequestHandler):
if submit_handler and hasattr(scc, '_tp'): if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive scc._tp.proactive_handler = old_proactive
_preset_counter_persist(self.server, body.get('preset_id'), _preset_counter_persist(self.server, body.get('preset_id'),
resp.get('final_cntr'), 'send-ota') resp.get('final_cntr'), 'send-ota',
kvn or None)
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
except Exception as e: except Exception as e:
@@ -6947,6 +7081,16 @@ class PysimHandler(BaseHTTPRequestHandler):
make_selectable = body.get('make_selectable', True) make_selectable = body.get('make_selectable', True)
privileges_hex = body.get('privileges', '').replace(' ', '') or '00' privileges_hex = body.get('privileges', '').replace(' ', '') or '00'
# The key version must be consistent and defined in the preset
# (TS 102 225 A.2 / the "keyset must exist" rule) - checked
# before the format probe consumes a counter.
kvn, keyset_err = self._keyset_guard(body, kic, kid)
if keyset_err:
err = {'success': False, 'error': keyset_err}
self._send_json(err, 400)
self._log_resp(err)
return
# LOAD block size: the default 240-byte payload cannot be sent # LOAD block size: the default 240-byte payload cannot be sent
# over SCP80 (pySim refuses a secured packet above one SMS). # over SCP80 (pySim refuses a secured packet above one SMS).
# Fit it automatically, or honour an explicit override capped # Fit it automatically, or honour an explicit override capped
@@ -6973,7 +7117,8 @@ class PysimHandler(BaseHTTPRequestHandler):
steps = [] steps = []
state = {'steps': steps, 'encode_error': None, 'failure': {}, state = {'steps': steps, 'encode_error': None, 'failure': {},
'cntr': cntr, 'preset_id': body.get('preset_id')} 'cntr': cntr, 'preset_id': body.get('preset_id'),
'kvn': kvn}
sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid, sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key, 'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': body.get('includeCpi', True)} 'include_cpi': body.get('includeCpi', True)}
@@ -7106,8 +7251,18 @@ class PysimHandler(BaseHTTPRequestHandler):
'tar': body.get('tar', '000000'), 'cntr': body.get('cntr', '00000000'), 'tar': body.get('tar', '000000'), 'cntr': body.get('cntr', '00000000'),
'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''), 'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''),
'include_cpi': body.get('includeCpi', True)} 'include_cpi': body.get('includeCpi', True)}
# The key version must be consistent and defined in the preset
# (TS 102 225 A.2 / the "keyset must exist" rule) - checked
# before the format probe consumes a counter.
kvn, keyset_err = self._keyset_guard(body, sp_state['kic'], sp_state['kid'])
if keyset_err:
err = {'success': False, 'error': keyset_err}
self._send_json(err, 400)
self._log_resp(err)
return
state = {'steps': [], 'encode_error': None, 'failure': {}, state = {'steps': [], 'encode_error': None, 'failure': {},
'cntr': sp_state['cntr'], 'preset_id': body.get('preset_id')} 'cntr': sp_state['cntr'], 'preset_id': body.get('preset_id'),
'kvn': kvn}
# Live progress for the PWA's operation modal (see /api/ram-install) # Live progress for the PWA's operation modal (see /api/ram-install)
_ram_progress_begin('install-app', 2) _ram_progress_begin('install-app', 2)
try: try:
@@ -7212,8 +7367,15 @@ class PysimHandler(BaseHTTPRequestHandler):
kic_key = body.get('kicKey', '') kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '') kid_key = body.get('kidKey', '')
steps = [] steps = []
kvn, keyset_err = self._keyset_guard(body, kic, kid)
if keyset_err:
err = {'success': False, 'error': keyset_err}
self._send_json(err, 400)
self._log_resp(err)
return
state = {'steps': steps, 'encode_error': None, 'failure': {}, state = {'steps': steps, 'encode_error': None, 'failure': {},
'cntr': cntr, 'preset_id': body.get('preset_id')} 'cntr': cntr, 'preset_id': body.get('preset_id'),
'kvn': kvn}
sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid, sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key, 'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': body.get('includeCpi', True)} 'include_cpi': body.get('includeCpi', True)}
@@ -7385,6 +7547,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'running': False, 'stop': False, 'name': None, 'status': None, 'running': False, 'stop': False, 'name': None, 'status': None,
'session': None, 'index': 0, 'total': 0, 'steps': [], 'session': None, 'index': 0, 'total': 0, 'steps': [],
'preset': None, 'scp80_counter': None, 'preset': None, 'scp80_counter': None,
'scp80_counters': {},
'started': None, 'finished': None, 'error': None, 'started': None, 'finished': None, 'error': None,
}) })
if busy: if busy:
+68 -5
View File
@@ -19,6 +19,7 @@ import urllib.request
from unittest import mock from unittest import mock
from pysim_simple_server.__main__ import _build_http_server from pysim_simple_server.__main__ import _build_http_server
from pysim_simple_server import presets
from pysim_simple_server.server import PysimHandler, _CARD_FREE_GET, _CARD_LOCK from pysim_simple_server.server import PysimHandler, _CARD_FREE_GET, _CARD_LOCK
ROOT = pathlib.Path(__file__).resolve().parents[1] ROOT = pathlib.Path(__file__).resolve().parents[1]
@@ -479,8 +480,13 @@ class PresetStoreHttpTests(unittest.TestCase):
e.close() e.close()
return e.code, body return e.code, body
def _keyset(self, **kw):
ks = {'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB'}
ks.update(kw)
return ks
def _preset(self, **kw): def _preset(self, **kw):
p = {'name': 'C', 'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB'} p = {'name': 'C', 'keysets': [self._keyset()]}
p.update(kw) p.update(kw)
return p return p
@@ -497,7 +503,7 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(status, 200, resp) self.assertEqual(status, 200, resp)
pid = resp['preset']['id'] pid = resp['preset']['id']
self.assertTrue(pid) self.assertTrue(pid)
self.assertEqual(resp['preset']['cntr'], '0000000001') self.assertEqual(resp['preset']['keysets'][0]['cntr'], '0000000001')
# a duplicate ICCID (raw-hex form) is refused # a duplicate ICCID (raw-hex form) is refused
status, resp = self._post('/api/presets', status, resp = self._post('/api/presets',
@@ -528,7 +534,9 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(status, 200, resp) self.assertEqual(status, 200, resp)
self.assertEqual(resp['added'], 1) self.assertEqual(resp['added'], 1)
self.assertEqual(resp['skipped'], 1) self.assertEqual(resp['skipped'], 1)
self.assertEqual(self.store.find_by_iccid('8970119000004600098')['cntr'], '0000000007') imported = self.store.find_by_iccid('8970119000004600098')
self.assertEqual(imported['keysets'][0]['cntr'], '0000000007')
self.assertEqual(presets.keyset_kvn(imported['keysets'][0]), 1)
status, resp = self._post('/api/presets/import', {'presets': 'nope'}) status, resp = self._post('/api/presets/import', {'presets': 'nope'})
self.assertEqual(status, 400, resp) self.assertEqual(status, 400, resp)
@@ -557,7 +565,7 @@ class PresetStoreHttpTests(unittest.TestCase):
patch.stop() patch.stop()
self.assertEqual(status, 200, resp) self.assertEqual(status, 200, resp)
self.assertEqual(resp['final_cntr'], '0000000002') self.assertEqual(resp['final_cntr'], '0000000002')
self.assertEqual(self.store.get(p['id'])['cntr'], '0000000002') self.assertEqual(self.store.find_keyset(p['id'], 1)['cntr'], '0000000002')
def test_a_counter_less_send_ota_body_still_answers(self): def test_a_counter_less_send_ota_body_still_answers(self):
# a pre-built packet without a counter: no final_cntr, no persist, # a pre-built packet without a counter: no final_cntr, no persist,
@@ -581,7 +589,62 @@ class PresetStoreHttpTests(unittest.TestCase):
patch.stop() patch.stop()
self.assertEqual(status, 200, resp) self.assertEqual(status, 200, resp)
self.assertNotIn('final_cntr', resp) self.assertNotIn('final_cntr', resp)
self.assertEqual(self.store.get(p['id'])['cntr'], '0000000001') self.assertEqual(self.store.find_keyset(p['id'], 1)['cntr'], '0000000001')
def test_send_ota_refuses_a_kic_kid_version_mismatch(self):
# TS 102 225 A.2: the versions must be identical - invalid input
self.server.app = object()
status, resp = self._post('/api/send-ota', {
'sp': '00', 'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '25',
'tar': '000000', 'cntr': '0000000001'})
self.assertEqual(status, 400, resp)
self.assertIn('same key version', resp['error'])
def test_send_ota_refuses_an_undefined_key_version(self):
p = self.store.add(self._preset(iccid='8970119000004600098'))
self.server.app = object()
status, resp = self._post('/api/send-ota', {
'sp': '00', 'spi1': '16', 'spi2': '01', 'kic': '35', 'kid': '35',
'tar': '000000', 'cntr': '0000000001', 'preset_id': p['id']})
self.assertEqual(status, 400, resp)
self.assertIn('key version 3 is not defined', resp['error'])
def test_the_counter_lands_in_the_keyset_of_the_packet(self):
p = self.store.add(self._preset(keysets=[
self._keyset(kic='15', kid='15'),
self._keyset(kic='29', kid='29', kicKey='CC', kidKey='DD', cntr='0000000005')]))
patches = [
mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': '027100000000'}),
mock.patch.object(self.srv, '_decode_por', lambda *a, **k: {
'response_status': 'por_ok', 'tar': '000000', 'cntr': '0000000005',
'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': '',
'decoded': {'number_of_commands': 1, 'last_status_word': '9000',
'last_response_data': ''}}),
]
self.server.app = object()
for patch in patches:
patch.start()
try:
status, resp = self._post('/api/send-ota', {
'sp': '00', 'spi1': '16', 'spi2': '01', 'kic': '29', 'kid': '29',
'tar': '000000', 'cntr': '0000000005', 'kicKey': 'CC', 'kidKey': 'DD',
'preset_id': p['id']})
finally:
for patch in reversed(patches):
patch.stop()
self.assertEqual(status, 200, resp)
self.assertEqual(resp['final_cntr'], '0000000006')
self.assertEqual(self.store.find_keyset(p['id'], 2)['cntr'], '0000000006')
self.assertEqual(self.store.find_keyset(p['id'], 1)['cntr'], '0000000001')
def test_a_ram_install_refuses_an_undefined_key_version(self):
p = self.store.add(self._preset(iccid='8970119000004600098'))
status, resp = self._post('/api/ram-install', {
'cap_hex': _mini_cap_hex(), 'kic': '35', 'kid': '35', 'preset_id': p['id']})
self.assertEqual(status, 400, resp)
self.assertIn('key version 3 is not defined', resp['error'])
def test_a_store_write_failure_answers_a_json_500(self): def test_a_store_write_failure_answers_a_json_500(self):
with mock.patch.object(self.store, 'add', side_effect=OSError('read-only file system')): with mock.patch.object(self.store, 'add', side_effect=OSError('read-only file system')):
+160 -51
View File
@@ -2,8 +2,9 @@
The store replaces the localStorage presets; the ICCID normalisation must stay The store replaces the localStorage presets; the ICCID normalisation must stay
byte-compatible with the PWA's `cardsNormIccid` (digits / spaced / raw byte-compatible with the PWA's `cardsNormIccid` (digits / spaced / raw
nibble-swapped EF hex), the counter must only move forward, and the file must nibble-swapped EF hex), every keyset carries its own monotonic counter (one per
survive a corrupt store without silently destroying it. key version, TS 102 225 Annex A.1), and the file must survive a corrupt store
without silently destroying it.
""" """
import json import json
@@ -21,8 +22,14 @@ def make_store(tmp):
return presets.PresetStore(pathlib.Path(tmp) / 'card_presets.json') return presets.PresetStore(pathlib.Path(tmp) / 'card_presets.json')
def keyset(**kw):
ks = {'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB'}
ks.update(kw)
return ks
def preset(**kw): def preset(**kw):
p = {'name': 'C', 'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB'} p = {'name': 'C', 'keysets': [keyset()]}
p.update(kw) p.update(kw)
return p return p
@@ -63,6 +70,16 @@ class CounterTests(unittest.TestCase):
self.assertFalse(presets.counter_ahead('nonsense', '0000000002')) self.assertFalse(presets.counter_ahead('nonsense', '0000000002'))
class KeysetKvnTests(unittest.TestCase):
def test_the_key_version_is_the_high_nibble_of_kic(self):
self.assertEqual(presets.keyset_kvn(keyset(kic='15')), 1)
self.assertEqual(presets.keyset_kvn(keyset(kic='29')), 2)
self.assertEqual(presets.keyset_kvn(keyset(kic='3A')), 3)
self.assertIsNone(presets.keyset_kvn(keyset(kic='')))
self.assertIsNone(presets.keyset_kvn(keyset(kic='5')))
self.assertIsNone(presets.keyset_kvn(None))
class StoreCrudTests(unittest.TestCase): class StoreCrudTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.TemporaryDirectory() self.tmp = tempfile.TemporaryDirectory()
@@ -76,21 +93,56 @@ class StoreCrudTests(unittest.TestCase):
self.assertTrue(p['id']) self.assertTrue(p['id'])
self.assertEqual(p['spi1'], '16') self.assertEqual(p['spi1'], '16')
self.assertEqual(p['spi2'], '01') self.assertEqual(p['spi2'], '01')
self.assertEqual(p['cntr'], '0000000001')
self.assertEqual(p['tar'], '000000') self.assertEqual(p['tar'], '000000')
self.assertEqual(p['uiccTar'], 'B00000') self.assertEqual(p['uiccTar'], 'B00000')
self.assertEqual(p['usimTar'], 'B00001') self.assertEqual(p['usimTar'], 'B00001')
self.assertEqual(p['keysets'][0]['cntr'], '0000000001')
self.assertEqual(presets.keyset_kvn(p['keysets'][0]), 1)
self.assertEqual([x['id'] for x in self.store.list()], [p['id']]) self.assertEqual([x['id'] for x in self.store.list()], [p['id']])
def test_validation_requires_the_scp80_fields(self): def test_a_preset_carries_several_keysets(self):
p = self.store.add(preset(keysets=[keyset(kic='15', kid='15'),
keyset(kic='29', kid='29', cntr='0000000005')]))
self.assertEqual([presets.keyset_kvn(ks) for ks in p['keysets']], [1, 2])
self.assertEqual(self.store.find_keyset(p['id'], 2)['cntr'], '0000000005')
self.assertIsNone(self.store.find_keyset(p['id'], 3))
def test_validation_requires_at_least_one_keyset(self):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=[]))
self.assertIn('keyset', str(ctx.exception))
def test_a_keyset_needs_both_keys(self):
with self.assertRaises(presets.PresetError): with self.assertRaises(presets.PresetError):
self.store.add(preset(name='')) self.store.add(preset(keysets=[keyset(kicKey='')]))
with self.assertRaises(presets.PresetError): with self.assertRaises(presets.PresetError):
self.store.add(preset(kicKey='')) self.store.add(preset(keysets=[keyset(kidKey='')]))
def test_kic_and_kid_must_carry_the_same_key_version(self):
# TS 102 225 A.2: a mismatch is rejected by the card - refuse it here
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=[keyset(kic='15', kid='25')]))
self.assertIn('same key version', str(ctx.exception))
def test_key_version_00_and_duplicates_are_refused(self):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=[keyset(kic='05', kid='05')]))
self.assertIn('01-0F', str(ctx.exception))
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=[keyset(kic='15', kid='15'),
keyset(kic='15', kid='15')]))
self.assertIn('duplicate key version', str(ctx.exception))
# the byte must be a full hex byte
with self.assertRaises(presets.PresetError):
self.store.add(preset(keysets=[keyset(kic='1', kid='1')]))
def test_validation_requires_the_psk_rules(self):
with self.assertRaises(presets.PresetError): with self.assertRaises(presets.PresetError):
self.store.add(preset(pskIdentity='id')) self.store.add(preset(pskIdentity='id'))
with self.assertRaises(presets.PresetError): with self.assertRaises(presets.PresetError):
self.store.add(preset(pskIdentity='id', pskKey='00')) self.store.add(preset(pskIdentity='id', pskKey='00'))
with self.assertRaises(presets.PresetError):
self.store.add(preset(name=''))
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))
@@ -102,41 +154,47 @@ class StoreCrudTests(unittest.TestCase):
self.store.add(preset(name='No ICCID 2')) self.store.add(preset(name='No ICCID 2'))
self.assertEqual(len(self.store.list()), 3) self.assertEqual(len(self.store.list()), 3)
def test_update_is_partial_and_keeps_the_id(self):
p = self.store.add(preset(iccid=DIGITS))
up = self.store.update(p['id'], {'name': 'Renamed', 'cntr': '0000000009'})
self.assertEqual(up['id'], p['id'])
self.assertEqual(up['name'], 'Renamed')
self.assertEqual(up['cntr'], '0000000009')
self.assertEqual(up['kic'], '15')
self.assertIsNone(self.store.update('nope', {'name': 'x'}))
def test_remove(self):
p = self.store.add(preset())
self.assertTrue(self.store.remove(p['id']))
self.assertFalse(self.store.remove(p['id']))
self.assertEqual(self.store.list(), [])
def test_a_caller_supplied_id_never_collides(self): def test_a_caller_supplied_id_never_collides(self):
first = self.store.add(preset()) first = self.store.add(preset())
second = self.store.add(dict(preset(name='B'), id=first['id'])) second = self.store.add(dict(preset(name='B'), id=first['id']))
self.assertNotEqual(second['id'], first['id']) self.assertNotEqual(second['id'], first['id'])
self.assertEqual(len({p['id'] for p in self.store.list()}), 2) self.assertEqual(len({p['id'] for p in self.store.list()}), 2)
def test_update_is_partial_and_keeps_the_id(self):
p = self.store.add(preset(iccid=DIGITS))
up = self.store.update(p['id'], {'name': 'Renamed'})
self.assertEqual(up['id'], p['id'])
self.assertEqual(up['name'], 'Renamed')
self.assertEqual(up['keysets'][0]['kic'], '15')
# the keysets are replaceable as a whole (the Cards tab edit path)
up = self.store.update(p['id'], {'keysets': [keyset(kic='29', kid='29',
cntr='0000000009')]})
self.assertEqual(presets.keyset_kvn(up['keysets'][0]), 2)
self.assertEqual(up['keysets'][0]['cntr'], '0000000009')
self.assertIsNone(self.store.update('nope', {'name': 'x'}))
def test_the_counter_must_be_hex_and_at_most_ten_digits(self): def test_the_counter_must_be_hex_and_at_most_ten_digits(self):
for bad in ('ZZ', '00000000000000000000'): for bad in ('ZZ', '00000000000000000000'):
with self.assertRaises(presets.PresetError) as ctx: with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(cntr=bad)) self.store.add(preset(keysets=[keyset(cntr=bad)]))
self.assertIn('counter', str(ctx.exception)) self.assertIn('counter', str(ctx.exception))
# whitespace is cleaned and a short value is stored fixed-width # whitespace is cleaned and a short value is stored fixed-width
self.assertEqual(self.store.add(preset(cntr='12 34'))['cntr'], '0000001234') p = self.store.add(preset(keysets=[keyset(cntr='12 34')]))
self.assertEqual(self.store.add(preset(cntr='abc'))['cntr'], '0000000ABC') self.assertEqual(p['keysets'][0]['cntr'], '0000001234')
p = self.store.add(preset(keysets=[keyset(cntr='abc')]))
self.assertEqual(p['keysets'][0]['cntr'], '0000000ABC')
def test_update_validates_the_counter_too(self): def test_update_validates_the_counter_too(self):
p = self.store.add(preset()) p = self.store.add(preset())
with self.assertRaises(presets.PresetError): with self.assertRaises(presets.PresetError):
self.store.update(p['id'], {'cntr': 'ZZ'}) self.store.update(p['id'], {'keysets': [keyset(cntr='ZZ')]})
self.assertEqual(self.store.get(p['id'])['cntr'], '0000000001') self.assertEqual(self.store.get(p['id'])['keysets'][0]['cntr'], '0000000001')
def test_remove(self):
p = self.store.add(preset())
self.assertTrue(self.store.remove(p['id']))
self.assertFalse(self.store.remove(p['id']))
self.assertEqual(self.store.list(), [])
def test_find_by_iccid(self): def test_find_by_iccid(self):
p = self.store.add(preset(iccid=DIGITS)) p = self.store.add(preset(iccid=DIGITS))
@@ -148,7 +206,9 @@ class StoreCrudTests(unittest.TestCase):
def test_callers_get_copies(self): def test_callers_get_copies(self):
p = self.store.add(preset()) p = self.store.add(preset())
p['name'] = 'mutated' p['name'] = 'mutated'
p['keysets'][0]['cntr'] = 'mutated'
self.assertEqual(self.store.get(p['id'])['name'], 'C') self.assertEqual(self.store.get(p['id'])['name'], 'C')
self.assertEqual(self.store.get(p['id'])['keysets'][0]['cntr'], '0000000001')
items = self.store.list() items = self.store.list()
items[0]['name'] = 'mutated' items[0]['name'] = 'mutated'
self.assertEqual(self.store.list()[0]['name'], 'C') self.assertEqual(self.store.list()[0]['name'], 'C')
@@ -163,39 +223,65 @@ class StoreCounterTests(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.tmp.cleanup() self.tmp.cleanup()
def counter(self, kvn=1):
return self.store.find_keyset(self.p['id'], kvn)['cntr']
def test_set_counter_advances_and_ignores_stale_values(self): def test_set_counter_advances_and_ignores_stale_values(self):
self.store.set_counter(self.p['id'], '0000000005', 'send-ota') self.store.set_counter(self.p['id'], '0000000005', 'send-ota', kvn=1)
self.assertEqual(self.store.get(self.p['id'])['cntr'], '0000000005') self.assertEqual(self.counter(), '0000000005')
# a stale caller can never regress the counter # a stale caller can never regress the counter
self.store.set_counter(self.p['id'], '0000000003', 'send-ota') self.store.set_counter(self.p['id'], '0000000003', 'send-ota', kvn=1)
self.assertEqual(self.store.get(self.p['id'])['cntr'], '0000000005') self.assertEqual(self.counter(), '0000000005')
# equal values are a no-op # equal values are a no-op
self.store.set_counter(self.p['id'], '0000000005', 'send-ota', kvn=1)
self.assertEqual(self.counter(), '0000000005')
def test_each_key_version_has_its_own_counter(self):
# TS 102 225 A.1: a dedicated counter per key version
p = self.store.update(self.p['id'], {'keysets': [
keyset(kic='15', kid='15', cntr='0000000002'),
keyset(kic='29', kid='29', cntr='0000000007')]})
self.store.set_counter(p['id'], '0000000003', 'send-ota', kvn=1)
self.assertEqual(self.counter(1), '0000000003')
self.assertEqual(self.counter(2), '0000000007') # untouched
self.store.set_counter(p['id'], '0000000008', 'send-ota', kvn=2)
self.assertEqual(self.counter(1), '0000000003')
self.assertEqual(self.counter(2), '0000000008')
def test_an_unknown_key_version_changes_nothing(self):
self.store.set_counter(self.p['id'], '0000000005', 'send-ota', kvn=3)
self.assertEqual(self.counter(1), '0000000001')
def test_kvn_none_uses_the_only_keyset(self):
self.store.set_counter(self.p['id'], '0000000005', 'send-ota') self.store.set_counter(self.p['id'], '0000000005', 'send-ota')
self.assertEqual(self.store.get(self.p['id'])['cntr'], '0000000005') self.assertEqual(self.counter(1), '0000000005')
# ... and is refused (logged) when several keysets exist
p = self.store.update(self.p['id'], {'keysets': [
keyset(kic='15', kid='15'), keyset(kic='29', kid='29')]})
self.store.set_counter(p['id'], '0000000009', 'send-ota')
self.assertEqual(self.counter(1), '0000000001')
def test_set_counter_allows_a_wrapped_value(self): def test_set_counter_allows_a_wrapped_value(self):
self.store.update(self.p['id'], {'cntr': 'FFFFFFFFFF'}) self.store.update(self.p['id'], {'keysets': [keyset(cntr='FFFFFFFFFF')]})
self.store.set_counter(self.p['id'], '0000000000', 'send-ota') self.store.set_counter(self.p['id'], '0000000000', 'send-ota', kvn=1)
self.assertEqual(self.store.get(self.p['id'])['cntr'], '0000000000') self.assertEqual(self.counter(), '0000000000')
def test_set_counter_on_unknown_id_is_none(self): def test_set_counter_on_unknown_id_is_none(self):
self.assertIsNone(self.store.set_counter('nope', '0000000002')) self.assertIsNone(self.store.set_counter('nope', '0000000002', kvn=1))
def test_set_counter_ignores_a_malformed_value(self): def test_set_counter_ignores_a_malformed_value(self):
self.store.set_counter(self.p['id'], 'ZZ', 'send-ota') for bad in ('ZZ', '00000000000000000000', ''):
self.assertEqual(self.store.get(self.p['id'])['cntr'], '0000000001') self.store.set_counter(self.p['id'], bad, 'send-ota', kvn=1)
self.store.set_counter(self.p['id'], '00000000000000000000', 'send-ota') self.assertEqual(self.counter(), '0000000001')
self.assertEqual(self.store.get(self.p['id'])['cntr'], '0000000001')
self.store.set_counter(self.p['id'], '', 'send-ota')
self.assertEqual(self.store.get(self.p['id'])['cntr'], '0000000001')
def test_counter_changes_are_audited(self): def test_counter_changes_are_audited_with_the_key_version(self):
self.store.set_counter(self.p['id'], '0000000005', 'send-ota') self.store.set_counter(self.p['id'], '0000000005', 'send-ota', kvn=1)
lines = self.store.audit_path.read_text(encoding='utf-8').strip().splitlines() lines = self.store.audit_path.read_text(encoding='utf-8').strip().splitlines()
self.assertEqual(len(lines), 1) self.assertEqual(len(lines), 1)
entry = json.loads(lines[0]) entry = json.loads(lines[0])
self.assertEqual(entry['old'], '0000000001') self.assertEqual(entry['old'], '0000000001')
self.assertEqual(entry['new'], '0000000005') self.assertEqual(entry['new'], '0000000005')
self.assertEqual(entry['kvn'], 1)
self.assertEqual(entry['source'], 'send-ota') self.assertEqual(entry['source'], 'send-ota')
@@ -208,19 +294,35 @@ 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,
store.set_counter(p['id'], '0000000004', 'send-ota') keysets=[keyset(kic='15', kid='15'),
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')
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.get(p['id'])['cntr'], '0000000004') self.assertEqual(again.find_keyset(p['id'], 2)['cntr'], '0000000004')
def test_a_v380_flat_preset_loads_as_one_keyset(self):
path = pathlib.Path(self.tmp.name) / 'card_presets.json'
path.write_text(json.dumps({'version': 1, 'presets': [
{'name': 'Old', 'iccid': DIGITS, 'kic': '29', 'kid': '29',
'kicKey': '11', 'kidKey': '22', 'cntr': '0000000007',
'spi1': '16', 'spi2': '01'}]}), encoding='utf-8')
store = make_store(self.tmp.name)
p = store.list()[0]
self.assertEqual(len(p['keysets']), 1)
self.assertEqual(presets.keyset_kvn(p['keysets'][0]), 2)
self.assertEqual(p['keysets'][0]['cntr'], '0000000007')
self.assertEqual(p['keysets'][0]['kicKey'], '11')
self.assertNotIn('cntr', p)
def test_no_temporary_files_are_left_behind(self): def test_no_temporary_files_are_left_behind(self):
store = make_store(self.tmp.name) store = make_store(self.tmp.name)
p = store.add(preset()) p = store.add(preset())
store.add(preset(name='Second')) store.add(preset(name='Second'))
store.set_counter(p['id'], '0000000005', 'send-ota') # writes the audit file store.set_counter(p['id'], '0000000005', 'send-ota', kvn=1)
leftovers = [f.name for f in pathlib.Path(self.tmp.name).iterdir() leftovers = [f.name for f in pathlib.Path(self.tmp.name).iterdir()
if f.name not in ('card_presets.json', 'card_presets.json.audit.jsonl')] if f.name not in ('card_presets.json', 'card_presets.json.audit.jsonl')]
self.assertEqual(leftovers, []) self.assertEqual(leftovers, [])
@@ -249,7 +351,8 @@ class StoreImportTests(unittest.TestCase):
self.tmp.cleanup() self.tmp.cleanup()
def test_import_accepts_the_old_localstorage_shape(self): def test_import_accepts_the_old_localstorage_shape(self):
# an export from the browser: no ids, no defaults filled in # an export from the browser (v3.8.0 or the localStorage era): flat
# key fields, no ids, no defaults filled in
old = [{'name': 'A', 'iccid': DIGITS, 'kic': '15', 'kid': '15', old = [{'name': 'A', 'iccid': DIGITS, 'kic': '15', 'kid': '15',
'kicKey': 'AA', 'kidKey': 'BB', 'cntr': '0000000007'}, 'kicKey': 'AA', 'kidKey': 'BB', 'cntr': '0000000007'},
{'name': 'B', 'iccid': '', 'kic': '25', 'kid': '25', {'name': 'B', 'iccid': '', 'kic': '25', 'kid': '25',
@@ -258,14 +361,20 @@ class StoreImportTests(unittest.TestCase):
self.assertEqual(res['added'], 2) self.assertEqual(res['added'], 2)
self.assertEqual(res['skipped'], 0) self.assertEqual(res['skipped'], 0)
a = self.store.find_by_iccid(DIGITS) a = self.store.find_by_iccid(DIGITS)
self.assertEqual(a['cntr'], '0000000007') self.assertEqual(a['keysets'][0]['cntr'], '0000000007')
self.assertEqual(presets.keyset_kvn(a['keysets'][0]), 1)
self.assertTrue(a['id']) self.assertTrue(a['id'])
def test_import_takes_the_keyset_shape_too(self):
res = self.store.import_presets([preset(iccid=DIGITS)])
self.assertEqual(res['added'], 1)
self.assertEqual(self.store.find_by_iccid(DIGITS)['keysets'][0]['kic'], '15')
def test_import_skips_duplicates_and_reports_invalid_entries(self): def test_import_skips_duplicates_and_reports_invalid_entries(self):
self.store.add(preset(iccid=DIGITS)) self.store.add(preset(iccid=DIGITS))
res = self.store.import_presets([ res = self.store.import_presets([
preset(iccid=RAW_HEX), # same card in raw form preset(iccid=RAW_HEX), # same card in raw form
{'name': 'Broken'}, # no KIc/KID/keys {'name': 'Broken'}, # no keyset at all
'not a dict', 'not a dict',
]) ])
self.assertEqual(res['added'], 0) self.assertEqual(res['added'], 0)