feat: TAR probe + the SPI1 counter-tracking rule; keyless packets build again (v3.8.0)

Three things learned from probing a live card, plus the capability they enabled:

1. Counter tracking follows SPI1.b5b4 (TS 102 225 5.1.1): with 00 the counter
   is "present, ignored, never updated", so a packet without a counter check
   must leave every counter untouched.  _counter_tracked() now guards
   /api/send-ota (no final_cntr, no persistence, a log line), the RAM step
   sender and the test runner; _preset_counter_persist() requires a non-zero
   key version (the "single keyset" fallback could advance a keyset spuriously
   for a keyless send).  The PWA advances the form counter only when the server
   reports final_cntr and says "counter not tracked" otherwise.

2. The spec's no-security form builds again: _ota_keyset() refused the valid
   KIc/KID '00' (TS 102 225 A.2: '00' is legal with SPI1.b3=0 for KIc and
   SPI1.b2b1=00 for KID) because pySim wants an algorithm.  An unknown nibble
   is now refused only when the algorithm is actually used (ciphering / a
   ciphered PoR for KIc, RC/CC/DS / a MAC'd PoR for KID) and 'implicit' codes
   the nibble as 0 otherwise - POST /api/send-ota {apdu, spi1:"00", kic:"00",
   kid:"00"} now builds 0D00010000B0000000000000010000A40000, the exact packet
   the card accepted (a fake 15/15 was refused with 6200).  A no-security PoR
   is decodable again too, and a requested-but-missing PoR sets por_missing
   on the plain path as well.

3. TAR probe (POST /api/tar-probe, RAM op "Probe TARs"): one secured packet per
   TAR - the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1
   (TAR_PROBE_TARS), or a custom list - carrying a harmless C-APDU (SELECT MF)
   with the preset's keyset and SPI 16/01.  Each TAR is classified
   (registered / no_answer / no_por / refused) from the ENVELOPE verdict and
   the application's PoR, counters advance and persist normally, and the run is
   refused when SPI1 has no counter check.  Live on a card: 000000, B00000 and
   B00001 registered (compact-format applications, answering 6D00 / 6B00),
   the expanded-format TARs, USAT, multiplexing and CASD refused with 6200 -
   with the keyless form the same card accepted every TAR and answered no PoR.

Docs (api.md, help EN/RU, AGENTS), sw cache simple-v337.  Tests: 618 Python
(no-security build, the algorithm-only-when-used rule, the tracking rule, the
persist guard, the probe classification) and 736 Node (the probe renderer and
wiring, spCounterTracked).
This commit is contained in:
2026-09-30 21:17:59 +03:00
parent 386958b261
commit 6eeb8dacb3
10 changed files with 615 additions and 31 deletions
+40
View File
@@ -36,6 +36,7 @@ a 3.x PWA).
| `/api/presets/update` | POST | Update a preset (`{id, fields}`; the counter may be raised) |
| `/api/presets/delete` | POST | Delete a preset (`{id}`) |
| `/api/presets/import` | POST | Import presets (`{presets: [...], mode: merge\|replace}`) |
| `/api/tar-probe` | POST | Probe the card's registered OTA applications (one secured packet per TAR, TS 101 220 Annex D) |
| `/api/esim/chip` | GET | eUICC chip details (EID, EUICCInfo1/2, configured addresses) |
| `/api/esim/profiles` | GET | Installed eSIM profiles with their metadata |
| `/api/esim/notifications` | GET | Pending eSIM notifications (read-only) |
@@ -245,6 +246,35 @@ import also accepts presets exported by the older localStorage-based builds.
`"replace"`) → `{"ok": true, "added": N, "skipped": M, "errors": […]}`;
invalid entries and duplicates are skipped and reported, never fatal.
### `POST /api/tar-probe`
Probe which OTA applications the card has registered: one **secured packet per
TAR** (the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1 unless
a `tars` list is given), each carrying a harmless C-APDU (`SELECT MF` by
default), with the preset's keyset and SPI `16/01` (counter check + PoR).
Every accepted packet consumes a counter, which is persisted into the keyset
like any other operation, so the probe is refused when the SPI1 has no counter
check (b5b4 = 00) or the named preset does not define the key version.
```json
{"preset_id": "…", "kic": "25", "kid": "25", "kicKey": "…", "kidKey": "…",
"cntr": "0000000187", "apdu": "00A40000023F00", "tars": ["000000", "B00000"]}
```
**Response:** `{"success": true, "registered": N, "total": M,
"final_cntr": "…", "kvn": 2, "spi1": "16", "spi2": "01",
"results": [{"tar": "000000", "label": "Issuer Security Domain (compact)",
"verdict": "registered", "sw": "9000", "por_status": "por_ok",
"por_sw": "6D00", "por_data": ""}], "steps": [...]}`
Verdicts: `registered` (ENVELOPE 9000 + a PoR carrying the application's own
status word/data), `no_answer` (accepted, PoR without an R-APDU), `no_por`
(accepted, no PoR at all — the packet was silently dropped), `refused` (a
non-9000 ENVELOPE, typically `6200` for a TAR without a registered
application). Observed on a live card: `000000`, `B00000` and `B00001`
registered (compact-format applications), the expanded-format TARs, USAT,
multiplexing and CASD refused.
### eSIM / LPA (local ES10 operations)
These endpoints work only when the equipped card is an eUICC (SGP.22/32);
@@ -388,6 +418,16 @@ 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.
**Counter tracking follows SPI1.b5b4** (TS 102 225 §5.1.1): with `00` the
counter field is "present, ignored, never updated", so a packet whose SPI1 has
no counter check (the keyless `SPI1 00` form) reports **no** `final_cntr` and
persists nothing — a keyless send must leave every preset counter untouched.
The spec's no-security KIc/KID values `00/00` are accepted when the SPI says
no ciphering / no RC-CC-DS (A.2), so `{apdu, spi1: "00", kic: "00", kid: "00"}`
builds a keyless packet (the card accepted exactly that form; a fake `15/15`
was refused with `6200`). When a PoR was requested and the card did not
answer, the response carries `por_missing: true`.
**Request body:**
```json
{