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 (KIc/KID + keys, SPI, per-target TARs, counter, PSK pair, ADM)
live on the server in `~/.pysim-simple-server/card_presets.json`
(`--card-presets PATH` overrides it; `Path.home()` resolves the same way on
Linux, macOS and Windows). The file is written atomically and every counter
change is appended to `card_presets.json.audit.jsonl`.
Card presets (keysets, SPI, per-target TARs, PSK pair, ADM) live on the server
in `~/.pysim-simple-server/card_presets.json` (`--card-presets PATH` overrides
it; `Path.home()` resolves the same way on Linux, macOS and Windows). The file
is written atomically and every counter change is appended to
`card_presets.json.audit.jsonl`.
The store is the source of truth for the SCP80 counter: the operations below
accept a `preset_id` and **persist the counter they consumed themselves**
(monotonic - a stale value never regresses it), so a closed tab, a lost
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).
**Keysets.** A preset 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, there is no separate field:
- `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).
- `POST /api/presets` — body = the preset fields; returns `{"ok": true, "preset": {…}}`.
A duplicate ICCID (digits, spaced or raw EF hex are normalised to one form)
is refused with `400 {"error": "card with this ICCID already exists: …"}`.
- `POST /api/presets` — body = the preset fields (`keysets` included); returns
`{"ok": true, "preset": {…}}`. A duplicate ICCID (digits, spaced or raw EF
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;
a plain `{id, name, …}` body works too). The counter is written as given -
this is the deliberate human edit; unknown ids answer `404`.
a plain `{id, name, …}` body works too, and `keysets` replaces the list).
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/import` — `{"presets": […], "mode": "merge"}` (or
`"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.
A request without a `cntr` (nothing to advance) simply reports no
`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
response or a closed tab cannot lose the increment.
the counter into the keyset of the key version the packet used (see *Card
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:**
```json