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:
+27
-7
@@ -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
@@ -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 §8.2.1.3.2.4 — 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 §8.2.1.3.2.4 — 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 — 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 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 §5.1.2/§5.1.3.1</li>
|
<li>AES requires a replay-protected counter: SPI1 bits b5 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 §5.1.2/§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 “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A “Send to Card” 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’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 §4.3) — 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’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’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 — b5 b4 ≠ 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’s CNTR field is a copy of the packet’s), so the notice offers a bounded <strong>Sync counter</strong> probe — 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) — 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 “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A “Send to Card” 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’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 §4.3) — 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’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’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 — b5 b4 ≠ 00; without one no counter is advanced or saved) and clears the packet — the server persists the accepted counter into the preset, the form’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’s CNTR field is a copy of the packet’s), so the notice offers a bounded <strong>Sync counter</strong> probe — 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) — 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 §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’s form, Le=’00’ returns the listing directly), and a registry query the card refuses in the current format is retried in the other formats — 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 — 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) — some cards serve the expanded next page only wrapped in the <code class="font-mono text-sm">AA</code> template — 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’s inferred <strong>ETSI release</strong> (<code class="font-mono text-sm">uicc.toolkit</code> version → 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 — the package’s module AIDs are the applet instance AIDs, and the row is annotated as coming from the app instance — 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 §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’s form, Le=’00’ returns the listing directly), and a registry query the card refuses in the current format is retried in the other formats — 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 — 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) — some cards serve the expanded next page only wrapped in the <code class="font-mono text-sm">AA</code> template — 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’s inferred <strong>ETSI release</strong> (<code class="font-mono text-sm">uicc.toolkit</code> version → 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 — the package’s module AIDs are the applet instance AIDs, and the row is annotated as coming from the app instance — so a card that refuses every tag-list form still shows package versions wherever an applet instance exists.</p>
|
||||||
|
|||||||
+103
-55
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v392';
|
const CACHE = 'simple-v393';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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', ''),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user