v3.19.0: the applet-agent script batch - por objects, alpha/sms checks, scp80 format

The test scripts get the pieces an applet test needed:

- scp80 check.por accepts an object {status?, sw?, data?} asserting the
  decoded PoR of this very exchange (inline or via SEND SHORT MESSAGE);
  the evaluation is shared with the expect-step por check
  (_por_check_results).  The editor's PoR select gains 'custom' with the
  three fields (an empty object refuses on save).
- a new alpha content check reads the command's Alpha identifier (05/85,
  Annex A coded) - the text check stays bound to the 8D Text string, so a
  command carrying both keeps them distinct.
- a new sms content check asserts a SEND SHORT MESSAGE's submit TPDU:
  DA / PID / DCS / UDL / UD (the parsed fields are logged and reported;
  an applet's own SMS, not the OTA PoR the por check decodes).
- scp80.params.format wraps the C-APDU in the TS 102 226 5.2.1 Command
  Scripting template (expanded = AA..., expanded-ae = AE 80...00 00); the
  log shows WRAPPED=, the report C-APDU=<effective> format=<fmt>.
- docs: Worksheet 1 addresses an RFM TAR (B00000/B00001/B00010 compact,
  B00120/B00140/B00130 expanded) with the own-TAR and MSL/spi1 notes, and
  file-write is documented as a direct APDU that does not fire
  EVENT_REMOTE_FILE_UPDATE.

Tests: 819 frontend + 724 Python; sw cache simple-v387.
This commit is contained in:
2026-10-04 20:57:38 +03:00
parent d1ea4fe450
commit 92506e72b9
11 changed files with 650 additions and 80 deletions
+14 -6
View File
@@ -677,25 +677,33 @@ SET UP MENU, which is refreshed whenever the card sends the menu), `file-write`
verifies `check.data`), verifies `check.data`),
`apdu` (raw transport, no auto-handler), `scp80` (`apdu` or `sp`, selected by `apdu` (raw transport, no auto-handler), `scp80` (`apdu` or `sp`, selected by
the optional `source` field when both are present; optional `kvn` (keyset the optional `source` field when both are present; optional `kvn` (keyset
number 1-15), `tar`/`spi1`/`spi2` overrides - KIc/KID and the counter always number 1-15), `tar`/`spi1`/`spi2` overrides and `format` (`compact` default /
`expanded` / `expanded-ae` - the TS 102 226 5.2.1 Command Scripting template
around the C-APDU) - KIc/KID and the counter always
come from the resolved preset, which must match the equipped card and be come from the resolved preset, which must match the equipped card and be
complete) or `status` complete) or `status`
(`attempts`, `interval_ms` - when `attempts > 1` the default SW check is the (`attempts`, `interval_ms` - when `attempts > 1` the default SW check is the
mask `91??`, i.e. poll until the card announces a command). mask `91??`, i.e. poll until the card announces a command).
`check` is `{"sw": ..., "data": ...}` (exact or `{"mode": "mask", "value": `check` is `{"sw": ..., "data": ...}` (exact or `{"mode": "mask", "value":
"91??"}`, `?` = per-nibble wildcard) plus `"por": "none"|"ok"|"any"` for "91??"}`, `?` = per-nibble wildcard) plus `"por"` for SCP80 -
SCP80. `on_fail` is `error` (terminates the script) or `warning` (continues). `"none"`/`"ok"`/`"any"` or an object `{"status"?, "sw"?, "data"?}` asserting
the decoded PoR of this exchange (inline or SEND SHORT MESSAGE; at least one
field). `on_fail` is `error` (terminates the script) or `warning` (continues).
**Expectation steps** (`type: "expect"`) require a command pending from the **Expectation steps** (`type: "expect"`) require a command pending from the
previous step (`91XX`); they never poll - a `9000` response means no command previous step (`91XX`); they never poll - a `9000` response means no command
and is an error (TS 102 221 7.4.2.1 / TS 102 223 6.3; add a `status` action and is an error (TS 102 221 7.4.2.1 / TS 102 223 6.3; add a `status` action
if the card delivers on poll). `command` is a name or type code; `checks` if the card delivers on poll). `command` is a name or type code; `checks`
may be `text` (contains/exact), `item` (`id`/`text` for SELECT ITEM / SET UP may be `text` (contains/exact), `alpha` (the command's Alpha identifier
MENU), `raw` (mask), `por` (a SEND SHORT MESSAGE carrying the PoR of the `05`/`85`, contains/exact - distinct from the `8D` text string), `item`
(`id`/`text` for SELECT ITEM / SET UP MENU), `raw` (mask), `por` (a SEND
SHORT MESSAGE carrying the PoR of the
preceding `scp80` step - PoR-in-submit, SPI2 bit `20`: `status` (the response preceding `scp80` step - PoR-in-submit, SPI2 bit `20`: `status` (the response
status name, `ok` = `por_ok`), `sw` (the R-APDU status word, exact/mask) and status name, `ok` = `por_ok`), `sw` (the R-APDU status word, exact/mask) and
`data` (the R-APDU response data, exact/mask)) or `files` (the File List of a `data` (the R-APDU response data, exact/mask)), `sms` (the SEND SHORT MESSAGE
TPDU: `da` digits, `pid`/`dcs`/`ud` hex exact/mask, `udl` decimal; SMS-SUBMIT
only) or `files` (the File List of a
REFRESH, TS 102 223 8.18; `files` is a list of path hex strings, matched REFRESH, TS 102 223 8.18; `files` is a list of path hex strings, matched
order-insensitively); `respond` is the TERMINAL RESPONSE in the TS 102 223 order-insensitively); `respond` is the TERMINAL RESPONSE in the TS 102 223
6.8.0 object order (`result` name or value; `text`+`dcs` for GET INKEY/GET 6.8.0 object order (`result` name or value; `text`+`dcs` for GET INKEY/GET
+44 -9
View File
@@ -14,8 +14,9 @@ localStorage export imports as-is), and any API client can create or run them
**Format**: server ≥ 3.15.0 (`kvn`, `por` and `files` checks), scripts stored **Format**: server ≥ 3.15.0 (`kvn`, `por` and `files` checks), scripts stored
server-side since 3.16.0, `menu-select` by text and the spec-order TERMINAL server-side since 3.16.0, `menu-select` by text and the spec-order TERMINAL
RESPONSE codings since 3.17.0, the semantic `event` action since 3.18.0 and RESPONSE codings since 3.17.0, the semantic `event` action since 3.18.0, the
the `envelope` action's `src` override since 3.18.1. `envelope` action's `src` override since 3.18.1, the inline `por` object, the
`alpha`/`sms` checks and `scp80.params.format` since 3.19.0.
## Writing a test for an applet ## Writing a test for an applet
@@ -24,9 +25,9 @@ and the card's preset):
| What | Where it goes | Notes | | What | Where it goes | Notes |
|---|---|---| |---|---|---|
| Applet TAR (3 bytes) + MSL, or an explicit SPI1 | `scp80.params.tar` / `spi1` | the card preset must carry the TAR with its MSL, or the step passes `spi1` | | TAR to address (the RFM application or the applet's own) + MSL, or an explicit SPI1 | `scp80.params.tar` / `spi1` | the card preset must carry the TAR with its MSL, or the step passes `spi1`; RFM TARs: `B00000`/`B00001`/`B00010` compact, `B00120`/`B00140`/`B00130` expanded (see Worksheet 1) |
| Keyset number | `scp80.params.kvn` (1–15) | only when the applet's keys are not the preset's first keyset | | Keyset number | `scp80.params.kvn` (1–15) | only when the applet's keys are not the preset's first keyset |
| Trigger C-APDUs | `scp80.params.apdu` | one secured packet per `scp80` step; the preset provides keys and counter | | Trigger C-APDUs (+ their format) | `scp80.params.apdu` / `format` | one secured packet per `scp80` step; the preset provides keys and counter, `format` wraps the APDU for an expanded-format application |
| Expected file paths + contents | `file-read.params.path` + `check.data` | a mask must have **exactly** the actual value's length | | Expected file paths + contents | `file-read.params.path` + `check.data` | a mask must have **exactly** the actual value's length |
| Expected proactive command(s) | `expect.command` + `qualifier`/`checks` | REFRESH `qualifier: "00"` = NAA init + full FCN, `files` asserts the changed EFs | | Expected proactive command(s) | `expect.command` + `qualifier`/`checks` | REFRESH `qualifier: "00"` = NAA init + full FCN, `files` asserts the changed EFs |
| PoR transport + expected PoR | `scp80.check.por`/`check.data` (inline) or `expect SEND SHORT MESSAGE` + `por` check | pick per the SPI2 the applet expects | | PoR transport + expected PoR | `scp80.check.por`/`check.data` (inline) or `expect SEND SHORT MESSAGE` + `por` check | pick per the SPI2 the applet expects |
@@ -38,7 +39,7 @@ and the card's preset):
{"name": "applet RFM update", {"name": "applet RFM update",
"steps": [ "steps": [
{"type": "action", "kind": "scp80", {"type": "action", "kind": "scp80",
"params": {"apdu": "<RFM C-APDU>", "tar": "<applet TAR>", "kvn": 1, "spi2": "01"}, "params": {"apdu": "<RFM C-APDU>", "tar": "B00001", "kvn": 1, "spi2": "01"},
"check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"}, "check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"},
"label": "RFM update"}, "label": "RFM update"},
{"type": "expect", "command": "REFRESH", "qualifier": "00", {"type": "expect", "command": "REFRESH", "qualifier": "00",
@@ -49,6 +50,17 @@ and the card's preset):
]} ]}
``` ```
`tar` addresses the **RFM application that owns the file system**, not the
applet-under-test: `B00000` = UICC shared FS, `B00001` = ADF RFM, `B00010` =
SIM FS (compact), and `B00120`/`B00140`/`B00130` the expanded-format twins
(TS 101 220 Annex D; the probe list in the SCP80 tab carries the same names).
An applet that accepts secured packets under its **own** TAR - the TAR given
in its INSTALL [for install] parameters (TS 102 226 §8.2.1.3.2.7) - uses that
TAR instead. The preset must carry a TAR entry **with its MSL** or the step
passes an explicit `spi1`; an expanded-format RFM application also expects the
wrapped command template (`"format": "expanded"`/`"expanded-ae"`, see the
actions table).
### Worksheet 2 - incoming data → particular PoR, no other actions ### Worksheet 2 - incoming data → particular PoR, no other actions
Inline PoR: Inline PoR:
@@ -113,6 +125,9 @@ TEST-RUN step 4: MENU-SELECT ENVELOPE=80c2000009d30702020181900101 item=1 ('One'
- A PoR delivered as SEND SHORT MESSAGE is logged on the step that fetches it: - A PoR delivered as SEND SHORT MESSAGE is logged on the step that fetches it:
`PoR[sms-submit] …` followed by the same `R-APDU` line. `PoR[sms-submit] …` followed by the same `R-APDU` line.
- A SEND SHORT MESSAGE expectation also logs its parsed TPDU
(`SMS DA=… PID=… DCS=… UDL=… UD=…`) after the `CMD` line, so an applet's own
SMS can be read even without an `sms` check.
- Other actions log their send/response pair too (`APDU TX=… -> SW=…`, - Other actions log their send/response pair too (`APDU TX=… -> SW=…`,
`ENVELOPE(Event Download) … -> SW=…`, `READ MF/… -> SW=…`). `ENVELOPE(Event Download) … -> SW=…`, `READ MF/… -> SW=…`).
- The shared secured-packet sender logs each segment's answer - The shared secured-packet sender logs each segment's answer
@@ -166,12 +181,17 @@ continue correctly.
| `file-write` | `path`, `data`, `mode` `auto`/`binary`/`record`, `record` 1–255 | UPDATE BINARY/RECORD | | `file-write` | `path`, `data`, `mode` `auto`/`binary`/`record`, `record` 1–255 | UPDATE BINARY/RECORD |
| `file-read` | `path`, `mode`, `record` | READ BINARY/RECORD; verify with `check.data` | | `file-read` | `path`, `mode`, `record` | READ BINARY/RECORD; verify with `check.data` |
| `apdu` | `apdu` hex | raw transport, no auto-handler | | `apdu` | `apdu` hex | raw transport, no auto-handler |
| `scp80` | `apdu` **xor** `sp` (optional `source`), `kvn`, `tar`, `spi1`, `spi2` | secured packet to the TAR | | `scp80` | `apdu` **xor** `sp` (optional `source`), `kvn`, `tar`, `spi1`, `spi2`, `format` | secured packet to the TAR; `format`: `compact` (default - the C-APDU verbatim), `expanded` (the `AA`-prefixed Command Scripting template) or `expanded-ae` (the `AE 80 … 00 00` form), TS 102 226 §5.2.1 |
| `status` | `attempts` 1–1000, `interval_ms` 0–10000 | with `attempts > 1` the default SW check is `91??` (poll until a command) | | `status` | `attempts` 1–1000, `interval_ms` 0–10000 | with `attempts > 1` the default SW check is `91??` (poll until a command) |
`path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs `path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs
or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`. or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`.
`file-write` sends a direct terminal-side UPDATE BINARY/RECORD - it does **not**
fire the card's EVENT_REMOTE_FILE_UPDATE (that event reports an update done by
a remote entity through OTA/RFM, TS 102 223 §7.5.9): assert the change with
`file-read` or the REFRESH `files` check.
### Semantic events (`kind: "event"`) ### Semantic events (`kind: "event"`)
The server builds the event objects for the events a test needs most; an The server builds the event objects for the events a test needs most; an
@@ -208,7 +228,11 @@ the default is the terminal, matching the Phone tab's forms.
`attempts > 1` defaults to the `91??` mask). `attempts > 1` defaults to the `91??` mask).
- `data` - the response data (for SCP80: the inline PoR packet). - `data` - the response data (for SCP80: the inline PoR packet).
- `por` (SCP80 actions only) - `"any"` (default, no check), `"ok"` - `por` (SCP80 actions only) - `"any"` (default, no check), `"ok"`
(`response_status == por_ok`), `"none"` (no PoR expected). (`response_status == por_ok`), `"none"` (no PoR expected), or an **object**
`{"status"?, "sw"?, "data"?}` asserting the decoded PoR of this very
exchange (at least one field; the same fields as the expectation's `por`
check) - it works for both the inline and the SEND SHORT MESSAGE transport.
Example: `"por": {"status": "por_ok", "sw": "9000"}`.
- A plain hex string is exact; a value containing `?` is a mask. **A mask - A plain hex string is exact; a value containing `?` is a mask. **A mask
must have exactly the same length as the actual value** (`?` is a per-nibble must have exactly the same length as the actual value** (`?` is a per-nibble
wildcard) - e.g. `91??` matches `9102`, `027100????` matches a 4-byte wildcard) - e.g. `91??` matches `9102`, `027100????` matches a 4-byte
@@ -232,10 +256,12 @@ command-details byte 3 (one byte, exact/mask).
| kind | fields | checks | | kind | fields | checks |
|---|---|---| |---|---|---|
| `text` | `mode` contains/exact, `value`, `case_sensitive` | alpha identifier / text | | `text` | `mode` contains/exact, `value`, `case_sensitive` | the **Text string** (`8D`, DCS coded) of DISPLAY TEXT / GET INKEY / GET INPUT |
| `alpha` | `mode` contains/exact, `value`, `case_sensitive` | the command's **Alpha identifier** (tag `05`/`85`, TS 102 223 §8.2, Annex A coded) - distinct from `text` when a command carries both |
| `item` | `id`, `text`, `mode`, `case_sensitive` | items of SELECT ITEM / SET UP MENU | | `item` | `id`, `text`, `mode`, `case_sensitive` | items of SELECT ITEM / SET UP MENU |
| `raw` | `mode` exact/mask, `value` | the whole fetched command (hex, exact length needed) | | `raw` | `mode` exact/mask, `value` | the whole fetched command (hex, exact length needed) |
| `por` | `status`, `sw`, `data` | the PoR carried by a **SEND SHORT MESSAGE** (see below) | | `por` | `status`, `sw`, `data` | the PoR carried by a **SEND SHORT MESSAGE** (see below) |
| `sms` | `da`, `pid`, `dcs`, `udl`, `ud` | the TPDU of a SEND SHORT MESSAGE (see below) |
| `files` | `files` (list of path hex strings) or a comma/space separated string | the **File List** of a REFRESH, TS 102 223 §8.18; matched order-insensitively | | `files` | `files` (list of path hex strings) or a comma/space separated string | the **File List** of a REFRESH, TS 102 223 §8.18; matched order-insensitively |
`por` needs an SCP80 step **before** the expectation: the TPDU of the SEND `por` needs an SCP80 step **before** the expectation: the TPDU of the SEND
@@ -248,6 +274,14 @@ several SMS parts is accumulated across SEND SHORT MESSAGE expectations: put
the `por` check on the step that completes the sequence (intermediate steps the `por` check on the step that completes the sequence (intermediate steps
should use `raw` or no PoR check). should use `raw` or no PoR check).
`sms` parses the fetched SEND SHORT MESSAGE's TPDU (SMS-SUBMIT only - any other
MTI fails the check): `da` the destination digits (exact, whitespace ignored),
`pid`/`dcs` single bytes and `ud` the raw user data (a UDH included) as hex
exact/mask, `udl` the user-data length in decimal (octets, or septets for the
7-bit alphabet - whatever the TPDU carries). Use it for an applet's own SMS;
the OTA PoR of the preceding scp80 step is what `por` decodes. At least one
field is required.
### `respond` ### `respond`
The TERMINAL RESPONSE for the fetched command, in the TS 102 223 §6.8.0 The TERMINAL RESPONSE for the fetched command, in the TS 102 223 §6.8.0
@@ -275,7 +309,8 @@ UCS2 answer to a GET INPUT:
1. **Inline** - the card answers the ENVELOPE `9000` with the PoR in the 1. **Inline** - the card answers the ENVELOPE `9000` with the PoR in the
response data. Verify on the action: `"por": "ok"` and/or response data. Verify on the action: `"por": "ok"` and/or
`"data": {"mode": "mask", "value": "…"}`. `"data": {"mode": "mask", "value": "…"}`, or assert the decoded PoR with
`"por": {"status": "por_ok", "sw": "9000"}`.
2. **PoR-in-submit** (SPI2 bit `20`, e.g. `spi2: "21"`) - the card announces 2. **PoR-in-submit** (SPI2 bit `20`, e.g. `spi2: "21"`) - the card announces
a **SEND SHORT MESSAGE** (`91XX`); the PoR is inside its SMS TPDU: a **SEND SHORT MESSAGE** (`91XX`); the PoR is inside its SMS TPDU:
+2 -2
View File
@@ -563,8 +563,8 @@
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3> <h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3>
<p class="text-sm mb-2"><strong>Тест-скрипт</strong> &mdash; упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP&nbsp;409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в <strong>хранилище на сервере</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки &laquo;Сохранить&raquo; нет &mdash; черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p> <p class="text-sm mb-2"><strong>Тест-скрипт</strong> &mdash; упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP&nbsp;409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в <strong>хранилище на сервере</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки &laquo;Сохранить&raquo; нет &mdash; черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: сырой тип события + данные, с опциональным переопределением источника &mdash; 82 терминал / 83 сеть) или <em>Event</em> (семантические формы: Location status, Access technology change, Network rejection, Data connection status change &mdash; поля редактируются как объект JSON в редакторе шагов, а для остальных событий форма события на вкладке «Телефон» показывает готовый hex), <em>Menu Selection</em> (item id 1&ndash;255 или текст пункта с точным/частичным совпадением &mdash; текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li> <li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: сырой тип события + данные, с опциональным переопределением источника &mdash; 82 терминал / 83 сеть) или <em>Event</em> (семантические формы: Location status, Access technology change, Network rejection, Data connection status change &mdash; поля редактируются как объект JSON в редакторе шагов, а для остальных событий форма события на вкладке «Телефон» показывает готовый hex), <em>Menu Selection</em> (item id 1&ndash;255 или текст пункта с точным/частичным совпадением &mdash; текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2 и формат команды &mdash; компактный или обёрнутый в шаблон Command Scripting TS 102 226 &sect;5.2.1 <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em>; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em> или своя проверка декодированного PoR &mdash; статус / SW / данные; транспорт через SEND SHORT MESSAGE работает так же).</li>
<li><strong>Ожидания</strong> &mdash; карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); <code class="font-mono text-sm">9000</code> означает &laquo;команды нет&raquo; и считается ошибкой &mdash; добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <em>PoR</em> (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 <code class="font-mono text-sm">20</code>: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80) и проверка <em>Files</em> (File List команды REFRESH, например квалификатор <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). <strong>TERMINAL RESPONSE</strong> (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.</li> <li><strong>Ожидания</strong> &mdash; карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); <code class="font-mono text-sm">9000</code> означает &laquo;команды нет&raquo; и считается ошибкой &mdash; добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <em>Alpha</em> (идентификатор Alpha команды, тег <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; отличается от текстовой строки <code class="font-mono text-sm">8D</code>), проверка <em>SMS TPDU</em> (submit-TPDU команды SEND SHORT MESSAGE: получатель / PID / DCS / UDL / данные &mdash; для собственного SMS апплета), проверка <em>PoR</em> (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 <code class="font-mono text-sm">20</code>: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80) и проверка <em>Files</em> (File List команды REFRESH, например квалификатор <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). <strong>TERMINAL RESPONSE</strong> (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.</li>
<li><strong>Уровни ошибок</strong> &mdash; несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.</li> <li><strong>Уровни ошибок</strong> &mdash; несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.</li>
<li><strong>Шаблоны</strong> &mdash; Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный (поля действия Event вводятся объектом JSON), отдельные скрипты экспортируются/импортируются как JSON.</li> <li><strong>Шаблоны</strong> &mdash; Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный (поля действия Event вводятся объектом JSON), отдельные скрипты экспортируются/импортируются как JSON.</li>
</ul> </ul>
+2 -2
View File
@@ -562,8 +562,8 @@
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3> <h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
<p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP&nbsp;409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; edits are written to the store as you change them (there is no Save button &mdash; a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p> <p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP&nbsp;409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; edits are written to the store as you change them (there is no Save button &mdash; a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: raw event type + data, with an optional source override &mdash; 82 terminal / 83 network) or <em>Event</em> (the semantic forms: Location status, Access technology change, Network rejection, Data connection status change &mdash; the fields are edited as a JSON object in the step editor, and the Phone tab's event form shows the built hex for the other events), <em>Menu Selection</em> (item id 1&ndash;255, or the item's text with an exact/contains match &mdash; the text is resolved against the card's menu, which follows the applet's install parameters), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides; the keys and the counter come from the card preset matched by the equipped card's ICCID &mdash; the run names the stored preset, so it always uses its keysets, TARs and counter &mdash; and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards &mdash; <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>).</li> <li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: raw event type + data, with an optional source override &mdash; 82 terminal / 83 network) or <em>Event</em> (the semantic forms: Location status, Access technology change, Network rejection, Data connection status change &mdash; the fields are edited as a JSON object in the step editor, and the Phone tab's event form shows the built hex for the other events), <em>Menu Selection</em> (item id 1&ndash;255, or the item's text with an exact/contains match &mdash; the text is resolved against the card's menu, which follows the applet's install parameters), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides and a command format &mdash; compact, or the TS 102 226 &sect;5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em> around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID &mdash; the run names the stored preset, so it always uses its keysets, TARs and counter &mdash; and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards &mdash; <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>, or a custom assertion of the decoded PoR &mdash; status / SW / data; the SEND SHORT MESSAGE transport works the same).</li>
<li><strong>Expectations</strong> &mdash; the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); a <code class="font-mono text-sm">9000</code> means &ldquo;no command&rdquo; and is an error &mdash; add a STATUS action when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> (result, item identifier, text, extra TLVs) is part of the instruction.</li> <li><strong>Expectations</strong> &mdash; the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); a <code class="font-mono text-sm">9000</code> means &ldquo;no command&rdquo; and is an error &mdash; add a STATUS action when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, an <em>Alpha</em> check (the command's Alpha identifier, tag <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; distinct from the <code class="font-mono text-sm">8D</code> text string), an <em>SMS TPDU</em> check (the submit TPDU of a SEND SHORT MESSAGE: destination / PID / DCS / UDL / user data &mdash; for an applet's own SMS), a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> (result, item identifier, text, extra TLVs) is part of the instruction.</li>
<li><strong>Fail levels</strong> &mdash; a mismatch flags the step as <em>Error</em> (terminates the script) or <em>Warning</em> (continues); each check is reported with expected vs actual values.</li> <li><strong>Fail levels</strong> &mdash; a mismatch flags the step as <em>Error</em> (terminates the script) or <em>Warning</em> (continues); each check is reported with expected vs actual values.</li>
<li><strong>Templates</strong> &mdash; Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based (the event action's fields are a JSON object) and individual scripts export/import as JSON.</li> <li><strong>Templates</strong> &mdash; Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based (the event action's fields are a JSON object) and individual scripts export/import as JSON.</li>
</ul> </ul>
+88 -13
View File
@@ -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.18.4'; const SIMPLE_VERSION = '3.19.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -7040,9 +7040,18 @@ function testScriptProblem(script) {
return ''; return '';
} }
function testPorSpecSummary(por) {
const parts = [];
if (por.status) parts.push('status ' + por.status);
if (por.sw) parts.push('SW ' + (por.sw.mode === 'mask' ? '~' : '') + por.sw.value);
if (por.data) parts.push('data ' + (por.data.mode === 'mask' ? '~' : '') + por.data.value);
return parts.join(' ');
}
function testCheckSummary(c) { function testCheckSummary(c) {
if (!c || !c.kind) return ''; if (!c || !c.kind) return '';
if (c.kind === 'text') return 'text ' + (c.mode === 'exact' ? '=' : '~') + JSON.stringify(c.value || ''); if (c.kind === 'text') return 'text ' + (c.mode === 'exact' ? '=' : '~') + JSON.stringify(c.value || '');
if (c.kind === 'alpha') return 'alpha ' + (c.mode === 'exact' ? '=' : '~') + JSON.stringify(c.value || '');
if (c.kind === 'item') { if (c.kind === 'item') {
let s = 'item'; let s = 'item';
if (c.id) s += ' #' + c.id; if (c.id) s += ' #' + c.id;
@@ -7050,12 +7059,15 @@ function testCheckSummary(c) {
return s; return s;
} }
if (c.kind === 'raw') return 'raw ' + (c.mode === 'mask' ? '~' : '=') + (c.value || ''); if (c.kind === 'raw') return 'raw ' + (c.mode === 'mask' ? '~' : '=') + (c.value || '');
if (c.kind === 'por') { if (c.kind === 'por') return 'por ' + testPorSpecSummary(c);
if (c.kind === 'sms') {
const parts = []; const parts = [];
if (c.status) parts.push('status ' + c.status); if (c.da) parts.push('DA ' + c.da);
if (c.sw) parts.push('SW ' + (c.sw.mode === 'mask' ? '~' : '') + c.sw.value); if (c.pid) parts.push('PID ' + (c.pid.mode === 'mask' ? '~' : '') + c.pid.value);
if (c.data) parts.push('data ' + (c.data.mode === 'mask' ? '~' : '') + c.data.value); if (c.dcs) parts.push('DCS ' + (c.dcs.mode === 'mask' ? '~' : '') + c.dcs.value);
return 'por ' + parts.join(' '); if (c.udl !== undefined) parts.push('UDL ' + c.udl);
if (c.ud) parts.push('UD ' + (c.ud.mode === 'mask' ? '~' : '') + c.ud.value);
return 'sms ' + parts.join(' ');
} }
if (c.kind === 'files') return 'files ' + (c.files || []).join(' '); if (c.kind === 'files') return 'files ' + (c.files || []).join(' ');
return ''; return '';
@@ -7090,7 +7102,7 @@ function testStepSummary(step) {
s = 'EVENT ' + (EVENT_NAMES[code] || ('0x' + String(p.event || '?'))); s = 'EVENT ' + (EVENT_NAMES[code] || ('0x' + String(p.event || '?')));
} }
else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?'); else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?');
else if (step.kind === 'scp80') s = 'SCP80 ' + ((p.source || (p.sp ? 'sp' : 'apdu')) === 'sp' ? (p.sp || '(secured packet)') : (p.apdu || '?')) + (p.tar ? ' TAR=' + p.tar : '') + (p.spi1 ? ' SPI1=' + p.spi1 : '') + (p.spi2 ? ' SPI2=' + p.spi2 : ''); else if (step.kind === 'scp80') s = 'SCP80 ' + ((p.source || (p.sp ? 'sp' : 'apdu')) === 'sp' ? (p.sp || '(secured packet)') : (p.apdu || '?')) + (p.tar ? ' TAR=' + p.tar : '') + (p.spi1 ? ' SPI1=' + p.spi1 : '') + (p.spi2 ? ' SPI2=' + p.spi2 : '') + (p.format && p.format !== 'compact' ? ' fmt=' + p.format : '');
else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || ''); else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || '');
else if (step.kind === 'file-read') s = 'READ ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : ''); else if (step.kind === 'file-read') s = 'READ ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '');
else if (step.kind === 'status') s = 'STATUS' + ((p.attempts || 1) > 1 ? ' x' + p.attempts + ' (poll)' : ''); else if (step.kind === 'status') s = 'STATUS' + ((p.attempts || 1) > 1 ? ' x' + p.attempts + ' (poll)' : '');
@@ -7099,7 +7111,9 @@ function testStepSummary(step) {
const ck = []; const ck = [];
if (c.sw && c.sw.value && !(c.sw.mode !== 'mask' && c.sw.value === '9000')) ck.push('SW ' + (c.sw.mode === 'mask' ? '~' : '') + c.sw.value); if (c.sw && c.sw.value && !(c.sw.mode !== 'mask' && c.sw.value === '9000')) ck.push('SW ' + (c.sw.mode === 'mask' ? '~' : '') + c.sw.value);
if (c.data && c.data.value) ck.push('data ' + (c.data.mode === 'mask' ? '~' : '') + c.data.value); if (c.data && c.data.value) ck.push('data ' + (c.data.mode === 'mask' ? '~' : '') + c.data.value);
if (c.por && c.por !== 'any') ck.push('PoR ' + c.por); if (c.por && c.por !== 'any') {
ck.push('PoR ' + (typeof c.por === 'string' ? c.por : testPorSpecSummary(c.por)));
}
if (step.on_fail === 'warning') ck.push('⚠'); if (step.on_fail === 'warning') ck.push('⚠');
return s + (ck.length ? ' · ' + ck.join(', ') : ''); return s + (ck.length ? ' · ' + ck.join(', ') : '');
} }
@@ -7390,7 +7404,13 @@ function testStepRender() {
html += testFormRow('Source', testFormSelect('test-f-src', src, [ html += testFormRow('Source', testFormSelect('test-f-src', src, [
{v: 'apdu', l: t('C-APDU (built with the preset)')}, {v: 'sp', l: t('Pre-built secured packet')}], 'testStepKindChanged()')); {v: 'apdu', l: t('C-APDU (built with the preset)')}, {v: 'sp', l: t('Pre-built secured packet')}], 'testStepKindChanged()'));
if (src === 'sp') html += testFormRow('Secured packet (hex)', testFormInput('test-f-sp', p.sp, '01 00 ...')); if (src === 'sp') html += testFormRow('Secured packet (hex)', testFormInput('test-f-sp', p.sp, '01 00 ...'));
else html += testFormRow('C-APDU (hex)', testFormInput('test-f-apdu', p.apdu, '80E2900000')); else {
html += testFormRow('C-APDU (hex)', testFormInput('test-f-apdu', p.apdu, '80E2900000'));
html += testFormRow('Command format', testFormSelect('test-f-fmt', p.format || 'compact', [
{v: 'compact', l: t('Compact')},
{v: 'expanded', l: t('Expanded (AA template)')},
{v: 'expanded-ae', l: t('Expanded (AE 80 template)')}]));
}
html += testFormRow('Keyset number (optional)', html += testFormRow('Keyset number (optional)',
testFormInput('test-f-kvn', p.kvn === undefined ? '' : p.kvn, t('empty = the preset\'s first keyset'))); testFormInput('test-f-kvn', p.kvn === undefined ? '' : p.kvn, t('empty = the preset\'s first keyset')));
html += testFormRow('TAR override (hex, optional)', testFormInput('test-f-tar', p.tar, 'B00000')); html += testFormRow('TAR override (hex, optional)', testFormInput('test-f-tar', p.tar, 'B00000'));
@@ -7410,8 +7430,15 @@ function testStepRender() {
+ testFormSelect('test-f-cdata-mode', cd.mode || 'exact', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}]) + testFormSelect('test-f-cdata-mode', cd.mode || 'exact', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}])
+ testFormInput('test-f-cdata', cd.value, 'AA??') + '</div>'); + testFormInput('test-f-cdata', cd.value, 'AA??') + '</div>');
if (step.kind === 'scp80') { if (step.kind === 'scp80') {
html += testFormRow('PoR check', testFormSelect('test-f-por', c.por || 'any', [ const porObj = c.por && typeof c.por === 'object' ? c.por : null;
{v: 'any', l: t('Any')}, {v: 'none', l: t('No PoR expected')}, {v: 'ok', l: t('PoR = por_ok')}])); html += testFormRow('PoR check', testFormSelect('test-f-por', porObj ? 'custom' : (c.por || 'any'), [
{v: 'any', l: t('Any')}, {v: 'none', l: t('No PoR expected')}, {v: 'ok', l: t('PoR = por_ok')},
{v: 'custom', l: t('Custom (status / SW / data)')}], 'testStepKindChanged()'));
if (porObj) {
html += testFormRow('PoR status', testFormInput('test-f-porstatus', porObj.status, 'por_ok'));
html += testFormRow('PoR SW (hex)', testFormInput('test-f-por-sw', porObj.sw && porObj.sw.value, '6A88'));
html += testFormRow('PoR data (hex)', testFormInput('test-f-por-data', porObj.data && porObj.data.value, 'AA??'));
}
} }
html += testFormRow('On mismatch', testFormSelect('test-f-fail', step.on_fail || 'error', [ html += testFormRow('On mismatch', testFormSelect('test-f-fail', step.on_fail || 'error', [
{v: 'error', l: t('Error (stop the script)')}, {v: 'warning', l: t('Warning (continue)')}])); {v: 'error', l: t('Error (stop the script)')}, {v: 'warning', l: t('Warning (continue)')}]));
@@ -7478,7 +7505,8 @@ function testRenderChecks() {
html += '<div class="flex items-start gap-2 mb-1">' html += '<div class="flex items-start gap-2 mb-1">'
+ testFormSelect('test-check-kind-' + i, c.kind || 'text', [ + testFormSelect('test-check-kind-' + i, c.kind || 'text', [
{v: 'text', l: t('Text')}, {v: 'item', l: t('Item')}, {v: 'raw', l: t('Raw')}, {v: 'text', l: t('Text')}, {v: 'item', l: t('Item')}, {v: 'raw', l: t('Raw')},
{v: 'por', l: t('PoR')}, {v: 'files', l: t('Files')}], 'testCheckKindChanged()'); {v: 'alpha', l: t('Alpha')}, {v: 'por', l: t('PoR')}, {v: 'sms', l: t('SMS TPDU')},
{v: 'files', l: t('Files')}], 'testCheckKindChanged()');
if (c.kind === 'item') { if (c.kind === 'item') {
html += testFormInput('test-check-id-' + i, c.id, t('id')); html += testFormInput('test-check-id-' + i, c.id, t('id'));
html += testFormInput('test-check-text-' + i, c.text, t('text')); html += testFormInput('test-check-text-' + i, c.text, t('text'));
@@ -7491,6 +7519,12 @@ function testRenderChecks() {
html += testFormInput('test-check-porstatus-' + i, c.status, 'por_ok'); html += testFormInput('test-check-porstatus-' + i, c.status, 'por_ok');
html += testFormInput('test-check-por-sw-' + i, c.sw && c.sw.value, '6A88'); html += testFormInput('test-check-por-sw-' + i, c.sw && c.sw.value, '6A88');
html += testFormInput('test-check-por-data-' + i, c.data && c.data.value, 'AA??'); html += testFormInput('test-check-por-data-' + i, c.data && c.data.value, 'AA??');
} else if (c.kind === 'sms') {
html += testFormInput('test-check-smsda-' + i, c.da, '12345');
html += testFormInput('test-check-smspid-' + i, c.pid && c.pid.value, '7F');
html += testFormInput('test-check-smsdcs-' + i, c.dcs && c.dcs.value, '00');
html += testFormInput('test-check-smsudl-' + i, c.udl === undefined ? '' : c.udl, 'UDL');
html += testFormInput('test-check-smsud-' + i, c.ud && c.ud.value, 'AA??');
} else if (c.kind === 'files') { } else if (c.kind === 'files') {
html += testFormInput('test-check-files-' + i, (c.files || []).join(', '), '3F007F106F3A'); html += testFormInput('test-check-files-' + i, (c.files || []).join(', '), '3F007F106F3A');
} else { } else {
@@ -7544,6 +7578,17 @@ function testChecksCollect() {
if (sw) row.sw = {mode: sw.includes('?') ? 'mask' : 'exact', value: sw}; if (sw) row.sw = {mode: sw.includes('?') ? 'mask' : 'exact', value: sw};
const data = val('test-check-por-data-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase(); const data = val('test-check-por-data-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
if (data) row.data = {mode: data.includes('?') ? 'mask' : 'exact', value: data}; if (data) row.data = {mode: data.includes('?') ? 'mask' : 'exact', value: data};
} else if (kind === 'sms') {
const da = val('test-check-smsda-' + i);
if (da) row.da = da;
const pid = val('test-check-smspid-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
if (pid) row.pid = {mode: pid.includes('?') ? 'mask' : 'exact', value: pid};
const dcs = val('test-check-smsdcs-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
if (dcs) row.dcs = {mode: dcs.includes('?') ? 'mask' : 'exact', value: dcs};
const udl = val('test-check-smsudl-' + i);
if (udl !== '' && !isNaN(parseInt(udl, 10))) row.udl = parseInt(udl, 10);
const ud = val('test-check-smsud-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
if (ud) row.ud = {mode: ud.includes('?') ? 'mask' : 'exact', value: ud};
} else if (kind === 'files') { } else if (kind === 'files') {
row.files = val('test-check-files-' + i).split(/[,\s]+/) row.files = val('test-check-files-' + i).split(/[,\s]+/)
.map(s => s.replace(/[^0-9a-fA-F]/g, '').toUpperCase()).filter(Boolean); .map(s => s.replace(/[^0-9a-fA-F]/g, '').toUpperCase()).filter(Boolean);
@@ -7621,6 +7666,8 @@ function testStepCollect() {
['tar', 'spi1', 'spi2'].forEach(k => { const v = hexNoMask('test-f-' + k); if (v) p[k] = v; }); ['tar', 'spi1', 'spi2'].forEach(k => { const v = hexNoMask('test-f-' + k); if (v) p[k] = v; });
const kvn = (document.getElementById('test-f-kvn') || {}).value; const kvn = (document.getElementById('test-f-kvn') || {}).value;
if (kvn !== undefined && String(kvn).trim() !== '') p.kvn = parseInt(String(kvn).trim(), 10); if (kvn !== undefined && String(kvn).trim() !== '') p.kvn = parseInt(String(kvn).trim(), 10);
const fmt = val('test-f-fmt');
if (fmt && fmt !== 'compact') p.format = fmt;
} }
else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); } else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); }
step.params = p; step.params = p;
@@ -7630,7 +7677,19 @@ function testStepCollect() {
const dataVal = hex('test-f-cdata'); const dataVal = hex('test-f-cdata');
if (dataVal) check.data = {mode: val('test-f-cdata-mode') || 'exact', value: dataVal}; if (dataVal) check.data = {mode: val('test-f-cdata-mode') || 'exact', value: dataVal};
const por = val('test-f-por'); const por = val('test-f-por');
if (kind === 'scp80' && por && por !== 'any') check.por = por; if (kind === 'scp80' && por === 'custom') {
// the decoded-PoR assertion: status / SW / data, at least one
const spec = {};
const st = val('test-f-porstatus');
if (st) spec.status = st;
const psw = hex('test-f-por-sw');
if (psw) spec.sw = {mode: psw.includes('?') ? 'mask' : 'exact', value: psw};
const pdata = hex('test-f-por-data');
if (pdata) spec.data = {mode: pdata.includes('?') ? 'mask' : 'exact', value: pdata};
check.por = spec;
} else if (kind === 'scp80' && por && por !== 'any') {
check.por = por;
}
step.check = check; step.check = check;
step.on_fail = val('test-f-fail') || 'error'; step.on_fail = val('test-f-fail') || 'error';
const label = val('test-f-label'); const label = val('test-f-label');
@@ -7645,6 +7704,7 @@ function testStepCollect() {
if (c.kind === 'text') return !!(c.value || '').trim(); if (c.kind === 'text') return !!(c.value || '').trim();
if (c.kind === 'item') return c.id || (c.text || '').trim(); if (c.kind === 'item') return c.id || (c.text || '').trim();
if (c.kind === 'por') return !!(c.status || c.sw || c.data); if (c.kind === 'por') return !!(c.status || c.sw || c.data);
if (c.kind === 'sms') return !!(c.da || c.pid || c.dcs || c.udl !== undefined || c.ud);
if (c.kind === 'files') return !!(c.files && c.files.length); if (c.kind === 'files') return !!(c.files && c.files.length);
return !!(c.value || '').trim(); return !!(c.value || '').trim();
}); });
@@ -7669,6 +7729,10 @@ function testStepFormError() {
if (step.kind === 'scp80') { if (step.kind === 'scp80') {
const src = p.source || (p.sp ? 'sp' : 'apdu'); const src = p.source || (p.sp ? 'sp' : 'apdu');
if (src === 'sp' ? !p.sp : !p.apdu) return t('SCP80 needs an APDU or a secured packet'); if (src === 'sp' ? !p.sp : !p.apdu) return t('SCP80 needs an APDU or a secured packet');
const pc = (step.check || {}).por;
if (pc && typeof pc === 'object' && !pc.status && !pc.sw && !pc.data) {
return t('PoR check: status, SW or data is required');
}
} }
if (step.kind === 'menu-select') { if (step.kind === 'menu-select') {
const problem = testMenuSelectProblem(p); const problem = testMenuSelectProblem(p);
@@ -21116,6 +21180,17 @@ const LANG_RU = {
'default: 9000 (91?? when polling)': 'по умолчанию: 9000 (91?? при опросе)', 'default: 9000 (91?? when polling)': 'по умолчанию: 9000 (91?? при опросе)',
'Expected data (hex, optional)': 'Ожидаемые данные (hex, опционально)', 'Expected data (hex, optional)': 'Ожидаемые данные (hex, опционально)',
'PoR check': 'Проверка PoR', 'PoR check': 'Проверка PoR',
'Custom (status / SW / data)': 'Своя проверка (status / SW / data)',
'PoR status': 'Статус PoR',
'PoR SW (hex)': 'SW PoR (hex)',
'PoR data (hex)': 'Данные PoR (hex)',
'PoR check: status, SW or data is required': 'Проверка PoR: нужен status, SW или data',
'Command format': 'Формат команды',
'Compact': 'Компактный',
'Expanded (AA template)': 'Расширенный (шаблон AA)',
'Expanded (AE 80 template)': 'Расширенный (шаблон AE 80)',
'Alpha': 'Alpha',
'SMS TPDU': 'TPDU SMS',
'PoR ok': 'PoR: успешно', 'PoR ok': 'PoR: успешно',
'no PoR': 'PoR: нет', 'no PoR': 'PoR: нет',
'PoR error': 'Ошибка PoR', 'PoR error': 'Ошибка PoR',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v386'; const CACHE = 'simple-v387';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+92
View File
@@ -34,6 +34,7 @@ eval(extractBlock('const TEST_ACTION_KINDS = [', 'let _testScripts').replace(/^c
eval(extractFunc(html, 'testTemplate')); eval(extractFunc(html, 'testTemplate'));
eval(extractFunc(html, 'testMenuSelectProblem')); eval(extractFunc(html, 'testMenuSelectProblem'));
eval(extractFunc(html, 'testScriptProblem')); eval(extractFunc(html, 'testScriptProblem'));
eval(extractFunc(html, 'testPorSpecSummary'));
eval(extractFunc(html, 'testCheckSummary')); eval(extractFunc(html, 'testCheckSummary'));
eval(extractFunc(html, 'testStepSummary')); eval(extractFunc(html, 'testStepSummary'));
eval(extractFunc(html, 'testCommandOptions')); eval(extractFunc(html, 'testCommandOptions'));
@@ -194,6 +195,97 @@ test('the SCP80 source switch sticks and preserves the other value', () => {
assert.strictEqual(_testEditStep.params.sp, 'AABBCC'); assert.strictEqual(_testEditStep.params.sp, 'AABBCC');
}); });
test('the SCP80 step collects the format and the custom PoR object', () => {
_testEditStep = { type: 'action', kind: 'scp80', params: { apdu: '80E2900000' } };
_testEditChecks = [];
_testEditStepIndex = 0;
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000',
'test-f-fmt': 'expanded', 'test-f-kvn': '', 'test-f-tar': '', 'test-f-spi1': '',
'test-f-spi2': '', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'custom',
'test-f-porstatus': 'por_ok', 'test-f-por-sw': '6a8?', 'test-f-por-data': 'aa??',
'test-f-fail': 'error' });
testStepCollect();
assert.strictEqual(_testEditStep.params.format, 'expanded');
assert.deepStrictEqual(_testEditStep.check.por,
{ status: 'por_ok', sw: { mode: 'mask', value: '6A8?' },
data: { mode: 'mask', value: 'AA??' } });
// a custom PoR check with no fields refuses to save
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000',
'test-f-fmt': 'compact', 'test-f-kvn': '', 'test-f-tar': '', 'test-f-spi1': '',
'test-f-spi2': '', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'custom',
'test-f-fail': 'error' });
assert.match(testStepFormError(), /PoR check/);
assert.ok(!('format' in _testEditStep.params)); // compact is the default
// the plain string forms stay and their object is dropped on switch
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000',
'test-f-fmt': '', 'test-f-kvn': '', 'test-f-tar': '', 'test-f-spi1': '',
'test-f-spi2': '', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'none',
'test-f-fail': 'error' });
testStepCollect();
assert.strictEqual(_testEditStep.check.por, 'none');
// the summary renders the effective format and the object's fields
const s = testStepSummary({ type: 'action', kind: 'scp80',
params: { apdu: '80E2', format: 'expanded-ae' },
check: { por: { status: 'por_ok', sw: { mode: 'mask', value: '6A8?' } } } });
assert.ok(s.includes('fmt=expanded-ae'), s);
assert.ok(s.includes('PoR status por_ok SW ~6A8?'), s);
});
test('the SCP80 step form renders the format select and the custom PoR fields', () => {
const els = {
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
'test-step-title': {}, 'test-step-body': {},
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
};
globalThis.document = { getElementById: id => els[id] || null };
_testEditStep = { type: 'action', kind: 'scp80',
params: { apdu: '80E2900000', format: 'expanded' },
check: { por: { status: 'por_ok' } } };
_testEditChecks = [];
_testEditStepIndex = 0;
testStepRender();
const body = els['test-step-body'].innerHTML;
assert.match(body, /id="test-f-fmt"/);
assert.match(body, /<option value="expanded" selected>/);
assert.match(body, /<option value="custom" selected>/);
assert.match(body, /id="test-f-porstatus" value="por_ok"/);
assert.match(body, /id="test-f-por-sw" value=""/);
// the pre-built packet source has no format select (the wrapper applies
// to a C-APDU only)
_testEditStep = { type: 'action', kind: 'scp80', params: { source: 'sp', sp: 'AABB' } };
testStepRender();
assert.ok(!els['test-step-body'].innerHTML.includes('id="test-f-fmt"'));
});
test('the alpha and sms checks collect and summarise', () => {
assert.strictEqual(testCheckSummary({ kind: 'alpha', mode: 'exact', value: 'Alfa' }),
'alpha ="Alfa"');
assert.strictEqual(testCheckSummary({ kind: 'sms', da: '12345',
pid: { mode: 'exact', value: '7F' }, dcs: { mode: 'exact', value: '00' },
udl: 5, ud: { mode: 'mask', value: 'AA??' } }),
'sms DA 12345 PID 7F DCS 00 UDL 5 UD ~AA??');
// the sms fields collect with hex masks and a decimal UDL (0 included)
_testEditChecks = [{ kind: 'sms' }];
fakeForm({ 'test-check-kind-0': 'sms', 'test-check-smsda-0': '12345',
'test-check-smspid-0': '7f', 'test-check-smsdcs-0': '00',
'test-check-smsudl-0': '0', 'test-check-smsud-0': 'aa??',
'test-check-fail-0': 'error' });
testChecksCollect();
assert.deepStrictEqual(_testEditChecks, [{ kind: 'sms', on_fail: 'error', da: '12345',
pid: { mode: 'exact', value: '7F' }, dcs: { mode: 'exact', value: '00' }, udl: 0,
ud: { mode: 'mask', value: 'AA??' } }]);
// an empty sms check is dropped on save; an alpha with a value survives
_testEditStep = { type: 'expect', command: 'SEND SHORT MESSAGE', checks: [],
respond: { result: 'ok' } };
_testEditChecks = [{ kind: 'sms' }, { kind: 'alpha', mode: 'contains', value: 'Alfa' }];
fakeForm({ 'test-check-kind-0': 'sms', 'test-check-fail-0': 'error',
'test-check-kind-1': 'alpha', 'test-check-mode-1': 'contains',
'test-check-value-1': 'Alfa', 'test-check-fail-1': 'error' });
testStepCollect();
assert.deepStrictEqual(_testEditStep.checks,
[{ kind: 'alpha', on_fail: 'error', mode: 'contains', value: 'Alfa' }]);
});
test('the menu-select editor collects the item id or the text', () => { test('the menu-select editor collects the item id or the text', () => {
// by text (the id varies with the applet's install parameters) // by text (the id varies with the applet's install parameters)
_testEditStep = { type: 'action', kind: 'menu-select', params: {} }; _testEditStep = { type: 'action', kind: 'menu-select', params: {} };
+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.18.4" version = "3.19.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.
+129 -35
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.18.4' VERSION = '3.19.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
@@ -2783,6 +2783,34 @@ def _decode_sms_ud(pid, dcs, ud, udhi, udl):
return out return out
def _sms_tpdu_fields(raw):
"""Parse the TPDU of a SEND SHORT MESSAGE command into the fields a test
script can assert (the same SMS_SUBMIT parse the log uses): the message
type, DA digits, PID/DCS, UDL and the raw user data. None when the
command carries no TPDU."""
tpdu_hex = _find_sms_tpdu(raw)
if not tpdu_hex:
return None
try:
tpdu = bytes.fromhex(tpdu_hex)
mti = tpdu[0] & 0x03
out = {'mti': mti, 'tpdu': tpdu_hex.upper()}
if mti != 1:
return out
from pySim.sms import SMS_SUBMIT
s = SMS_SUBMIT.from_bytes(tpdu)
out.update({
'da': str(getattr(s.tp_da, 'digits', '') or '').rstrip('fF'),
'pid': '%02X' % s.tp_pid,
'dcs': '%02X' % s.tp_dcs,
'udl': int(s.tp_udl),
'ud': bytes(s.tp_ud).hex().upper(),
})
return out
except Exception:
return None
def _decode_send_sm(raw): def _decode_send_sm(raw):
"""Decode a SEND SHORT MESSAGE command (TS 102 223 6.4.10): alpha """Decode a SEND SHORT MESSAGE command (TS 102 223 6.4.10): alpha
identifier, address and the 3GPP-SMS TPDU with its user data.""" identifier, address and the 3GPP-SMS TPDU with its user data."""
@@ -5767,12 +5795,41 @@ def _test_finish_entry(entry, result):
fields = {'status': result.get('status', 'ok'), fields = {'status': result.get('status', 'ok'),
'checks': result.get('checks', []), 'checks': result.get('checks', []),
'ms': int((time.time() - (entry.get('started') or time.time())) * 1000)} 'ms': int((time.time() - (entry.get('started') or time.time())) * 1000)}
for key in ('sent', 'sw', 'data', 'note', 'por', 'counter', 'command'): for key in ('sent', 'sw', 'data', 'note', 'por', 'sms', 'counter', 'command'):
if result.get(key) is not None: if result.get(key) is not None:
fields[key] = result[key] fields[key] = result[key]
_test_entry_update(entry, fields) _test_entry_update(entry, fields)
def _por_check_results(spec, por, level, prefix='PoR'):
"""The decoded-PoR assertion rows (status/sw/data), shared by the scp80
action's `check.por` object and the expectation's `por` content check."""
checks = []
if por is None:
checks.append(_test_check_result(
prefix, False, 'a decoded PoR',
'no PoR in this command (or no scp80 step before it)', level))
return checks
if 'status' in spec:
actual = str(por.get('response_status') or '')
want = spec['status']
ok = (actual.lower() == want.lower()
or (want.lower() == 'ok' and actual == 'por_ok'))
checks.append(_test_check_result(prefix + ' status', ok, want,
actual or '(none)', level))
if 'sw' in spec:
actual = str((por.get('decoded') or {}).get('last_status_word') or '')
checks.append(_test_check_result(
prefix + ' SW', testscript.match_value(spec['sw'], actual),
spec['sw']['value'], actual or '(none)', level))
if 'data' in spec:
actual = str((por.get('decoded') or {}).get('last_response_data') or '')
checks.append(_test_check_result(
prefix + ' data', testscript.match_value(spec['data'], actual),
spec['data']['value'], actual or '(none)', level))
return checks
def _test_action_checks(step, sw, data, por, kind): def _test_action_checks(step, sw, data, por, kind):
check = step['check'] check = step['check']
level = step['on_fail'] level = step['on_fail']
@@ -5786,15 +5843,19 @@ def _test_action_checks(step, sw, data, por, kind):
else: else:
checks.append(_test_check_result('Data', True, check['data']['value'], checks.append(_test_check_result('Data', True, check['data']['value'],
'(not checked - SW mismatch)', 'ok')) '(not checked - SW mismatch)', 'ok'))
if kind == 'scp80' and check.get('por') != 'any': if kind == 'scp80':
want = check['por'] want = check.get('por')
if want == 'none': if isinstance(want, dict):
ok = por is None # the decoded-PoR assertion (inline or submit transport)
actual = 'none' if por is None else 'present' checks.extend(_por_check_results(want, por, level))
else: elif want != 'any':
ok = bool(por) and por.get('response_status') == 'por_ok' if want == 'none':
actual = (por or {}).get('response_status') or 'none' ok = por is None
checks.append(_test_check_result('PoR', ok, want, actual, level)) actual = 'none' if por is None else 'present'
else:
ok = bool(por) and por.get('response_status') == 'por_ok'
actual = (por or {}).get('response_status') or 'none'
checks.append(_test_check_result('PoR', ok, want, actual, level))
return checks return checks
@@ -5899,15 +5960,25 @@ def _test_run_scp80(server, step, ctx):
ctx['last_scp80'] = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid, ctx['last_scp80'] = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'counter': counter, 'kicKey': kic_key, 'kidKey': kid_key, 'counter': counter, 'kicKey': kic_key, 'kidKey': kid_key,
'tar': tar} 'tar': tar}
fmt = p.get('format') or 'compact'
apdu_hex = p.get('apdu') or ''
if p.get('sp'): if p.get('sp'):
sp_hex = p['sp'] sp_hex = p['sp']
source = 'sp' source = 'sp'
_test_log('SCP80 SP=%s' % sp_hex) _test_log('SCP80 SP=%s' % sp_hex)
else: else:
sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, counter,
p['apdu'], kic_key, kid_key)
source = 'apdu' source = 'apdu'
_test_log('SCP80 C-APDU=%s' % p['apdu']) if fmt != 'compact':
# TS 102 226 5.2.1: wrap the C-APDU in the Command Scripting
# template (the expanded remote-management format the RFM/GP
# applications expect for multi-command payloads)
apdu_hex = _ram_format_apdu(apdu_hex, fmt)
_test_log('SCP80 C-APDU=%s WRAPPED=%s (%s)'
% (p['apdu'], apdu_hex, fmt))
else:
_test_log('SCP80 C-APDU=%s' % apdu_hex)
sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, counter,
apdu_hex, kic_key, kid_key)
_test_log('SCP80 SECURED=%s (%d B) TAR=%s SPI1=%s SPI2=%s CNTR=%s' _test_log('SCP80 SECURED=%s (%d B) TAR=%s SPI1=%s SPI2=%s CNTR=%s'
% (sp_hex, len(sp_hex) // 2, tar or '-', spi1, spi2, counter)) % (sp_hex, len(sp_hex) // 2, tar or '-', spi1, spi2, counter))
result = _send_secured_packet(scc, sp_hex, server.sms_oa, sm_sc=server.sms_sc, result = _send_secured_packet(scc, sp_hex, server.sms_oa, sm_sc=server.sms_sc,
@@ -5936,10 +6007,12 @@ def _test_run_scp80(server, step, ctx):
_test_log('PoR[inline] undecodable raw=%s' % data) _test_log('PoR[inline] undecodable raw=%s' % data)
else: else:
_test_log('PoR[inline] none') _test_log('PoR[inline] none')
sent = 'SCP80 %s TAR=%s SPI1=%s SPI2=%s cntr=%s%s %s' % ( sent = 'SCP80 %s TAR=%s SPI1=%s SPI2=%s cntr=%s%s %s%s' % (
source, tar or '-', spi1, spi2, counter, source, tar or '-', spi1, spi2, counter,
(' kvn=%d' % kvn) if kvn else '', (' kvn=%d' % kvn) if kvn else '',
('SP=%s' % sp_hex) if source == 'sp' else ('C-APDU=%s' % p['apdu'])) ('SP=%s' % sp_hex) if source == 'sp'
else ('C-APDU=%s' % apdu_hex),
(' format=%s' % fmt) if (source == 'apdu' and fmt != 'compact') else '')
if sw and _counter_tracked(spi1): if sw and _counter_tracked(spi1):
# The card answered and the packet asks for a counter check, so the # The card answered and the packet asks for a counter check, so the
# SCP80 counter was consumed: advance the working value of this key # SCP80 counter was consumed: advance the working value of this key
@@ -6158,6 +6231,12 @@ def _test_run_expect(server, step, pending, ctx=None):
type_name = PROACTIVE_TYPE_NAMES.get(cmd_type, 'UNKNOWN') type_name = PROACTIVE_TYPE_NAMES.get(cmd_type, 'UNKNOWN')
_test_log('CMD 0x%02X %s qual=%s' % ( _test_log('CMD 0x%02X %s qual=%s' % (
cmd_type, type_name, ('%02X' % cmd_qual) if cmd_qual is not None else '-')) cmd_type, type_name, ('%02X' % cmd_qual) if cmd_qual is not None else '-'))
sms_fields = _sms_tpdu_fields(raw) if cmd_type == 0x13 else None
if sms_fields and sms_fields.get('mti') == 1:
_test_log('SMS DA=%s PID=%s DCS=%s UDL=%s UD=%s'
% (sms_fields.get('da'), sms_fields.get('pid'),
sms_fields.get('dcs'), sms_fields.get('udl'),
sms_fields.get('ud')))
# A SEND SHORT MESSAGE carries the PoR when the packet asked for it in # A SEND SHORT MESSAGE carries the PoR when the packet asked for it in
# submit mode (SPI2 bit 0x20): decode it with the triggering scp80 step's # submit mode (SPI2 bit 0x20): decode it with the triggering scp80 step's
# context so the checks can assert the PoR contents. # context so the checks can assert the PoR contents.
@@ -6224,29 +6303,44 @@ def _test_run_expect(server, step, pending, ctx=None):
testscript.match_value({'mode': c['mode'], 'value': c['value']}, actual), testscript.match_value({'mode': c['mode'], 'value': c['value']}, actual),
c['value'], actual, c['on_fail'])) c['value'], actual, c['on_fail']))
elif c['kind'] == 'por': elif c['kind'] == 'por':
if por is None: checks.extend(_por_check_results(c, por, c['on_fail']))
elif c['kind'] == 'alpha':
alpha = _cmd_tlv(httpota.proactive_tlvs(raw), 0x05)
text = None
if alpha:
try:
text = _STK_DECODE._decode(alpha, {}, 'stk')
except Exception:
text = None
checks.append(_test_check_result(
'Alpha', testscript.match_text(c, text),
c['value'], text if text is not None else '(none)', c['on_fail']))
elif c['kind'] == 'sms':
if sms_fields is None or sms_fields.get('mti') != 1:
checks.append(_test_check_result( checks.append(_test_check_result(
'PoR', False, 'a decoded PoR', 'SMS', False, 'a SUBMIT TPDU', 'none (not an SMS-SUBMIT)',
'no PoR in this command (or no scp80 step before it)',
c['on_fail'])) c['on_fail']))
else: else:
if 'status' in c: if 'da' in c:
actual = str(por.get('response_status') or '')
want = c['status']
ok = (actual.lower() == want.lower()
or (want.lower() == 'ok' and actual == 'por_ok'))
checks.append(_test_check_result('PoR status', ok, want,
actual or '(none)', c['on_fail']))
if 'sw' in c:
actual = str((por.get('decoded') or {}).get('last_status_word') or '')
checks.append(_test_check_result( checks.append(_test_check_result(
'PoR SW', testscript.match_value(c['sw'], actual), 'SMS DA', sms_fields.get('da') == c['da'], c['da'],
c['sw']['value'], actual or '(none)', c['on_fail'])) sms_fields.get('da') or '(none)', c['on_fail']))
if 'data' in c: for label, key in (('PID', 'pid'), ('DCS', 'dcs')):
actual = str((por.get('decoded') or {}).get('last_response_data') or '') if key in c:
actual = sms_fields.get(key) or ''
checks.append(_test_check_result(
'SMS ' + label, testscript.match_value(c[key], actual),
c[key]['value'], actual or '(none)', c['on_fail']))
if 'udl' in c:
actual = sms_fields.get('udl')
checks.append(_test_check_result( checks.append(_test_check_result(
'PoR data', testscript.match_value(c['data'], actual), 'SMS UDL', actual == c['udl'], str(c['udl']),
c['data']['value'], actual or '(none)', c['on_fail'])) '(none)' if actual is None else str(actual), c['on_fail']))
if 'ud' in c:
actual = sms_fields.get('ud') or ''
checks.append(_test_check_result(
'SMS UD', testscript.match_value(c['ud'], actual),
c['ud']['value'], actual or '(none)', c['on_fail']))
elif c['kind'] == 'files': elif c['kind'] == 'files':
paths = _parse_file_list(raw) paths = _parse_file_list(raw)
want = c['files'] want = c['files']
@@ -6261,7 +6355,7 @@ def _test_run_expect(server, step, pending, ctx=None):
status = testscript.combine_levels([c['level'] if not c['ok'] else 'ok' for c in checks]) status = testscript.combine_levels([c['level'] if not c['ok'] else 'ok' for c in checks])
result = {'status': status, 'sw': tr_sw, 'data': raw.hex().upper(), result = {'status': status, 'sw': tr_sw, 'data': raw.hex().upper(),
'sent': 'TR ' + tr.hex().upper(), 'checks': checks, 'sent': 'TR ' + tr.hex().upper(), 'checks': checks,
'por': por, 'por': por, 'sms': sms_fields,
'command': {'type_hex': '%02X' % cmd_type, 'type_name': type_name, 'command': {'type_hex': '%02X' % cmd_type, 'type_name': type_name,
'qualifier': '%02X' % cmd_qual if cmd_qual is not None else None, 'qualifier': '%02X' % cmd_qual if cmd_qual is not None else None,
'raw': raw.hex().upper()}} 'raw': raw.hex().upper()}}
+62 -11
View File
@@ -54,6 +54,21 @@ class ScriptError(ValueError):
# ─── normalisation helpers ────────────────────────────────────────────── # ─── normalisation helpers ──────────────────────────────────────────────
def _por_fields(c, what='por check'):
"""The decoded-PoR assertion fields (status/sw/data); at least one."""
out = {}
status = c.get('status')
if status is not None and str(status).strip():
out['status'] = str(status).strip()
if c.get('sw') not in (None, ''):
out['sw'] = _check_spec(c.get('sw'), what + ' sw')
if c.get('data') not in (None, ''):
out['data'] = _check_spec(c.get('data'), what + ' data')
if not (out.get('status') or out.get('sw') or out.get('data')):
raise ScriptError(what + ': status, sw or data is required')
return out
def _fail_level(value, default='error'): def _fail_level(value, default='error'):
if value in (None, ''): if value in (None, ''):
return default return default
@@ -312,6 +327,15 @@ def _normalise_params(kind, p):
# the counter stay preset-owned, the step only picks which keyset. # the counter stay preset-owned, the step only picks which keyset.
if p.get('kvn') not in (None, ''): if p.get('kvn') not in (None, ''):
out['kvn'] = _int(p.get('kvn'), 'scp80 keyset number', 1, 15) out['kvn'] = _int(p.get('kvn'), 'scp80 keyset number', 1, 15)
# The command format: the C-APDU verbatim (compact, the default) or
# wrapped in the expanded Command Scripting template (TS 102 226
# 5.2.1) - `expanded` = AA/<len>/22/..., `expanded-ae` = AE 80/22/00 00.
fmt = str(p.get('format') or '').strip().lower()
if fmt:
if fmt not in ('compact', 'expanded', 'expanded-ae'):
raise ScriptError('scp80: format must be compact, expanded or '
'expanded-ae')
out['format'] = fmt
return out return out
if kind == 'status': if kind == 'status':
attempts = p.get('attempts') attempts = p.get('attempts')
@@ -337,14 +361,20 @@ def _normalise_check(check, kind, params):
sw = {'mode': 'exact', 'value': '9000'} sw = {'mode': 'exact', 'value': '9000'}
data = _check_spec(check.get('data'), 'check.data') data = _check_spec(check.get('data'), 'check.data')
por = check.get('por') por = check.get('por')
if por is None: if isinstance(por, dict):
# The decoded-PoR assertion (available on scp80 actions whether the
# PoR arrived inline or via SEND SHORT MESSAGE).
if kind != 'scp80':
raise ScriptError('check.por is only valid for scp80 actions')
por = _por_fields(por, 'check.por')
elif por is None:
por = 'any' por = 'any'
else: else:
por = str(por).lower() por = str(por).lower()
if kind != 'scp80': if kind != 'scp80':
raise ScriptError('check.por is only valid for scp80 actions') raise ScriptError('check.por is only valid for scp80 actions')
if por not in POR_CHECKS: if por not in POR_CHECKS:
raise ScriptError('check.por must be none, ok or any') raise ScriptError('check.por must be none, ok, any or an object')
return {'sw': sw, 'data': data, 'por': por} return {'sw': sw, 'data': data, 'por': por}
@@ -416,15 +446,36 @@ def _normalise_content_check(c, default_level):
'on_fail': level} 'on_fail': level}
if kind == 'por': if kind == 'por':
out = {'kind': 'por', 'on_fail': level} out = {'kind': 'por', 'on_fail': level}
status = c.get('status') out.update(_por_fields(c, 'por check'))
if status is not None and str(status).strip(): return out
out['status'] = str(status).strip() if kind == 'alpha':
if c.get('sw') not in (None, ''): mode = str(c.get('mode') or 'contains').lower()
out['sw'] = _check_spec(c.get('sw'), 'por check sw') if mode not in ('contains', 'exact'):
if c.get('data') not in (None, ''): raise ScriptError('alpha check: mode must be contains or exact')
out['data'] = _check_spec(c.get('data'), 'por check data') if c.get('value') is None:
if not (out.get('status') or out.get('sw') or out.get('data')): raise ScriptError('alpha check: value is required')
raise ScriptError('por check: status, sw or data is required') return {'kind': 'alpha', 'mode': mode, 'value': str(c['value']),
'case_sensitive': bool(c.get('case_sensitive', True)),
'on_fail': level}
if kind == 'sms':
# The SEND SHORT MESSAGE TPDU fields (an applet's own SMS, as opposed
# to the PoR of an OTA exchange).
out = {'kind': 'sms', 'on_fail': level}
da = c.get('da')
if da is not None and str(da).strip():
d = re.sub(r'\s', '', str(da))
if not re.fullmatch(r'[0-9*#+]{1,20}', d):
raise ScriptError('sms check: da must be up to 20 digits')
out['da'] = d
for key in ('pid', 'dcs'):
if c.get(key) not in (None, ''):
out[key] = _check_spec(c.get(key), 'sms check %s' % key)
if c.get('udl') not in (None, ''):
out['udl'] = _int(c.get('udl'), 'sms check udl', 0, 255)
if c.get('ud') not in (None, ''):
out['ud'] = _check_spec(c.get('ud'), 'sms check ud')
if not any(k in out for k in ('da', 'pid', 'dcs', 'udl', 'ud')):
raise ScriptError('sms check: da, pid, dcs, udl or ud is required')
return out return out
if kind == 'files': if kind == 'files':
val = c.get('files', c.get('value')) val = c.get('files', c.get('value'))
+215
View File
@@ -213,6 +213,72 @@ class TestValidation(unittest.TestCase):
{'type': 'expect', 'command': 'ANY', {'type': 'expect', 'command': 'ANY',
'checks': [{'kind': 'files', 'value': ['zz']}]}]}, _resolver) 'checks': [{'kind': 'files', 'value': ['zz']}]}]}, _resolver)
def test_scp80_format_param_and_por_object(self):
# v3.19.0: the scp80 step may wrap the C-APDU in the expanded Command
# Scripting template (TS 102 226 5.2.1) and assert the decoded PoR
# inline (`check.por` as an object).
script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'scp80',
'params': {'apdu': '80E2900000', 'format': 'expanded'},
'check': {'por': {'status': 'por_ok', 'sw': '6A8?',
'data': {'mode': 'mask', 'value': 'AA??'}}}},
]}, _resolver)
params = script['steps'][0]['params']
self.assertEqual(params['format'], 'expanded')
por = script['steps'][0]['check']['por']
self.assertEqual(por['status'], 'por_ok')
self.assertEqual(por['sw'], {'mode': 'mask', 'value': '6A8?'})
self.assertEqual(por['data'], {'mode': 'mask', 'value': 'AA??'})
# both codings are accepted; an unknown one is refused
for fmt in ('compact', 'expanded-ae'):
script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'scp80',
'params': {'apdu': '80E2', 'format': fmt}}]}, _resolver)
self.assertEqual(script['steps'][0]['params']['format'], fmt)
with self.assertRaises(T.ScriptError):
T.normalise_script({'steps': [
{'type': 'action', 'kind': 'scp80',
'params': {'apdu': '80E2', 'format': 'auto'}}]}, _resolver)
# an empty PoR object has nothing to assert; the object is scp80-only
with self.assertRaises(T.ScriptError):
T.normalise_script({'steps': [
{'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2'},
'check': {'por': {}}}]}, _resolver)
with self.assertRaises(T.ScriptError):
T.normalise_script({'steps': [
{'type': 'action', 'kind': 'status', 'params': {'attempts': 1},
'check': {'por': {'status': 'por_ok'}}}]}, _resolver)
def test_alpha_and_sms_content_checks(self):
# `alpha` reads the Alpha identifier ('05', Annex A coded); `sms`
# asserts the SEND SHORT MESSAGE TPDU fields (submit only).
script = T.normalise_script({'steps': [
{'type': 'expect', 'command': 'DISPLAY TEXT',
'checks': [{'kind': 'alpha', 'mode': 'contains', 'value': 'Alfa',
'case_sensitive': False}]},
{'type': 'expect', 'command': 'SEND SHORT MESSAGE',
'checks': [{'kind': 'sms', 'da': '12 345', 'pid': '7F', 'dcs': '00',
'udl': 5, 'ud': '0271??AABB'}]},
]}, _resolver)
alpha = script['steps'][0]['checks'][0]
self.assertEqual(alpha['kind'], 'alpha')
self.assertEqual(alpha['mode'], 'contains')
self.assertFalse(alpha['case_sensitive'])
sms = script['steps'][1]['checks'][0]
self.assertEqual(sms['da'], '12345')
self.assertEqual(sms['pid'], {'mode': 'exact', 'value': '7F'})
self.assertEqual(sms['dcs'], {'mode': 'exact', 'value': '00'})
self.assertEqual(sms['udl'], 5)
self.assertEqual(sms['ud'], {'mode': 'mask', 'value': '0271??AABB'})
for check in ({'kind': 'alpha'}, # no value
{'kind': 'alpha', 'mode': 'nope', 'value': 'x'},
{'kind': 'sms'}, # no fields
{'kind': 'sms', 'da': '12X'},
{'kind': 'sms', 'udl': 256}):
with self.assertRaises(T.ScriptError):
T.normalise_script({'steps': [
{'type': 'expect', 'command': 'ANY', 'checks': [check]}]}, _resolver)
class TestMatchers(unittest.TestCase): class TestMatchers(unittest.TestCase):
def test_match_value(self): def test_match_value(self):
@@ -883,6 +949,155 @@ class TestRunnerActions(RunnerTestCase):
self.assertTrue(labels['Files']['ok'], labels) self.assertTrue(labels['Files']['ok'], labels)
self.assertEqual(labels['Files']['actual'], '3F007F106F3A') self.assertEqual(labels['Files']['actual'], '3F007F106F3A')
# a DISPLAY TEXT carrying the Alpha identifier ('05' = 'Alfa') plus the
# text string ('8D' = 'hello') - the alpha check must not read the text
DISPLAY_TEXT_ALPHA_CMD = 'D0178103012100820281820504416C66618D060468656C6C6F'
# a SEND SHORT MESSAGE with an SMS-SUBMIT: DA 12345, PID 7F, DCS 00,
# UDL 5, UD 02 71 00 AA BB
SEND_SM_SUBMIT_CMD = 'D01A8103011300820283810B0F010005812143F57F0005027100AABB'
def test_expect_alpha_and_sms_checks(self):
# the Alpha identifier ('05', TS 102 223 8.2) and the SEND SHORT
# MESSAGE TPDU fields (an applet's own SMS, not the OTA PoR)
scc = FakeScc()
scc.push('80F2', '', '9102')
scc.push('8012', self.DISPLAY_TEXT_ALPHA_CMD, '9000')
scc.push('8014', '', '9000')
scc.push('80F2', '', '9102')
scc.push('8012', self.SEND_SM_SUBMIT_CMD, '9000')
scc.push('8014', '', '9000')
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'status', 'params': {'attempts': 2},
'check': {'sw': {'mode': 'mask', 'value': '91??'}}},
{'type': 'expect', 'command': 'DISPLAY TEXT',
'checks': [{'kind': 'alpha', 'mode': 'exact', 'value': 'Alfa'}],
'respond': {'result': 'ok'}},
{'type': 'action', 'kind': 'status', 'params': {'attempts': 2},
'check': {'sw': {'mode': 'mask', 'value': '91??'}}},
{'type': 'expect', 'command': 'SEND SHORT MESSAGE',
'checks': [{'kind': 'sms', 'da': '12345', 'pid': '7F', 'dcs': '00',
'udl': 5, 'ud': '027100AABB'}],
'respond': {'result': 'ok'}},
])
self.assertEqual(run['status'], 'ok', run['steps'])
labels = {c['label']: c for c in run['steps'][1]['checks']}
self.assertTrue(labels['Alpha']['ok'], labels)
self.assertEqual(labels['Alpha']['actual'], 'Alfa')
labels = {c['label']: c for c in run['steps'][3]['checks']}
for label in ('SMS DA', 'SMS PID', 'SMS DCS', 'SMS UDL', 'SMS UD'):
self.assertTrue(labels[label]['ok'], labels)
self.assertEqual(run['steps'][3]['sms']['da'], '12345')
log = buf.getvalue()
self.assertIn('TEST-RUN step 4: SMS DA=12345 PID=7F DCS=00 UDL=5 UD=027100AABB', log)
def test_expect_sms_check_mismatch_terminates(self):
scc = FakeScc()
scc.push('80F2', '', '9102')
scc.push('8012', self.SEND_SM_SUBMIT_CMD, '9000')
scc.push('8014', '', '9000')
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'status', 'params': {'attempts': 2},
'check': {'sw': {'mode': 'mask', 'value': '91??'}}},
{'type': 'expect', 'command': 'SEND SHORT MESSAGE',
'checks': [{'kind': 'sms', 'dcs': 'AA'}], 'respond': {'result': 'ok'}},
])
self.assertNotEqual(run['status'], 'ok')
labels = {c['label']: c for c in run['steps'][1]['checks']}
self.assertFalse(labels['SMS DCS']['ok'])
self.assertEqual(labels['SMS DCS']['expected'], 'AA')
self.assertEqual(labels['SMS DCS']['actual'], '00')
def test_scp80_format_wraps_the_apdu(self):
# `format` wraps the C-APDU in the expanded Command Scripting template
# (TS 102 226 5.2.1): `expanded` = AA/<len>/22/..., `expanded-ae` =
# AE 80 ... 00 00. A compact step keeps the APDU verbatim.
for fmt, want in (('', '80E2900000'),
('expanded', 'AA07220580E2900000'),
('expanded-ae', 'AE80220580E29000000000')):
scc = FakeScc()
preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16,
'kidKey': 'BB' * 16, 'counter': '00000001',
'tars': preset_tars()}
params = {'apdu': '80E2900000'}
if fmt:
params['format'] = fmt
buf = io.StringIO()
with mock.patch.object(S, '_build_secured_packet',
return_value=('AA' * 10, {})) as build, \
mock.patch.object(S, '_send_secured_packet',
return_value={'success': True, 'sw': '9000',
'response_data': None}):
with contextlib.redirect_stderr(buf):
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'scp80', 'params': params},
], preset)
self.assertEqual(run['status'], 'ok', run['steps'])
self.assertEqual(build.call_args[0][6], want)
log = buf.getvalue()
if fmt:
self.assertIn('TEST-RUN step 1: SCP80 C-APDU=80E2900000 '
'WRAPPED=%s (%s)' % (want, fmt), log)
self.assertIn('C-APDU=%s format=%s' % (want, fmt),
run['steps'][0]['sent'])
else:
self.assertIn('TEST-RUN step 1: SCP80 C-APDU=80E2900000', log)
self.assertNotIn('WRAPPED', log)
self.assertNotIn('format=', run['steps'][0]['sent'])
def test_scp80_inline_por_object_check(self):
# `check.por` as an object asserts the decoded PoR (status/SW/data) of
# this very exchange - inline here, the SEND SHORT MESSAGE transport
# works the same
decoded = {'response_status': 'por_ok', 'tar': '000000',
'cntr': '0000000A', 'raw': '0271000021AABB',
'decoded': {'last_status_word': '9000',
'last_response_data': 'AABB'}}
preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16,
'kidKey': 'BB' * 16, 'counter': '0000000A',
'tars': preset_tars()}
with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})), \
mock.patch.object(S, '_send_secured_packet',
return_value={'success': True, 'sw': '9000',
'response_data': '0271000021AABB'}), \
mock.patch.object(S, '_decode_por', return_value=decoded):
run = self.run_script(FakeServer(FakeScc()), [
{'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'},
'check': {'por': {'status': 'por_ok', 'sw': '9000',
'data': 'AABB'}}},
], preset)
self.assertEqual(run['status'], 'ok', run['steps'])
labels = {c['label']: c for c in run['steps'][0]['checks']}
self.assertTrue(labels['PoR status']['ok'], labels)
self.assertTrue(labels['PoR SW']['ok'], labels)
self.assertTrue(labels['PoR data']['ok'], labels)
# a mismatch fails the step; without a decoded PoR the check names it
with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})), \
mock.patch.object(S, '_send_secured_packet',
return_value={'success': True, 'sw': '9000',
'response_data': '0271000021AABB'}), \
mock.patch.object(S, '_decode_por', return_value=decoded):
run = self.run_script(FakeServer(FakeScc()), [
{'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'},
'check': {'por': {'status': 'por_error'}}},
], preset)
self.assertNotEqual(run['status'], 'ok')
labels = {c['label']: c for c in run['steps'][0]['checks']}
self.assertFalse(labels['PoR status']['ok'])
self.assertEqual(labels['PoR status']['actual'], 'por_ok')
with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})), \
mock.patch.object(S, '_send_secured_packet',
return_value={'success': True, 'sw': '9000',
'response_data': None}):
run = self.run_script(FakeServer(FakeScc()), [
{'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'},
'check': {'por': {'status': 'por_ok'}}},
], preset)
labels = {c['label']: c for c in run['steps'][0]['checks']}
self.assertFalse(labels['PoR']['ok'])
self.assertIn('no PoR', labels['PoR']['actual'])
def test_parse_file_list_splits_concatenated_paths(self): def test_parse_file_list_splits_concatenated_paths(self):
raw = bytes.fromhex('D016810301010082028381120B023F002FE23F007F106F3A') raw = bytes.fromhex('D016810301010082028381120B023F002FE23F007F106F3A')
self.assertEqual(S._parse_file_list(raw), ['3F002FE2', '3F007F106F3A']) self.assertEqual(S._parse_file_list(raw), ['3F002FE2', '3F007F106F3A'])