v3.20.0: the server-owned SCP80 counter (an untouched field = the preset's next value)

The counter an operation consumed was re-entered by the PWA on every send
(the form field was filled from the preset and posted back), so a stale tab
could send a value the card rejects (cntr_low), and typing in the field
silently wrote the preset.  Now the store is the single source of truth and
the form field is a per-send override (Option B).

- _preset_counter_seed: an explicit `cntr` in the request wins (a hand send,
  the counter probe or a pre-built packet may deliberately pick one);
  otherwise the preset store's value for the packet's keyset.  Wired into
  /api/send-ota (the APDU path; the pre-built `sp` path keeps the caller's
  bookkeeping value - the packet carries its own counter), /api/ram-install,
  /api/ram-install-app, /api/cap-compat, /api/tar-probe and /api/sp-verify
  (the reference must use the counter the JS packet was built with).
- the send-ota response echoes the effective `cntr` (a caller that omitted
  one learns what was used) and the OTA SEND log line prints it.
- persistence is unchanged and monotonic (presets.set_counter refuses
  backwards writes), pinned by tests: an explicit lower counter is used for
  the packet but never lowers the store, a higher accepted one is saved.
- PWA: the Secured packet counter field starts empty with the preset's next
  value as its placeholder (auto: <cntr>); typing affects that send only and
  is never written to the preset (spCntrSyncPreset is gone), spParams /
  getRamSpParams omit an empty counter, the RAM/send flows sync only the
  local preset copy (forward-only) instead of filling the field, the +/-
  buttons start from the effective value, and the preview/verify packet is
  built from the effective counter (genSp).  Placeholder translated EN/RU.
- the runner and /api/counter-probe keep their existing seeding (the probe
  already starts at the failed value and walks above it).
- docs: api.md's optional-`cntr` contract + seeding notes on every endpoint,
  help EN/RU 3.1.  Tests: 822 frontend + 734 Python; sw cache simple-v393.
This commit is contained in:
2026-10-05 22:22:50 +03:00
parent 0e06b918a7
commit 85cc4b9535
14 changed files with 331 additions and 101 deletions
+27 -7
View File
@@ -341,7 +341,9 @@ resolves to neither is refused with a hint **before any packet is sent**. The
resolved SPI1 must carry a counter check (b5b4 ≠ 00) and ask for a PoR. resolved SPI1 must carry a counter check (b5b4 ≠ 00) and ask for a PoR.
Every accepted packet consumes a counter, which is persisted into the keyset 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 like any other operation, so the probe is refused when the SPI1 has no counter
check or the named preset does not define the keyset number. check or the named preset does not define the keyset number. A missing
`cntr` is seeded from that keyset (v3.20.0; an explicit value wins but never
lowers the stored counter).
```json ```json
{"preset_id": "…", "kic": "25", "kid": "25", "kicKey": "…", "kidKey": "…", {"preset_id": "…", "kic": "25", "kid": "25", "kicKey": "…", "kidKey": "…",
@@ -506,8 +508,24 @@ scripts) must not be wrapped.
`final_cntr` is reported for the plain `sp` path too (a pre-built packet): `final_cntr` is reported for the plain `sp` path too (a pre-built packet):
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
`final_cntr` — it never fails the send. With `preset_id` the server persists **`cntr` is optional (v3.20.0)** — the preset store is the source of truth:
- with `apdu` (the server builds the packet) a missing `cntr` is seeded from
the named preset's keyset (the one the packet's KIc/KID resolve to); the
effective value is echoed back as `cntr`, so a caller that omits it learns
what was used;
- an explicit `cntr` always wins — the packet uses it verbatim (a hand send,
the counter probe or a pre-built `sp` may deliberately pick one);
- the store only moves **forward**: an explicit value (or its next counter)
below the stored one is used for the packet but never lowers the store; a
higher accepted value is persisted;
- the `sp` (pre-built packet) path never seeds — the packet carries its own
counter — so pass `cntr` with it to get `final_cntr` / persistence. A
request with neither simply reports no `final_cntr` — it never fails the
send.
With `preset_id` the server persists
the counter into the keyset the packet used (see *Card the counter into the keyset the packet used (see *Card
presets*), so a lost response or a closed tab cannot lose the increment; the presets*), so a lost response or a closed tab cannot lose the increment; the
request is refused when KIc/KID carry different keyset numbers (TS 102 225 A.2) request is refused when KIc/KID carry different keyset numbers (TS 102 225 A.2)
@@ -771,7 +789,7 @@ Clears a finished run report (409 while a run is active).
### `POST /api/ram-install` ### `POST /api/ram-install`
Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE; the PoR verdict and the remote command's own status word are both checked and the sequence aborts on the first failure (a non-`por_ok` PoR, a remote SW outside the success set, or an undecodable PoR). The counter advances only for a packet the card accepted (PoR `por_ok`); `final_cntr` is returned on success **and** on failure, so the caller keeps the card's consumed counter (a rejected packet leaves it unchanged). The RAM command format is detected per operation: a read-only `GET STATUS [ISD]` probe (`FORMAT CHECK (compact)`, then `FORMAT CHECK (expanded)`) decides between the compact C-APDU and the expanded `AA`/`22` form (TS 102 226 §5.2.1), and every INSTALL/LOAD step of the chain then uses the detected format — the response carries `ram_format`. Pass `ram_format` (`compact`/`expanded`) to pin it. The **NV footprint** is measured around the chain: a best-effort `GET DATA FF21` (Extended Card Resources, TS 102 226 §8.2.1.7.2) read before the first step and again after the last one (also on failure) reports the free non-volatile memory and its delta — the read is silent (no step record, no failure) and a card without FF21 simply yields no fields. The `.cap` archive (a ZIP of nested components) is parsed server-side in `_cap_parse`; no external tooling is required. Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE; the PoR verdict and the remote command's own status word are both checked and the sequence aborts on the first failure (a non-`por_ok` PoR, a remote SW outside the success set, or an undecodable PoR). The counter advances only for a packet the card accepted (PoR `por_ok`); `final_cntr` is returned on success **and** on failure, so the caller keeps the card's consumed counter (a rejected packet leaves it unchanged). A missing `cntr` is seeded from the preset's keyset (v3.20.0); an explicit value wins but never lowers the stored counter (only forward moves are persisted). The RAM command format is detected per operation: a read-only `GET STATUS [ISD]` probe (`FORMAT CHECK (compact)`, then `FORMAT CHECK (expanded)`) decides between the compact C-APDU and the expanded `AA`/`22` form (TS 102 226 §5.2.1), and every INSTALL/LOAD step of the chain then uses the detected format — the response carries `ram_format`. Pass `ram_format` (`compact`/`expanded`) to pin it. The **NV footprint** is measured around the chain: a best-effort `GET DATA FF21` (Extended Card Resources, TS 102 226 §8.2.1.7.2) read before the first step and again after the last one (also on failure) reports the free non-volatile memory and its delta — the read is silent (no step record, no failure) and a card without FF21 simply yields no fields. The `.cap` archive (a ZIP of nested components) is parsed server-side in `_cap_parse`; no external tooling is required.
**Request body:** **Request body:**
```json ```json
@@ -890,13 +908,13 @@ Run the single **INSTALL [for install]** (or **INSTALL [for make selectable]**)
| `install_params` / `stk_params` | no | As in `/api/ram-install` (the PWA composes `C9`+`EF`+raw and appends the STK part) | | `install_params` / `stk_params` | no | As in `/api/ram-install` (the PWA composes `C9`+`EF`+raw and appends the STK part) |
| `ram_format` | no | As in `/api/ram-install`: `auto` (default — a read-only probe step decides), `compact` or `expanded` | | `ram_format` | no | As in `/api/ram-install`: `auto` (default — a read-only probe step decides), `compact` or `expanded` |
**Response:** `{"success": bool, "steps": [...], "final_cntr": "...", "ram_format": "...", "error": "...", "failed_step": N}` — the same step records as `/api/ram-install`. **Response:** `{"success": bool, "steps": [...], "final_cntr": "...", "ram_format": "...", "error": "...", "failed_step": N}` — the same step records as `/api/ram-install`. A missing `cntr` is seeded from the preset's keyset (v3.20.0; an explicit value wins but never lowers the stored counter).
### `POST /api/cap-compat` ### `POST /api/cap-compat`
Run the **CAP compatibility test**: the format check, `INSTALL [for load]` and the LOAD blocks only up to the block that completes the **Import** component (the point where the JCRE verifies the import list, JC VM spec 4.5.2), then stop — no last-block flag and no `INSTALL [for install]`, so nothing is committed and nothing has to be deleted afterwards. The verdict (`imports_ok`) covers the **LOAD/import gate only**; the link gate is what a real install checks at `INSTALL [for install]`. Run the **CAP compatibility test**: the format check, `INSTALL [for load]` and the LOAD blocks only up to the block that completes the **Import** component (the point where the JCRE verifies the import list, JC VM spec 4.5.2), then stop — no last-block flag and no `INSTALL [for install]`, so nothing is committed and nothing has to be deleted afterwards. The verdict (`imports_ok`) covers the **LOAD/import gate only**; the link gate is what a real install checks at `INSTALL [for install]`.
**Request body:** as `/api/ram-install` (`cap_hex`, the SCP80 fields, optional `load_block_size` and `ram_format`), plus: **Request body:** as `/api/ram-install` (`cap_hex`, the SCP80 fields, optional `load_block_size` and `ram_format`) — a missing `cntr` is seeded from the preset's keyset (v3.20.0) — plus:
| Field | Req | Description | | Field | Req | Description |
|---|---|---| |---|---|---|
@@ -911,7 +929,9 @@ Run the **CAP compatibility test**: the format check, `INSTALL [for load]` and t
Cross-check a secured packet against pySim's `OtaDialectSms.encode_cmd` Cross-check a secured packet against pySim's `OtaDialectSms.encode_cmd`
reference. Returns the JS-generated packet, pySim reference, a match flag, reference. Returns the JS-generated packet, pySim reference, a match flag,
and the decoded SPI fields. and the decoded SPI fields. A missing `cntr` is seeded from the named
preset's keyset (v3.20.0) — the same rule the PWA's builder uses, so a
reference for a packet built from an untouched counter field matches.
```json ```json
{"spi1": "16", "spi2": "01", "kic": "15", "kid": "15", "tar": "b00000", {"spi1": "16", "spi2": "01", "kic": "15", "kid": "15", "tar": "b00000",
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -281,7 +281,7 @@
<p class="mb-3">The <strong>SCP80</strong> top-level tab groups the SCP80-related views. It is switched by three pills: <strong>Secured Packet</strong>, <strong>RAM</strong> and <strong>TAR probe</strong>. Assembles SCP80 secured packets per ETSI TS 102 225. When a card is equipped the server reads its <strong>EF.ICCID</strong> (2FE2); if a card preset carries the same number (digits, or the raw EF hex), it is selected automatically in all three SCP80 views.</p> <p class="mb-3">The <strong>SCP80</strong> top-level tab groups the SCP80-related views. It is switched by three pills: <strong>Secured Packet</strong>, <strong>RAM</strong> and <strong>TAR probe</strong>. Assembles SCP80 secured packets per ETSI TS 102 225. When a card is equipped the server reads its <strong>EF.ICCID</strong> (2FE2); if a card preset carries the same number (digits, or the raw EF hex), it is selected automatically in all three SCP80 views.</p>
<h3 id="secured-packet" class="text-lg font-medium mb-2">3.1 Secured Packet</h3> <h3 id="secured-packet" class="text-lg font-medium mb-2">3.1 Secured Packet</h3>
<p class="mb-2">Builds SCP80 secured packets per ETSI TS 102 225. The <strong>Keyset</strong> selector next to the card preset selector picks which keyset of the preset supplies the keys and the counter; the send is refused when the preset does not define that keyset number or when its KIc/KID carry different numbers (TS 102 225 A.2). It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected. The packet's TAR field suggests the preset's TAR entries (each with its description and MSL); applying a preset sets the operation's TAR and seeds the SPI1 from that TAR's <strong>MSL</strong> (Minimum SPI1, TS 102 226 &sect;8.2.1.3.2.4 &mdash; the card checks it before the security processing and answers <code class="font-mono text-sm">0A</code> when the packet's SPI1 is below it). A value below the MSL shows an amber warning under the form; a hand-typed TAR keeps the operator's SPI1.</p> <p class="mb-2">Builds SCP80 secured packets per ETSI TS 102 225. The <strong>Keyset</strong> selector next to the card preset selector picks which keyset of the preset supplies the keys and the counter; the send is refused when the preset does not define that keyset number or when its KIc/KID carry different numbers (TS 102 225 A.2). It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected. The packet's TAR field suggests the preset's TAR entries (each with its description and MSL); applying a preset sets the operation's TAR and seeds the SPI1 from that TAR's <strong>MSL</strong> (Minimum SPI1, TS 102 226 &sect;8.2.1.3.2.4 &mdash; the card checks it before the security processing and answers <code class="font-mono text-sm">0A</code> when the packet's SPI1 is below it). A value below the MSL shows an amber warning under the form; a hand-typed TAR keeps the operator's SPI1. The <strong>Counter</strong> field is an override: it starts empty and shows the preset's next counter as its placeholder &mdash; an empty field lets the server seed the packet from the stored counter and persist the value the card accepted; a typed value is used for that send only, is never written to the preset, and a lower one never rewinds the stored counter (only forward moves are persisted).</p>
<h4 id="packet-structure" class="font-medium mb-1">Packet structure</h4> <h4 id="packet-structure" class="font-medium mb-1">Packet structure</h4>
<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">Size</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">Size</th><th class="text-left py-1 px-2">Description</th></tr></thead>
@@ -310,7 +310,7 @@
<li>AES requires a replay-protected counter: SPI1 bits b5&nbsp;b4 must be <code class="font-mono text-sm">10</code> (counter higher) or <code class="font-mono text-sm">11</code> (counter +1) per TS 102 225 &sect;5.1.2/&sect;5.1.3.1</li> <li>AES requires a replay-protected counter: SPI1 bits b5&nbsp;b4 must be <code class="font-mono text-sm">10</code> (counter higher) or <code class="font-mono text-sm">11</code> (counter +1) per TS 102 225 &sect;5.1.2/&sect;5.1.3.1</li>
<li>Padding byte configurable (<code class="font-mono text-sm">00</code> default, or <code class="font-mono text-sm">FF</code>)</li> <li>Padding byte configurable (<code class="font-mono text-sm">00</code> default, or <code class="font-mono text-sm">FF</code>)</li>
</ul> </ul>
<p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim&rsquo;s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a <strong>concatenated</strong> download (TS 31.115 &sect;4.3) &mdash; the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card&rsquo;s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command&rsquo;s status word and response data are filled into the <strong>R-APDU parser</strong> panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check &mdash; b5 b4 &ne; 00; without one no counter is advanced or saved) and clears the packet. A <strong>low-counter</strong> verdict (<code class="font-mono text-sm">cntr_low</code>, or a warning ENVELOPE answer that still carries such a PoR) is <em>not</em> recoverable by retrying: every SCP80/RAM operation stops immediately. The card never reports its counter (the PoR&rsquo;s CNTR field is a copy of the packet&rsquo;s), so the notice offers a bounded <strong>Sync counter</strong> probe &mdash; a read-only command with increasing counter values (doubling steps, a 32-bit ceiling, at most 40 packets) until the card accepts one, then accepted+1 is stored (a search stopped by a security verdict after a counter rejection saves the value too, with an amber warning to check the security settings) &mdash; and a <strong>Go to preset</strong> button for raising the counter by hand; nothing else is written automatically.</p> <p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim&rsquo;s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a <strong>concatenated</strong> download (TS 31.115 &sect;4.3) &mdash; the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card&rsquo;s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command&rsquo;s status word and response data are filled into the <strong>R-APDU parser</strong> panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check &mdash; b5 b4 &ne; 00; without one no counter is advanced or saved) and clears the packet &mdash; the server persists the accepted counter into the preset, the form&rsquo;s counter field stays empty (its placeholder follows the stored value) unless the operator typed an override. A <strong>low-counter</strong> verdict (<code class="font-mono text-sm">cntr_low</code>, or a warning ENVELOPE answer that still carries such a PoR) is <em>not</em> recoverable by retrying: every SCP80/RAM operation stops immediately. The card never reports its counter (the PoR&rsquo;s CNTR field is a copy of the packet&rsquo;s), so the notice offers a bounded <strong>Sync counter</strong> probe &mdash; a read-only command with increasing counter values (doubling steps, a 32-bit ceiling, at most 40 packets) until the card accepts one, then accepted+1 is stored (a search stopped by a security verdict after a counter rejection saves the value too, with an amber warning to check the security settings) &mdash; and a <strong>Go to preset</strong> button for raising the counter by hand; nothing else is written automatically.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3> <h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3>
<p class="mb-2">Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the <strong>Cards</strong> tab provides the keys, the TAR table and the counter (RAM uses the preset's <strong>ISD TAR</strong>, <code class="font-mono text-sm">000000</code> by default; the SPI1 is derived from that TAR's MSL). The <strong>Keyset</strong> selector next to the preset selector picks the keyset whose keys and counter the operation uses. It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected. The <strong>Format</strong> selector pins the remote command format: <strong>Auto (detect)</strong> probes the read-only GET STATUS command compact and in both expanded codings and keeps the first that the card executes, <strong>Compact</strong> sends the bare C-APDU, and <strong>Expanded (AA, definite length)</strong> / <strong>Expanded (AE 80, indefinite length)</strong> wrap it in the TS 102 226 &sect;5.2.1 scripting template (both execute on the live ISD; a template whose declared lengths do not match its body draws a Bad format TLV, which the operation shows as the step failure). The listing and registry queries follow that format: compact listings keep the chained GET RESPONSE, while an expanded run sends the same P2=00 queries wrapped and unchained (the reference TCA loader&rsquo;s form, Le=&rsquo;00&rsquo; returns the listing directly), and a registry query the card refuses in the current format is retried in the other formats &mdash; the report names the one that served it. A <strong>next-occurrence page</strong> (<code class="font-mono text-sm">P2=03</code>) that a format refuses is retried in the other formats too &mdash; the expanded codings first (the serving one first when it is expanded), the compact command string last, because a compact page attempt can consume the page without delivering it (the card answers <code class="font-mono text-sm">61xx</code> and the chained GET RESPONSE fails; every later format then sees the occurrence already gone) &mdash; some cards serve the expanded next page only wrapped in the <code class="font-mono text-sm">AA</code> template &mdash; and a listing is marked <code class="font-mono text-sm">(first page only)</code> only when the card promised more (<code class="font-mono text-sm">6310</code>) and no format could fetch the follow-up; in <strong>Auto</strong> the explorer also queries entries the bulk listing left without details individually by AID (a few extra packets). The card-resources block also names the card&rsquo;s inferred <strong>ETSI release</strong> (<code class="font-mono text-sm">uicc.toolkit</code> version &rarr; REL-N, the same mapping as the CAP analysis; shown only when the expanded registry returned the versions). A package whose own listing carries no version derives it from its applet instance when one exists &mdash; the package&rsquo;s module AIDs are the applet instance AIDs, and the row is annotated as coming from the app instance &mdash; so a card that refuses every tag-list form still shows package versions wherever an applet instance exists.</p> <p class="mb-2">Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the <strong>Cards</strong> tab provides the keys, the TAR table and the counter (RAM uses the preset's <strong>ISD TAR</strong>, <code class="font-mono text-sm">000000</code> by default; the SPI1 is derived from that TAR's MSL). The <strong>Keyset</strong> selector next to the preset selector picks the keyset whose keys and counter the operation uses. It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected. The <strong>Format</strong> selector pins the remote command format: <strong>Auto (detect)</strong> probes the read-only GET STATUS command compact and in both expanded codings and keeps the first that the card executes, <strong>Compact</strong> sends the bare C-APDU, and <strong>Expanded (AA, definite length)</strong> / <strong>Expanded (AE 80, indefinite length)</strong> wrap it in the TS 102 226 &sect;5.2.1 scripting template (both execute on the live ISD; a template whose declared lengths do not match its body draws a Bad format TLV, which the operation shows as the step failure). The listing and registry queries follow that format: compact listings keep the chained GET RESPONSE, while an expanded run sends the same P2=00 queries wrapped and unchained (the reference TCA loader&rsquo;s form, Le=&rsquo;00&rsquo; returns the listing directly), and a registry query the card refuses in the current format is retried in the other formats &mdash; the report names the one that served it. A <strong>next-occurrence page</strong> (<code class="font-mono text-sm">P2=03</code>) that a format refuses is retried in the other formats too &mdash; the expanded codings first (the serving one first when it is expanded), the compact command string last, because a compact page attempt can consume the page without delivering it (the card answers <code class="font-mono text-sm">61xx</code> and the chained GET RESPONSE fails; every later format then sees the occurrence already gone) &mdash; some cards serve the expanded next page only wrapped in the <code class="font-mono text-sm">AA</code> template &mdash; and a listing is marked <code class="font-mono text-sm">(first page only)</code> only when the card promised more (<code class="font-mono text-sm">6310</code>) and no format could fetch the follow-up; in <strong>Auto</strong> the explorer also queries entries the bulk listing left without details individually by AID (a few extra packets). The card-resources block also names the card&rsquo;s inferred <strong>ETSI release</strong> (<code class="font-mono text-sm">uicc.toolkit</code> version &rarr; REL-N, the same mapping as the CAP analysis; shown only when the expanded registry returned the versions). A package whose own listing carries no version derives it from its applet instance when one exists &mdash; the package&rsquo;s module AIDs are the applet instance AIDs, and the row is annotated as coming from the app instance &mdash; so a card that refuses every tag-list form still shows package versions wherever an applet instance exists.</p>
+103 -55
View File
@@ -658,7 +658,7 @@
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Counter (5 bytes)</label> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Counter (5 bytes)</label>
<div class="flex gap-1 items-center"> <div class="flex gap-1 items-center">
<button type="button" onclick="spCntrAdjust(-1)" class="px-2.5 py-2 border border-gray-300 dark:border-slate-600 text-sm rounded hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600">−</button> <button type="button" onclick="spCntrAdjust(-1)" class="px-2.5 py-2 border border-gray-300 dark:border-slate-600 text-sm rounded hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600">−</button>
<input id="sp-cntr" oninput="spInvalidate()" onchange="spCntrSyncPreset()" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 flex-1 dark:bg-slate-800" placeholder="0000000001" maxlength="10" value="0000000001"> <input id="sp-cntr" oninput="spInvalidate()" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 flex-1 dark:bg-slate-800" placeholder="auto" maxlength="10">
<button type="button" onclick="spCntrAdjust(1)" class="px-2.5 py-2 border border-gray-300 dark:border-slate-600 text-sm rounded hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600">+</button> <button type="button" onclick="spCntrAdjust(1)" class="px-2.5 py-2 border border-gray-300 dark:border-slate-600 text-sm rounded hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600">+</button>
</div> </div>
</div> </div>
@@ -1877,7 +1877,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.19.2'; const SIMPLE_VERSION = '3.20.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -4822,7 +4822,9 @@ function packToSp(sourceId) {
} }
function spCntrAdjust(delta) { function spCntrAdjust(delta) {
const el = document.getElementById('sp-cntr'); const el = document.getElementById('sp-cntr');
let val = parseInt(el.value.replace(/[^0-9a-fA-F]/g, ''), 16) || 0; // The ± buttons start from the effective counter (typed override, else the
// preset's next value) and turn it into an explicit override.
let val = parseInt(spCntrEffective(), 16) || 0;
val = Math.max(0, val + delta) % 0x10000000000; // wrap the 5-byte counter val = Math.max(0, val + delta) % 0x10000000000; // wrap the 5-byte counter
el.value = val.toString(16).padStart(10, '0').toUpperCase(); el.value = val.toString(16).padStart(10, '0').toUpperCase();
} }
@@ -5099,6 +5101,9 @@ function spShowSizeInfo() {
} }
function _genSpBuild() { function _genSpBuild() {
// The counter the packet carries: the typed override, else the preset's
// next value (the server seeds the same way when the counter is omitted).
const cntrHex = spCntrEffective();
const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, ''); const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, '');
if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; } if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; }
@@ -5107,7 +5112,6 @@ function _genSpBuild() {
const kicHex = document.getElementById('sp-kic-hex').value; const kicHex = document.getElementById('sp-kic-hex').value;
const kidHex = document.getElementById('sp-kid-hex').value; const kidHex = document.getElementById('sp-kid-hex').value;
const tarHex = (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padEnd(6, '0').slice(0, 6); const tarHex = (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padEnd(6, '0').slice(0, 6);
const cntrHex = (document.getElementById('sp-cntr').value || '').replace(/[^0-9a-fA-F]/g, '').padStart(10, '0').slice(0, 10);
const kicKeyHex = (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''); const kicKeyHex = (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, '');
const kidKeyHex = (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''); const kidKeyHex = (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, '');
const padByte = parseInt(document.getElementById('sp-padding').value, 16); const padByte = parseInt(document.getElementById('sp-padding').value, 16);
@@ -8569,42 +8573,58 @@ function spPresetIdx() {
return (!isNaN(idx) && cards[idx]) ? idx : -1; return (!isNaN(idx) && cards[idx]) ? idx : -1;
} }
// The SCP80 form's counter input: a deliberate human edit, written to the // The SCP80 form's counter input is a per-send override (v3.20.0): an empty
// server-side preset (v3.8.0). The counter an *operation* accepted is // field means "let the server seed the counter from the preset store" - the
// persisted by the server itself - the send/RAM flows only sync the local copy // store is the source of truth and advances monotonically on accepted
// through spCntrLocalSync(). // packets; typing a value overrides it for that send only and is never
function spCntrSyncPreset() { // written to the preset. The preset's next value shows as the placeholder.
function spPresetCounterValue() {
const idx = spPresetIdx(); const idx = spPresetIdx();
if (idx < 0) return false; if (idx < 0) return '';
const preset = cards[idx];
const value = (document.getElementById('sp-cntr').value || '').trim().toUpperCase();
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) || const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0; spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
const ks = spKeysetFor(preset, kvn); // the same fallback cardsApplyFields uses: the matching keyset, else the
if (ks) ks.cntr = value; // preset's first one (an undefined duplicate would otherwise leave the
cardsRender(); // placeholder empty)
if (preset.id && ks) { const ks = (kvn && spKeysetFor(cards[idx], kvn)) || spKeysetList(cards[idx])[0] || null;
pysimFetch('/api/presets/update', { id: preset.id, fields: { keysets: spKeysetList(preset) } }) return (ks && ks.cntr) || '';
.then(res => {
if (res && res.error) throw new Error(res.error);
// the store normalises the counter (fixed 10-hex form)
if (res && res.preset) { cards[idx] = res.preset; cardsRender(); spKeysetSync(); }
})
.catch(e => ioStatus('cards-io', t('Counter not saved:') + ' ' + e.message));
}
return true;
} }
// Keep the selected preset in step with a counter the server already persisted function spCntrPlaceholder() {
// (the operations report it as final_cntr): display only, no write. const el = document.getElementById('sp-cntr');
if (!el) return;
const v = spPresetCounterValue();
el.placeholder = v ? t('auto') + ': ' + v : t('auto');
}
// The counter the packet is built with (preview / verify): the typed
// override, else the preset's next value, else the historic 1 default.
function spCntrEffective() {
const el = document.getElementById('sp-cntr');
const typed = ((el && el.value) || '').replace(/[^0-9a-fA-F]/g, '');
const v = typed || spPresetCounterValue() || '1';
return v.toUpperCase().padStart(10, '0').slice(0, 10);
}
// Numeric "b is ahead of a" for two hex counters (the store is monotonic).
function spCntrAhead(a, b) {
try { return parseInt(String(b), 16) > parseInt(String(a), 16); }
catch (e) { return false; }
}
// Keep the selected preset's copy in step with a counter the server already
// persisted (the operations report it as final_cntr): display only, no write,
// and the copy only moves forward (the store is monotonic).
function spCntrLocalSync(value) { function spCntrLocalSync(value) {
const idx = spPresetIdx(); const idx = spPresetIdx();
if (idx < 0) return; if (idx < 0) return;
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) || const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0; spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
const ks = spKeysetFor(cards[idx], kvn); const ks = spKeysetFor(cards[idx], kvn);
if (ks) ks.cntr = value; const v = String(value || '').toUpperCase();
if (ks && v && (!ks.cntr || spCntrAhead(ks.cntr, v))) ks.cntr = v;
cardsRender(); cardsRender();
spCntrPlaceholder();
} }
function spParams() { function spParams() {
@@ -8616,11 +8636,14 @@ function spParams() {
kic: document.getElementById('sp-kic-hex').value, kic: document.getElementById('sp-kic-hex').value,
kid: document.getElementById('sp-kid-hex').value, kid: document.getElementById('sp-kid-hex').value,
tar: (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padEnd(6, '0').slice(0, 6), tar: (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padEnd(6, '0').slice(0, 6),
cntr: document.getElementById('sp-cntr').value,
kicKey: document.getElementById('sp-kic-key').value, kicKey: document.getElementById('sp-kic-key').value,
kidKey: document.getElementById('sp-kid-key').value, kidKey: document.getElementById('sp-kid-key').value,
apdu: document.getElementById('sp-apdu').value, apdu: document.getElementById('sp-apdu').value,
}; };
// The counter is sent only when the field carries an explicit override;
// empty = the server seeds it from the preset store (v3.20.0).
const cntr = (document.getElementById('sp-cntr').value || '').replace(/[^0-9a-fA-F]/g, '');
if (cntr) p.cntr = cntr;
if (_spRamFormat) p.ram_format = _spRamFormat; if (_spRamFormat) p.ram_format = _spRamFormat;
return p; return p;
} }
@@ -8714,18 +8737,19 @@ async function pysimSendOta() {
// untouched - the card did not consume the packet (v3.6.3; the old // untouched - the card did not consume the packet (v3.6.3; the old
// "advance always" rule silently reverted preset edits). // "advance always" rule silently reverted preset edits).
if (spPorAccepted(por)) { if (spPorAccepted(por)) {
const cntrEl = document.getElementById('sp-cntr');
const spi1Now = (document.getElementById('sp-spi1') || {}).value; const spi1Now = (document.getElementById('sp-spi1') || {}).value;
let next = '';
if (data.final_cntr) { if (data.final_cntr) {
// the server persisted the accepted counter; sync the display // the server persisted the accepted counter; sync the local
cntrEl.value = data.final_cntr; // copy (display only - the form field is an override)
msg += ' | CNTR -> ' + cntrEl.value; next = String(data.final_cntr);
spCntrLocalSync(cntrEl.value);
} else if (spCounterTracked(spi1Now)) { } else if (spCounterTracked(spi1Now)) {
// an older server: advance the local working copy // an older server: advance the local working copy
cntrEl.value = spNextCntr(cntrEl.value); next = spNextCntr(spCntrEffective());
msg += ' | CNTR -> ' + cntrEl.value; }
spCntrLocalSync(cntrEl.value); if (next) {
msg += ' | CNTR -> ' + next;
spCntrLocalSync(next);
} else { } else {
// SPI1 without a counter check: the card keeps no replay // SPI1 without a counter check: the card keeps no replay
// value, so nothing is advanced (keyless SPI1 00 packets) // value, so nothing is advanced (keyless SPI1 00 packets)
@@ -9217,18 +9241,22 @@ function getRamSpParams(selId) {
const sel = document.getElementById(selId || 'ram-card-sel'); const sel = document.getElementById(selId || 'ram-card-sel');
const idx = sel ? parseInt(sel.value, 10) : NaN; const idx = sel ? parseInt(sel.value, 10) : NaN;
const preset = (!isNaN(idx) && cards[idx]) ? cards[idx] : null; const preset = (!isNaN(idx) && cards[idx]) ? cards[idx] : null;
return { const p = {
preset_id: (preset && preset.id) || '', preset_id: (preset && preset.id) || '',
spi1: document.getElementById('sp-spi1').value, spi1: document.getElementById('sp-spi1').value,
spi2: document.getElementById('sp-spi2-hex').value, spi2: document.getElementById('sp-spi2-hex').value,
kic: document.getElementById('sp-kic-hex').value, kic: document.getElementById('sp-kic-hex').value,
kid: document.getElementById('sp-kid-hex').value, kid: document.getElementById('sp-kid-hex').value,
tar: (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padEnd(6, '0').slice(0, 6), tar: (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padEnd(6, '0').slice(0, 6),
cntr: (document.getElementById('sp-cntr').value || '0000000001').replace(/[^0-9a-fA-F]/g, ''),
kicKey: (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''), kicKey: (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
kidKey: (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''), kidKey: (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
ram_format: (document.getElementById('ram-format') || {}).value || 'auto', ram_format: (document.getElementById('ram-format') || {}).value || 'auto',
}; };
// Override only when typed; empty = the server seeds the counter from the
// preset store (v3.20.0).
const cntr = (document.getElementById('sp-cntr').value || '').replace(/[^0-9a-fA-F]/g, '');
if (cntr) p.cntr = cntr;
return p;
} }
function ramIncrementCntr(cntr) { function ramIncrementCntr(cntr) {
@@ -9238,18 +9266,20 @@ function ramIncrementCntr(cntr) {
function ramSaveCntr(cntr) { function ramSaveCntr(cntr) {
// Keep the local copy in step with the counter the server persisted for the // Keep the local copy in step with the counter the server persisted for the
// operation (v3.8.0): display only - the store already holds it. // operation (v3.8.0): display only - the store already holds it, and the
const el = document.getElementById('sp-cntr'); // copy only moves forward (the store is monotonic). The form's counter
if (el) el.value = cntr; // field stays an explicit override; empty = the server seeds it.
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]) {
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) || const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0; spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
const ks = spKeysetFor(cards[selIdx], kvn); const ks = spKeysetFor(cards[selIdx], kvn);
if (ks) ks.cntr = cntr; const v = String(cntr || '').toUpperCase();
if (ks && v && (!ks.cntr || spCntrAhead(ks.cntr, v))) ks.cntr = v;
cardsRender(); cardsRender();
ramRender(); ramRender();
} }
spCntrPlaceholder();
} }
// Send a single GP APDU wrapped in SCP80 via /api/send-ota // Send a single GP APDU wrapped in SCP80 via /api/send-ota
@@ -11609,8 +11639,8 @@ async function ramSyncCounter(presetIdx, view) {
await cardsFetch(); await cardsFetch();
// the keyset selects' labels carry the counter - refresh them too // the keyset selects' labels carry the counter - refresh them too
spKeysetSync(); spKeysetSync();
const cntrEl = document.getElementById('sp-cntr'); // the field stays an override; its placeholder shows the stored value
if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr; spCntrPlaceholder();
} }
if (data.success) { if (data.success) {
const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' + const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' +
@@ -12498,7 +12528,10 @@ function spKeysetApply(ks) {
updateSpKid(); updateSpKid();
set('sp-kic-key', ks.kicKey || ''); set('sp-kic-key', ks.kicKey || '');
set('sp-kid-key', ks.kidKey || ''); set('sp-kid-key', ks.kidKey || '');
set('sp-cntr', ks.cntr || ''); // The counter field is an override: a keyset switch clears it (empty =
// the server seeds the packet counter, v3.20.0).
set('sp-cntr', '');
spCntrPlaceholder();
spInvalidate(); spInvalidate();
updateSp(); updateSp();
} }
@@ -12950,7 +12983,7 @@ function cardsAutoSelectByIccid(iccid) {
// packet TAR alone: the plain SCP80 send re-reads only the preset-owned // packet TAR alone: the plain SCP80 send re-reads only the preset-owned
// counter/keys/SPI, a manually entered target TAR (push/link triggers, // counter/keys/SPI, a manually entered target TAR (push/link triggers,
// applet OTA) must survive. // applet OTA) must survive.
function cardsApplyFields(idx, keepTar) { function cardsApplyFields(idx, keepTar, keepCntr) {
const c = cards[parseInt(idx)]; const c = cards[parseInt(idx)];
if (!c) return false; if (!c) return false;
// The TAR of the current operation (ISD for RAM/GP, the RFM TAR for the RFM // The TAR of the current operation (ISD for RAM/GP, the RFM TAR for the RFM
@@ -12986,10 +13019,18 @@ function cardsApplyFields(idx, keepTar) {
document.getElementById('sp-kid-idx').value = ((kidByte >> 4) & 0x0F).toString(16).toUpperCase(); 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'); document.getElementById('sp-kid-alg').value = (kidByte & 0x0F).toString(16).padStart(2, '0');
updateSpKid(); updateSpKid();
document.getElementById('sp-cntr').value = ks.cntr || '';
document.getElementById('sp-kic-key').value = ks.kicKey || ''; document.getElementById('sp-kic-key').value = ks.kicKey || '';
document.getElementById('sp-kid-key').value = ks.kidKey || ''; document.getElementById('sp-kid-key').value = ks.kidKey || '';
} }
// The counter field is an override: applying a preset clears it (empty =
// auto, the server seeds from the store); the pre-send refresh keeps a
// typed override (`keepCntr`). The placeholder always shows the preset's
// next value.
if (!keepCntr) {
const cntrEl = document.getElementById('sp-cntr');
if (cntrEl) cntrEl.value = '';
}
spCntrPlaceholder();
if (!keepTar) document.getElementById('sp-tar').value = cardsRoleTar(c, _spTarKey); if (!keepTar) document.getElementById('sp-tar').value = cardsRoleTar(c, _spTarKey);
spTarListRender(c); spTarListRender(c);
spInvalidate(); spInvalidate();
@@ -13000,8 +13041,8 @@ function cardsApplyFields(idx, keepTar) {
return true; return true;
} }
function cardsApply(idx, keepTar) { function cardsApply(idx, keepTar, keepCntr) {
if (!cardsApplyFields(idx, keepTar)) { if (!cardsApplyFields(idx, keepTar, keepCntr)) {
// "— Select card —": there is no keyset to choose, and the preset // "— Select card —": there is no keyset to choose, and the preset
// warning must not outlive the selection it described. // warning must not outlive the selection it described.
spKeysetSync(); spKeysetSync();
@@ -13014,16 +13055,18 @@ function cardsApply(idx, keepTar) {
} }
// SCP80/RAM operations re-read the selected preset before starting: the // SCP80/RAM operations re-read the selected preset before starting: the
// Cards tab (or a counter write-back) may have changed it after the form was // Cards tab (or another window) may have changed the keys, SPI or the
// filled, and a stale counter is rejected by the card (cntr_low) and would // counter the server will seed. A typed counter override survives the
// then be written back over the preset. Returns the preset counter ('' when // refresh - it is the user's explicit choice for the send. Returns the
// no preset is selected). // preset's counter ('' when no preset is selected).
function spRefreshFromPreset(selId, keepTar) { function spRefreshFromPreset(selId, keepTar) {
const sel = document.getElementById(selId || 'sp-card-sel'); const sel = document.getElementById(selId || 'sp-card-sel');
const idx = sel ? parseInt(sel.value, 10) : NaN; const idx = sel ? parseInt(sel.value, 10) : NaN;
if (isNaN(idx) || !cards[idx]) return ''; if (isNaN(idx) || !cards[idx]) return '';
if (selId === 'ram-card-sel') _spTarKey = 'isd'; if (selId === 'ram-card-sel') _spTarKey = 'isd';
cardsApply(idx, keepTar); // keepCntr: the form field is an override; do not re-fill it from the
// preset (empty = the server seeds the packet counter, v3.20.0).
cardsApply(idx, keepTar, true);
return cards[idx].cntr; return cards[idx].cntr;
} }
@@ -21244,6 +21287,7 @@ const LANG_RU = {
'Test script running': 'Выполняется тест-скрипт', 'Test script running': 'Выполняется тест-скрипт',
'Preset': 'Пресет', 'Preset': 'Пресет',
'Counter': 'Счётчик', 'Counter': 'Счётчик',
'auto': 'авто',
'Counter (hex)': 'Счётчик (hex)', 'Counter (hex)': 'Счётчик (hex)',
'No run yet.': 'Запусков пока нет.', 'No run yet.': 'Запусков пока нет.',
'running': 'выполняется', 'running': 'выполняется',
@@ -21774,6 +21818,8 @@ function refreshDynamicI18n() {
if (isViewVisible('profiler-scan-modal')) profilerScanRefreshOptions(); if (isViewVisible('profiler-scan-modal')) profilerScanRefreshOptions();
pysimUpdateCardActionLabels(); pysimUpdateCardActionLabels();
pysimUpdateStateIndicator(); pysimUpdateStateIndicator();
// the counter field's placeholder carries a t() string (auto / авто)
spCntrPlaceholder();
if (isViewVisible('tab-cards')) { if (isViewVisible('tab-cards')) {
cardsRender(); cardsRender();
cardsSetFormMode(); cardsSetFormMode();
@@ -21841,6 +21887,8 @@ chainInit('chain-ram');
ramResetGrants(); ramResetGrants();
updateInstallParamsHex(); updateInstallParamsHex();
translatePage(); translatePage();
// the counter field's placeholder carries a t() string (auto / авто)
spCntrPlaceholder();
// PWA // PWA
let deferredPrompt; let deferredPrompt;
const installBtn = document.getElementById('install-btn'); const installBtn = document.getElementById('install-btn');
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v392'; const CACHE = 'simple-v393';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+30 -9
View File
@@ -31,6 +31,8 @@ let code = '';
for (const fn of ['cardsTarList', 'cardsRoleDefault', 'cardsRoleTar', for (const fn of ['cardsTarList', 'cardsRoleDefault', 'cardsRoleTar',
'cardsTarEntry', 'cardsTarMsl', 'cardsApplyFields', 'cardsApply', 'cardsTarEntry', 'cardsTarMsl', 'cardsApplyFields', 'cardsApply',
'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr', 'spPresetIdx', 'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr', 'spPresetIdx',
'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrEffective', 'spCntrAhead',
'spCntrAdjust',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck', 'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender', 'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate', 'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
@@ -51,6 +53,7 @@ eval(code);
globalThis._genSpBuild = () => {}; globalThis._genSpBuild = () => {};
globalThis.spShowSizeInfo = () => {}; globalThis.spShowSizeInfo = () => {};
globalThis.spKeysetSync = globalThis.spKeysetSync || (() => {}); globalThis.spKeysetSync = globalThis.spKeysetSync || (() => {});
globalThis.t = s => s;
// 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
@@ -81,16 +84,23 @@ function fakeEnv(selValue, selId, presetCntr) {
return { els, gen: () => gen }; return { els, gen: () => gen };
} }
test('spRefreshFromPreset re-reads the edited preset before an operation', () => { test('spRefreshFromPreset keeps an explicit counter override, empty = auto', () => {
const env = fakeEnv('0', 'sp-card-sel', '00000000AA'); const env = fakeEnv('0', 'sp-card-sel', '00000000AA');
env.els['sp-cntr'].value = '0000000001'; // stale form copy env.els['sp-cntr'].value = '0000000001'; // explicit override
assert.strictEqual(spRefreshFromPreset('sp-card-sel'), '00000000AA'); assert.strictEqual(spRefreshFromPreset('sp-card-sel'), '00000000AA');
assert.strictEqual(env.els['sp-cntr'].value, '00000000AA'); assert.strictEqual(env.els['sp-cntr'].value, '0000000001',
assert.ok(env.gen() > 0, 'the packet must be regenerated from the new counter'); 'a typed counter survives the pre-send refresh');
assert.ok(env.gen() > 0, 'the packet must be regenerated from the preset');
// an empty field means "auto": the server seeds the counter, and the
// preset's next value is shown as the placeholder (v3.20.0)
env.els['sp-cntr'].value = '';
spRefreshFromPreset('sp-card-sel');
assert.strictEqual(env.els['sp-cntr'].value, '');
assert.match(env.els['sp-cntr'].placeholder, /00000000AA/);
// no preset selected: the manual form is left alone // no preset selected: the manual form is left alone
globalThis.cards = []; globalThis.cards = [];
assert.strictEqual(spRefreshFromPreset('sp-card-sel'), ''); assert.strictEqual(spRefreshFromPreset('sp-card-sel'), '');
assert.strictEqual(env.els['sp-cntr'].value, '00000000AA'); assert.strictEqual(env.els['sp-cntr'].value, '');
}); });
test('spRefreshFromPreset for the RAM selector targets the ISD TAR', () => { test('spRefreshFromPreset for the RAM selector targets the ISD TAR', () => {
@@ -105,23 +115,34 @@ test('the pre-send refresh keeps a hand-edited packet TAR (v3.6.21)', () => {
env.els['sp-tar'].value = 'AF4D01'; // e.g. a push/link trigger target env.els['sp-tar'].value = 'AF4D01'; // e.g. a push/link trigger target
assert.strictEqual(spRefreshFromPreset('sp-card-sel', true), '0000000020'); assert.strictEqual(spRefreshFromPreset('sp-card-sel', true), '0000000020');
assert.strictEqual(env.els['sp-tar'].value, 'AF4D01', 'the typed TAR must survive the send'); assert.strictEqual(env.els['sp-tar'].value, 'AF4D01', 'the typed TAR must survive the send');
assert.strictEqual(env.els['sp-cntr'].value, '0000000020', 'the counter still refreshes'); assert.strictEqual(env.els['sp-cntr'].value, '', 'the counter field stays an override');
assert.match(env.els['sp-cntr'].placeholder, /0000000020/);
// an explicit preset apply (or a pack) still sets the TAR // an explicit preset apply (or a pack) still sets the TAR
cardsApplyFields(0); cardsApplyFields(0);
assert.strictEqual(env.els['sp-tar'].value, 'B00000'); assert.strictEqual(env.els['sp-tar'].value, 'B00000');
}); });
test('cardsApplyFields fills the form without generating a packet', () => { test('cardsApplyFields leaves the counter to the server (auto) and shows it as a placeholder', () => {
const env = fakeEnv('0', 'sp-card-sel', '0000000007'); const env = fakeEnv('0', 'sp-card-sel', '0000000007');
env.els['sp-cntr'].value = 'nonsense'; env.els['sp-cntr'].value = 'nonsense'; // an old override
assert.strictEqual(cardsApplyFields(0), true); assert.strictEqual(cardsApplyFields(0), true);
assert.strictEqual(env.els['sp-cntr'].value, '0000000007'); assert.strictEqual(env.els['sp-cntr'].value, '',
'applying a preset clears the override (empty = the server seeds it)');
assert.match(env.els['sp-cntr'].placeholder, /0000000007/);
assert.strictEqual(env.gen(), 0, 'a plain field refresh must not rebuild the packet'); assert.strictEqual(env.gen(), 0, 'a plain field refresh must not rebuild the packet');
cardsApply(0); cardsApply(0);
assert.strictEqual(env.gen(), 1); assert.strictEqual(env.gen(), 1);
assert.strictEqual(cardsApplyFields(3), false); assert.strictEqual(cardsApplyFields(3), false);
}); });
test('the counter +/- buttons start from the preset value and create an override', () => {
const env = fakeEnv('0', 'sp-card-sel', '0000000020');
spCntrAdjust(1);
assert.strictEqual(env.els['sp-cntr'].value, '0000000021');
spCntrAdjust(-1);
assert.strictEqual(env.els['sp-cntr'].value, '0000000020');
});
test('spPorAccepted treats a missing PoR and por_ok as accepted', () => { test('spPorAccepted treats a missing PoR and por_ok as accepted', () => {
assert.strictEqual(spPorAccepted(undefined), true); assert.strictEqual(spPorAccepted(undefined), true);
assert.strictEqual(spPorAccepted({ response_status: 'por_ok' }), true); assert.strictEqual(spPorAccepted({ response_status: 'por_ok' }), true);
+12 -15
View File
@@ -31,7 +31,8 @@ let code = '';
const ASYNC_FNS = ['cardsAdd', 'cardsImport', 'cardsRemove']; const ASYNC_FNS = ['cardsAdd', 'cardsImport', 'cardsRemove'];
for (const fn of ['cardsFormValues', 'cardsClearForm', 'cardsEdit', for (const fn of ['cardsFormValues', 'cardsClearForm', 'cardsEdit',
'cardsAdd', 'cardsImport', 'cardsRemove', 'ramCardIdxAfterRemove', 'cardsAdd', 'cardsImport', 'cardsRemove', 'ramCardIdxAfterRemove',
'spPresetIdx', 'spCntrSyncPreset', 'spCntrLocalSync', 'ramSaveCntr', 'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrEffective',
'spCntrAhead', 'spCntrLocalSync', 'ramSaveCntr',
'cardsApplyFields', 'cardsApply', 'ramApplyCard', 'cardsApplyFields', 'cardsApply', 'ramApplyCard',
'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent', 'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent',
'spTarKeyForPack', 'spPresetTar', 'spTarListRender', 'packToSp', 'spTarKeyForPack', 'spPresetTar', 'spTarListRender', 'packToSp',
@@ -415,29 +416,25 @@ test('import posts the entries to the server store', async () => {
assert.strictEqual(apiCalls.length, 0); assert.strictEqual(apiCalls.length, 0);
}); });
test('the SCP80 counter edit is written to the store, a reported counter only locally', () => { test('the SCP80 counter field is an override - an edit is never written to the store', () => {
const els = setup(); const els = setup();
globalThis.cards = [{ id: 'abc123', name: 'C', keysets: [ globalThis.cards = [{ id: 'abc123', name: 'C', keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }]; { 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-kic-hex'].value = '15';
els['sp-kid-hex'].value = '15'; els['sp-kid-hex'].value = '15';
els['sp-cntr'].value = '0000000009'; els['sp-cntr'].value = '0000000009'; // an explicit override
assert.strictEqual(spCntrSyncPreset(), true); // typing alone writes nothing (no spCntrSyncPreset anymore, v3.20.0)
assert.strictEqual(cards[0].keysets[0].cntr, '0000000009'); assert.strictEqual(apiCalls.length, 0);
assert.strictEqual(apiCalls.length, 1); // a counter the server persisted: the local copy moves forward, display only
assert.strictEqual(apiCalls[0].path, '/api/presets/update');
assert.strictEqual(apiCalls[0].body.id, 'abc123');
assert.strictEqual(apiCalls[0].body.fields.keysets[0].cntr, '0000000009');
// a counter the server already persisted: display only, no second write
apiCalls = [];
spCntrLocalSync('000000000A'); spCntrLocalSync('000000000A');
assert.strictEqual(cards[0].keysets[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 // and the copy never regresses (the store is monotonic)
els['sp-card-sel'].value = ''; spCntrLocalSync('0000000005');
assert.strictEqual(spCntrSyncPreset(), false); assert.strictEqual(cards[0].keysets[0].cntr, '000000000A');
assert.strictEqual(apiCalls.length, 0); // the override in the field is untouched by the copy sync
assert.strictEqual(els['sp-cntr'].value, '0000000009');
}); });
test('applying a preset uses the TAR of the current operation and seeds the SPI1 from its MSL', () => { test('applying a preset uses the TAR of the current operation and seeds the SPI1 from its MSL', () => {
+2 -1
View File
@@ -979,8 +979,9 @@ test('every SCP80/RAM flow checks the low counter and stops', () => {
assert.ok(sync.includes("own = view === 'sp'"), sync); assert.ok(sync.includes("own = view === 'sp'"), sync);
assert.ok(sync.includes('kvnOf(own)'), sync); assert.ok(sync.includes('kvnOf(own)'), sync);
assert.ok(sync.includes('kvnOf(other)'), sync); assert.ok(sync.includes('kvnOf(other)'), sync);
assert.ok(sync.includes("getElementById('sp-cntr')"), sync);
assert.ok(sync.includes('spKeysetSync()'), 'the keyset labels must refresh'); assert.ok(sync.includes('spKeysetSync()'), 'the keyset labels must refresh');
assert.ok(sync.includes('spCntrPlaceholder()'),
'the stored value must refresh the field placeholder (the field stays an override)');
assert.ok(sync.includes("ramListingSpi2('40')"), assert.ok(sync.includes("ramListingSpi2('40')"),
'the RAM view must probe with the RAM listing transport (a PoR is required)'); 'the RAM view must probe with the RAM listing transport (a PoR is required)');
assert.ok(sync.includes('data.counter_saved'), assert.ok(sync.includes('data.counter_saved'),
+4 -1
View File
@@ -29,6 +29,7 @@ function extractFunc(src, name) {
let code = ''; let code = '';
for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr', for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr',
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress', 'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrAhead',
'ramHideProgress', 'ramDeleteFromExplorer', 'ramHideProgress', 'ramDeleteFromExplorer',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow', 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
'escHtml', 'esc', 'escHtml', 'esc',
@@ -94,7 +95,9 @@ test('accepted delete persists the consumed counter and drops the record', async
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].keysets[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'); // the form field is an override (empty = auto, the server seeds it); the
// operation only syncs the local preset copy, never the field (v3.20.0)
assert.strictEqual(els['sp-cntr'].value, '0000000005');
assert.strictEqual(calls.sent.preset_id, 'preset-1', assert.strictEqual(calls.sent.preset_id, 'preset-1',
'the operation must name the server-side preset that persists the counter'); 'the operation must name the server-side preset that persists the counter');
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false }); assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
+2
View File
@@ -30,6 +30,8 @@ const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3Cb
'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 counter of a built packet: typed override, else the preset's (v3.20.0)
'spCntrEffective', 'spPresetCounterValue',
// the key-version guard genSp runs before building (TS 102 225 A.2) // the key-version guard genSp runs before building (TS 102 225 A.2)
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck', 'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spPresetIdx', 'spKeysetGuard']; 'spPresetIdx', 'spKeysetGuard'];
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.19.2" version = "3.20.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+53 -7
View File
@@ -34,7 +34,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.19.2' VERSION = '3.20.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -1514,6 +1514,24 @@ def _preset_by_id(server, preset_id):
return store.get(preset_id) return store.get(preset_id)
def _preset_counter_seed(server, body, kvn):
"""The effective SCP80 counter for an operation: an explicit `cntr` in the
request wins (a hand send or a probe may deliberately pick one); otherwise
the counter the preset store holds for the packet's keyset - the operation
persists the next value on acceptance, so the store *is* the next counter
(v3.20.0). '' when neither is available (the packet then carries 0 and
nothing is tracked)."""
explicit = str((body or {}).get('cntr') or '').strip().upper()
if explicit:
return explicit
preset = _preset_by_id(server, (body or {}).get('preset_id'))
if preset is None or not kvn:
return ''
keyset = next((ks for ks in _preset_keysets(preset)
if presets.keyset_kvn(ks) == kvn), None)
return str((keyset or {}).get('cntr') or '').strip().upper()
def _hex_lt(a, b): def _hex_lt(a, b):
"""Numeric ``a < b`` for two hex byte strings; invalid input is never a """Numeric ``a < b`` for two hex byte strings; invalid input is never a
comparison (the packet builder's own validation reports it).""" comparison (the packet builder's own validation reports it)."""
@@ -7795,9 +7813,12 @@ class PysimHandler(BaseHTTPRequestHandler):
kic = body.get('kic', '25') kic = body.get('kic', '25')
kid = body.get('kid', '25') kid = body.get('kid', '25')
tar = body.get('tar', '000000') tar = body.get('tar', '000000')
cntr = body.get('cntr', '')
kic_key = body.get('kicKey', '') kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '') kid_key = body.get('kidKey', '')
# The counter: an explicit `cntr` wins; otherwise the
# preset store's value for the packet's keyset - the
# server owns the counters (v3.20.0).
cntr = _preset_counter_seed(self.server, body, kvn)
# RAM command format: only when the caller opted in with a # RAM command format: only when the caller opted in with a
# ram_format value (the RAM views do; applet-directed or # ram_format value (the RAM views do; applet-directed or
# pre-built payloads must not be wrapped). The probe(s) # pre-built payloads must not be wrapped). The probe(s)
@@ -7818,9 +7839,12 @@ class PysimHandler(BaseHTTPRequestHandler):
apdu = _ram_format_apdu(apdu, ram_format) apdu = _ram_format_apdu(apdu, ram_format)
sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, cntr, apdu, kic_key, kid_key) sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, cntr, apdu, kic_key, kid_key)
else: else:
# Regular SCP80: use pre-built secured packet # Regular SCP80: use pre-built secured packet. The packet
# carries its own counter; `cntr` here is the caller's
# bookkeeping value for `final_cntr`/persistence (no
# preset seed - the packet may hold anything).
sp_hex = sp sp_hex = sp
cntr = body.get('cntr', '') cntr = str(body.get('cntr') or '').strip().upper()
spi2_val = int(spi2, 16) spi2_val = int(spi2, 16)
# Capture the PoR from either transport: inline in the # Capture the PoR from either transport: inline in the
# ENVELOPE response or as a proactive SEND SHORT MESSAGE (some # ENVELOPE response or as a proactive SEND SHORT MESSAGE (some
@@ -7835,7 +7859,7 @@ class PysimHandler(BaseHTTPRequestHandler):
try: try:
sys.stderr.write('OTA SEND: SPI %s %s KIc %s KID %s TAR %s CNTR %s LEN %dB\n' % ( sys.stderr.write('OTA SEND: SPI %s %s KIc %s KID %s TAR %s CNTR %s LEN %dB\n' % (
spi1, spi2, body.get('kic', ''), spi1, spi2, body.get('kic', ''),
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''), body.get('kid', ''), body.get('tar', ''), cntr,
len(sp_hex) // 2)) len(sp_hex) // 2))
sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp) sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp)
sys.stderr.write('RAM SECURED-PACKET: %s\n' % sp_hex) sys.stderr.write('RAM SECURED-PACKET: %s\n' % sp_hex)
@@ -7948,6 +7972,10 @@ class PysimHandler(BaseHTTPRequestHandler):
finally: finally:
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
if cntr:
# echo the effective counter (a request without one got
# the preset's value - v3.20.0)
resp['cntr'] = cntr
if counter_tracked: if counter_tracked:
_preset_counter_persist(self.server, body.get('preset_id'), _preset_counter_persist(self.server, body.get('preset_id'),
resp.get('final_cntr'), 'send-ota', kvn) resp.get('final_cntr'), 'send-ota', kvn)
@@ -7966,8 +7994,13 @@ class PysimHandler(BaseHTTPRequestHandler):
body = self._read_body() body = self._read_body()
self._log_req(body) self._log_req(body)
try: try:
# The reference must be built from the same counter the JS
# packet used: an explicit body value wins, otherwise the
# preset's (v3.20.0 - the PWA omits an untouched counter).
kvn_ref = _kvn_of(body.get('kic', ''), body.get('kid', ''))[0]
cntr_ref = _preset_counter_seed(self.server, body, kvn_ref)
ref, spi = _ota_reference(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''), ref, spi = _ota_reference(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''), body.get('kid', ''), body.get('tar', ''), cntr_ref,
body.get('apdu', ''), body.get('kicKey', ''), body.get('kidKey', '')) body.get('apdu', ''), body.get('kicKey', ''), body.get('kidKey', ''))
js_sp = (body.get('sp', '') or '').replace(' ', '').lower() js_sp = (body.get('sp', '') or '').replace(' ', '').lower()
ref_l = ref.lower() ref_l = ref.lower()
@@ -8039,6 +8072,10 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp(err) self._log_resp(err)
return return
# The counter: explicit `cntr` wins, else the preset's value
# for this keyset (the server owns the counters, v3.20.0).
cntr = _preset_counter_seed(self.server, body, kvn) or cntr
# 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
@@ -8220,6 +8257,9 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(err, 400) self._send_json(err, 400)
self._log_resp(err) self._log_resp(err)
return return
# The counter: explicit `cntr` wins, else the preset's value
# for this keyset (the server owns the counters, v3.20.0).
sp_state['cntr'] = _preset_counter_seed(self.server, body, kvn) or sp_state['cntr']
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} 'kvn': kvn}
@@ -8341,6 +8381,9 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(err, 400) self._send_json(err, 400)
self._log_resp(err) self._log_resp(err)
return return
# The counter: explicit `cntr` wins, else the preset's value
# for this keyset (the server owns the counters, v3.20.0).
cntr = _preset_counter_seed(self.server, body, kvn) or cntr
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} 'kvn': kvn}
@@ -8671,7 +8714,10 @@ class PysimHandler(BaseHTTPRequestHandler):
return return
resolved.append((tar, label, tar_spi1)) resolved.append((tar, label, tar_spi1))
state = {'steps': [], 'encode_error': None, 'failure': {}, state = {'steps': [], 'encode_error': None, 'failure': {},
'cntr': body.get('cntr', '00000000'), # the counter: explicit `cntr` wins, else the preset's
# value for this keyset (the server owns the counters,
# v3.20.0)
'cntr': _preset_counter_seed(self.server, body, kvn) or '00000000',
'preset_id': body.get('preset_id'), 'kvn': kvn} 'preset_id': body.get('preset_id'), 'kvn': kvn}
sp = {'spi1': spi1_base, 'spi2': spi2, 'kic': kic, 'kid': kid, 'tar': '000000', sp = {'spi1': spi1_base, 'spi2': spi2, 'kic': kic, 'kid': kid, 'tar': '000000',
'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''), 'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''),
+54
View File
@@ -580,10 +580,64 @@ class PresetStoreHttpTests(unittest.TestCase):
status, resp = self._post('/api/counter-probe', {'preset_id': pid}) status, resp = self._post('/api/counter-probe', {'preset_id': pid})
self.assertEqual(status, 200, resp) self.assertEqual(status, 200, resp)
self.assertTrue(resp['success'], resp) self.assertTrue(resp['success'], resp)
self.assertEqual(resp['start'], '0000000001') # seeded from the keyset
self.assertEqual(resp['accepted_cntr'], '0000000002') self.assertEqual(resp['accepted_cntr'], '0000000002')
self.assertEqual(resp['stored_cntr'], '0000000003') self.assertEqual(resp['stored_cntr'], '0000000003')
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000003') self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000003')
def test_send_ota_without_cntr_seeds_the_preset_counter(self):
"""v3.20.0: the PWA's send buttons omit an untouched counter; the
server then uses the preset store's value for the packet's keyset."""
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)
pid = resp['preset']['id']
self.server.app = object() # /api/send-ota refuses an uninitialized app
with mock.patch.object(self.srv, '_build_secured_packet',
return_value=('00' * 20, {})) as build, \
mock.patch.object(self.srv, '_send_secured_packet', return_value={
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': '027100000e0ab0000000000000010000019000'}), \
mock.patch.object(self.srv, '_decode_por',
return_value={'response_status': 'por_ok'}):
status, resp = self._post('/api/send-ota', {
'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001',
'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
'kicKey': 'AA', 'kidKey': 'BB'})
self.assertEqual(status, 200, resp)
self.assertTrue(resp.get('success'), resp)
self.assertEqual(build.call_args[0][5], '0000000001') # seeded
self.assertEqual(resp['cntr'], '0000000001')
self.assertEqual(resp['final_cntr'], '0000000002')
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000002')
def test_send_ota_explicit_lower_counter_does_not_regress_the_store(self):
"""An explicit counter is used for the packet (a hand send), but the
store keeps its higher value (monotonic persistence, v3.20.0)."""
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)
pid = resp['preset']['id']
keysets = [dict(ks, cntr='0000000010') for ks in resp['preset']['keysets']]
status, resp = self._post('/api/presets/update',
{'id': pid, 'fields': {'keysets': keysets}})
self.assertEqual(status, 200, resp)
self.server.app = object() # /api/send-ota refuses an uninitialized app
with mock.patch.object(self.srv, '_build_secured_packet',
return_value=('00' * 20, {})) as build, \
mock.patch.object(self.srv, '_send_secured_packet', return_value={
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': '027100000e0ab0000000000000010000019000'}), \
mock.patch.object(self.srv, '_decode_por',
return_value={'response_status': 'por_ok'}):
status, resp = self._post('/api/send-ota', {
'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001',
'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
'cntr': '0000000005', 'kicKey': 'AA', 'kidKey': 'BB'})
self.assertEqual(status, 200, resp)
self.assertEqual(build.call_args[0][5], '0000000005') # explicit wins
self.assertEqual(resp['final_cntr'], '0000000006')
# the lower value never regresses the store
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000010')
def test_create_update_delete_round_trip(self): def test_create_update_delete_round_trip(self):
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp) self.assertEqual(status, 200, resp)
+38
View File
@@ -36,6 +36,7 @@ from pysim_simple_server.server import (
_counter_probe, _counter_probe,
_counter_probe_candidates, _counter_probe_candidates,
_counter_probe_params, _counter_probe_params,
_preset_counter_seed,
_ram_normalize_format, _ram_normalize_format,
_parse_proactive_header, _parse_proactive_header,
_parse_display_text, _parse_display_text,
@@ -535,6 +536,43 @@ class TestDecodePor(unittest.TestCase):
self.assertEqual(r['secured_data'], '016A86') self.assertEqual(r['secured_data'], '016A86')
class TestPresetCounterSeed(unittest.TestCase):
"""v3.20.0: the effective counter of an operation - explicit `cntr` wins,
otherwise the preset store's value for the packet's keyset."""
PRESET = {'keysets': [
{'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB',
'cntr': '0000000001'},
{'kic': '25', 'kid': '25', 'kicKey': 'CC', 'kidKey': 'DD',
'cntr': '0000000020'}]}
def _server(self):
preset = self.PRESET
class Store:
def get(self, pid):
return preset if pid == 'p1' else None
return types.SimpleNamespace(card_presets=Store())
def test_explicit_counter_wins(self):
self.assertEqual(_preset_counter_seed(self._server(),
{'cntr': ' 0000000009 '}, 1),
'0000000009')
# an explicit counter is used even without a preset
self.assertEqual(_preset_counter_seed(self._server(), {'cntr': '0000000009'}, 0),
'0000000009')
def test_missing_counter_seeds_from_the_keyset(self):
self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'p1'}, 2),
'0000000020')
# no/unknown preset, unknown keyset or no kvn: nothing to seed
self.assertEqual(_preset_counter_seed(self._server(), {}, 1), '')
self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'nope'}, 1), '')
self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'p1'}, 3), '')
self.assertEqual(_preset_counter_seed(self._server(), {'preset_id': 'p1'}, 0), '')
class TestProactiveDecode(unittest.TestCase): class TestProactiveDecode(unittest.TestCase):
"""Server-side proactive command/TR decode helpers (v1.8.0 log feature).""" """Server-side proactive command/TR decode helpers (v1.8.0 log feature)."""