scp81: PSK by identity, scripts page, exact snapshots, SCP80 LOAD fit (v2.2.0)

Cards / SCP81:
- Cards is a top-level tab; presets gain PSK identity + key, HTTP-OTA
  column, Edit/Update and a live PSK-map push into a running listener.
- SCP81 has Listener/Scripts pills; scripts are named local APDU lists
  (Empty / Explore / Install from .cap / Delete templates), sent to the
  server explicitly at start. The listener takes mode/host/port/script
  only; PSK inputs and the .cap row are gone.
- Multi-PSK TLS listener: identity -> key lookup from the card presets
  (POST /api/scp81/psk-map updates a running listener), unknown
  identities log tls-psk-unknown and fail; handshake logs carry psk_match.
- Script engine: execution tracking (next/done/pending/results), a
  resumed dialog sends only the unexecuted tail (unreported APDU is
  resent), a fresh dialog restarts, listing continuation pages are
  tracked separately (pending.pages/complete). Restart script button.
- POST /api/scp81/gen-install replaces the SCP81 ram-install queueing
  (generation only; the .cap is never stored).

Profiler / snapshots:
- Snapshot comparison is always exact (mask checkboxes removed; the
  first-4-bytes mask remains a profile-creation option).
- "matching records" line shows count + #record numbers.
- New Clone action: copy named "Copy of <profile>", opened in the editor.
- Matched-record count/numbers fix ("1 из 8 (#8)").

SCP80:
- Configurable / auto-fitted LOAD block size: each LOAD APDU encodes into
  one SMS (pySim rejects secured packets above 140 octets, so a 240-byte
  block could never be sent). Response reports the effective size and
  clamps; encode failures are reported per step with the pySim message.

SW cache otaman-v161; docs/api.md, scp81-findings and help EN/RU updated.
Tests: 226 Python + 356 frontend.
This commit is contained in:
2026-09-16 13:32:26 +03:00
parent ef8b651f28
commit 8c90958718
14 changed files with 1998 additions and 564 deletions
+99 -38
View File
@@ -47,9 +47,13 @@ connect and warns if versions are incompatible.
| `/api/pli-dict` | GET | Current dictionary (hex values per qualifier) | | `/api/pli-dict` | GET | Current dictionary (hex values per qualifier) |
| `/api/pli-dict` | POST | Update dictionary entries | | `/api/pli-dict` | POST | Update dictionary entries |
| `/api/scp81/bip` | POST | Start/stop the HTTP OTA listener (dump capture or PSK TLS server) | | `/api/scp81/bip` | POST | Start/stop the HTTP OTA listener (dump capture or PSK TLS server) |
| `/api/scp81/status` | GET | BIP terminal + listener state (channels, PSK identity seen) | | `/api/scp81/status` | GET | BIP terminal + listener state (channels, PSK identities, handshake identity) |
| `/api/scp81/log` | GET | HTTP OTA event log (`?after=<seq>`) | | `/api/scp81/log` | GET | HTTP OTA event log (`?after=<seq>`) |
| `/api/scp81/log-clear` | POST | Clear the HTTP OTA event log | | `/api/scp81/log-clear` | POST | Clear the HTTP OTA event log |
| `/api/scp81/queue` | POST | Replace the SCP81 command script (optionally force-restart) |
| `/api/scp81/script` | GET | Active command script + execution state and R-APDUs |
| `/api/scp81/psk-map` | POST | Replace the PSK table of a running TLS listener |
| `/api/scp81/gen-install` | POST | Generate the RAM APDU list for a `.cap` (no queueing) |
## Endpoint details ## Endpoint details
@@ -176,6 +180,7 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL
| `stk_params` | no | Hex CA TLV (TS 102 226 §8.2.1.3.2.1) for SIM toolkit app-specific params | | `stk_params` | no | Hex CA TLV (TS 102 226 §8.2.1.3.2.1) for SIM toolkit app-specific params |
| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) for `gen_install_parameters()` | | `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) for `gen_install_parameters()` |
| `make_selectable` | no | If true (default), final INSTALL uses P1=`0C` (install + make selectable) | | `make_selectable` | no | If true (default), final INSTALL uses P1=`0C` (install + make selectable) |
| `load_block_size` | no | Bytes of load-file payload per LOAD APDU, 1240. When empty/omitted the server auto-fits: the largest size whose SCP80 secured packet still encodes into one SMS (140 octets; e.g. 107 for the 3DES `spi1=16/spi2=01` configuration). An explicit value larger than the fitting size is clamped; over SCP80 the default 240 does **not** fit and used to fail with pySim's "Cannot encode command in a single SMS". |
**Response (success):** **Response (success):**
```json ```json
@@ -186,9 +191,17 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL
"final_cntr": "0000000004", "final_cntr": "0000000004",
"load_file_aid": "A000000003000000", "load_file_aid": "A000000003000000",
"module_aid": "A000000003000000", "module_aid": "A000000003000000",
"application_aid": "A000000003000000"} "application_aid": "A000000003000000",
"load_block_size": 107,
"load_block_size_requested": null,
"load_block_size_clamped": false}
``` ```
`load_block_size` is the effective size used for the LOAD blocks,
`load_block_size_requested` echoes an explicit `load_block_size` (null =
auto-fit) and `load_block_size_clamped` is true when the requested size was
reduced to fit one SMS.
**Response (failure):** **Response (failure):**
```json ```json
{"success": false, "failed_step": "load_1", {"success": false, "failed_step": "load_1",
@@ -467,19 +480,29 @@ ClientHello) without answering:
{"action": "start", "mode": "dump", "host": "127.0.0.1", "port": 8443} {"action": "start", "mode": "dump", "host": "127.0.0.1", "port": 8443}
``` ```
TLS mode runs the Phase B PSK TLS server (GPC v2.2 Amendment B): the PSK key TLS mode runs the Phase B PSK TLS server (GPC v2.2 Amendment B): the PSK
and optional identity are applied to the TLS handshake, and the GP HTTP table is applied to the TLS handshake, and the GP HTTP administration dialog
administration dialog (`X-Admin-*` headers, 200 with a command string or 204 (`X-Admin-*` headers, 200 with a command string or 204 No Content) is served.
No Content) is served. `psk_hex` is required (the previous key is reused when `psk_map` is the lookup table for the identity the card presents in the TLS
omitted); `psk_identity` restricts the accepted identity. The key is never handshake — the PWA sends it from the card presets (`{identity, psk_hex}`
stored or logged. objects or an `{identity: psk_hex}` map); a handshake whose identity is not
listed fails with the log entry `tls-psk-unknown`. The legacy single-key form
`psk_hex` (with optional `psk_identity`, empty = accept any identity) is still
accepted; when both are omitted the table of the previous start is reused.
Keys are never stored or logged.
```json ```json
{"action": "start", "mode": "tls", "host": "127.0.0.1", "port": 8443, {"action": "start", "mode": "tls", "host": "127.0.0.1", "port": 8443,
"psk_hex": "00112233445566778899aabbccddeeff", "psk_map": [{"identity": "89012345678901234567",
"psk_identity": "89012345678901234567"} "psk_hex": "00112233445566778899aabbccddeeff"}],
"script": ["80CAFF2100", "80F28002024F0000"], "script_kind": "Explore"}
``` ```
`script` is the APDU list served to the card (an explicit list, or `none`);
the server is agnostic to what the APDUs do. `script_kind` is an optional
label for the logs/results. Omitting `script` keeps the configured script and
its run progress.
Stop either mode with `{"action": "stop"}` (also disables the BIP terminal). Stop either mode with `{"action": "stop"}` (also disables the BIP terminal).
### `GET /api/scp81/status` ### `GET /api/scp81/status`
@@ -487,9 +510,29 @@ Stop either mode with `{"action": "stop"}` (also disables the BIP terminal).
```json ```json
{"bip": {"enabled": true, "target": "127.0.0.1:8443", "channels": [], "seq": 12}, {"bip": {"enabled": true, "target": "127.0.0.1:8443", "channels": [], "seq": 12},
"listener": {"mode": "tls", "host": "127.0.0.1", "port": 8443, "listener": {"mode": "tls", "host": "127.0.0.1", "port": 8443,
"psk_identity": null, "identity_seen": "89012345678901234567"}} "psk_identities": ["89012345678901234567"], "psk_wildcard": false,
"identity_seen": "89012345678901234567", "identity_matched": true}}
``` ```
`psk_identities` lists the identities the listener accepts (keys are never
exposed); `psk_wildcard` marks the legacy single-key mode. `identity_seen` /
`identity_matched` reflect the last handshake: an unknown identity is logged
as `tls-psk-unknown` and the handshake fails.
### `POST /api/scp81/psk-map`
Replaces the PSK table of the running TLS listener (the PWA pushes card-preset
edits without a listener restart):
```json
{"psk_map": [{"identity": "89012345678901234567",
"psk_hex": "00112233445566778899aabbccddeeff"}]}
```
Returns `{"ok": true, "identities": [...], "listener": {...}}`; entries
without an identity or a valid key are skipped, and an empty table is
rejected.
### `GET /api/scp81/log` ### `GET /api/scp81/log`
Returns the BIP/TLS event log (open/close, SEND/RECEIVE DATA hex, TLS Returns the BIP/TLS event log (open/close, SEND/RECEIVE DATA hex, TLS
@@ -498,50 +541,68 @@ newer entries; `seq` echoes the latest sequence number.
### `POST /api/scp81/queue` ### `POST /api/scp81/queue`
Queue explicit commands as the SCP81 script (used by the Remote APDU tab's Replace the SCP81 command script (used by the Remote APDU tab's RAM chain
RAM chain "Queue in SCP81"). Body `{"apdus": ["80E60C002E...", ...]}` (or a "Queue in SCP81" and the PWA's "Restart script"). Body
single `apdu`), optional `kind` and `force`. Entries that already are `{"apdus": ["80E60C002E...", ...]}` (or a single `apdu`), optional `kind` and
Command Scripting templates (`AA...`/`AE80...`, the expanded format) are `force`. Entries that already are Command Scripting templates
sent verbatim instead of being wrapped again. Refused while a script is (`AA...`/`AE80...`, the expanded format) are sent verbatim instead of being
mid-run unless forced. wrapped again. Refused while a script is mid-run unless forced; queuing resets
the execution progress.
### `POST /api/scp81/ram-install` ### `POST /api/scp81/gen-install`
Queue a RAM (GP) install as the SCP81 command script. The `.cap` is parsed Generate the RAM (GP) APDU sequence for a `.cap` without touching the listener
server-side (same parser as `/api/ram-install`) and expanded to the APDU or the running script; the PWA's "Install from .cap" script template stores
sequence INSTALL [for load] -> LOAD blocks (240-byte payloads) -> INSTALL the returned list. The `.cap` is parsed server-side (same parser as
[for install]; the list runs on the card's next POST, one C-APDU per request. `/api/ram-install`) and expanded to INSTALL [for load] -> LOAD blocks
(240-byte payloads) -> INSTALL [for install]; the file itself is never stored.
```json ```json
{"cap_hex": "504B0304...", "sd_aid": "A000000003000000", "privileges": "00", {"cap_hex": "504B0304...", "sd_aid": "A000000003000000", "privileges": "00",
"install_params": "", "stk_params": "", "make_selectable": true, "force": false} "install_params": "", "stk_params": "", "make_selectable": true}
``` ```
`sd_aid` empty = the ISD. Refused while a script is mid-run unless `force` is `sd_aid` empty = the ISD. Responds with `{"ok": true, "apdus": [...],
true. Responds with `{"ok": true, "queued": true, "apdus": N, "load_file_aid": "load_file_aid": ..., "module_aid": ...}`.
..., "module_aid": ...}`; the results appear in `/api/scp81/script` and the
R-APDU log. `GET /api/scp81/script` reports the script `kind`
(`explore`/`none`/`custom`/`ram-install`).
### `GET /api/scp81/script` ### `GET /api/scp81/script`
Returns the active command script and the R-APDUs collected so far: Returns the configured command script and the execution state:
```json ```json
{"script": ["80CAFF2100", "80F28002024F0000"], "sent": 1, {"script": ["80CAFF2100", "80F28002024F0000"], "next": 2, "total": 2,
"results": [{"index": 1, "sw": "9000", "rapdu": "FF210C810102..."}]} "done": [0, 1], "kind": "Explore",
"pending": {"index": 17, "pos": null, "page": true, "apdu": "80F28003024F0000"},
"pages": 11, "pages_queued": 0, "complete": false,
"results": [{"index": 1, "pos": 0, "page": false, "sw": "9000",
"apdu": "80CAFF2100", "rapdu": "FF210C810102..."}]}
``` ```
The script is selected when starting the TLS listener with the `script` `next` is the index of the next script APDU to send; `done` lists the script
parameter: `explore` (default — the reference administration server's command indices the card reported. `pending` describes the C-APDU awaiting the card's
sequence: GET DATA FF21 extended resources / free memory, GET STATUS P1=80 `X-Admin-Script-Status` report as `{index, pos, page, apdu}` (`pos` = script
Issuer Security Domain, GET DATA 0085 HTTP administration parameters, GET index, `null` for an auto continuation page) or `null`; `pages` counts the
STATUS P1=40 executable load files and P1=10 applications), `none` (answer continuation pages queued so far and `pages_queued` those not yet sent.
every POST with 204), or an explicit list of APDU hex strings. Each APDU is `complete` is true when every configured APDU was reported and nothing is in
flight — a script can therefore be complete while a listing page is still
being fetched (`pending.page` = true), which is tracked separately from the
script's own progress. `results` entries carry the send order (`index`), the
script position (`pos`, `null` for continuation pages) and the `page` flag.
Execution tracking and resume: an APDU counts as executed only when the card
reports it in the next POST's Response Scripting template. A POST with
`X-Admin-Resume` continues with the unexecuted tail (the pending APDU is
resent if its report never arrived), a POST without it is a fresh dialog where
the script runs from the start, and a completed script closes the session with
204.
Each APDU is
delivered in an `AE 80 22 <len> <apdu> 00 00` Command Scripting template delivered in an `AE 80 22 <len> <apdu> 00 00` Command Scripting template
(TS 102 226 §5.2.1) with `X-Admin-Next-URI`; the card returns its R-APDUs in (TS 102 226 §5.2.1) with `X-Admin-Next-URI`; the card returns its R-APDUs in
the next POST's Response Scripting template, which is parsed and logged the next POST's Response Scripting template, which is parsed and logged
(`script-rapdu`, `script-memory`). (`script-rapdu`, `script-memory`). Long GET STATUS listings that answer
`63 10` / `CA FE` ("more data available") are auto-continued with the same
command carrying P2.b1=1.
TLS mode also accepts `chunked` (**default `true`** — the reference server's TLS mode also accepts `chunked` (**default `true`** — the reference server's
chunked framing; the card rejects a chunked response that also carries a chunked framing; the card rejects a chunked response that also carries a
+4 -3
View File
@@ -306,11 +306,12 @@ INSTALL [for install] -> registries.
1. **UI:** group the per-page R-APDUs under their logical command in the 1. **UI:** group the per-page R-APDUs under their logical command in the
SCP81 tab (page merging/decoding for ELF and application listings); SCP81 tab (page merging/decoding for ELF and application listings);
expose the framing options in the tab. expose the framing options in the tab.
2. **Load/store over SCP81:** implemented - `POST /api/scp81/ram-install` 2. **Load/store over SCP81:** implemented - `POST /api/scp81/gen-install`
takes a `.cap`, expands it with the shared `_cap_apdu_sequence` helper takes a `.cap`, expands it with the shared `_cap_apdu_sequence` helper
(INSTALL [for load] -> 240-byte LOAD blocks -> INSTALL [for install]) and (INSTALL [for load] -> 240-byte LOAD blocks -> INSTALL [for install]) and
queues it as the command script, one C-APDU per POST. Live install test returns the APDU list, which the PWA stores as an "Install from .cap"
pending (needs a push with a suitable applet). script (the `.cap` itself is never stored). Live install verified
2026-09-16.
## Tooling ## Tooling
+11 -9
View File
@@ -44,7 +44,7 @@
<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> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>.</li> <li><strong>Шапка</strong> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>.</li>
<li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li> <li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li>
<li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>Разбор C-APDU</strong>, <strong>&laquo;Парсер ответов&raquo;</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Карты</strong>, <strong>RAM</strong>), <strong>&laquo;Профайлер&raquo;</strong> (вкладки <strong>&laquo;Профили&raquo;</strong>, <strong>&laquo;Снимки карт&raquo;</strong>, <strong>&laquo;Пользовательские файлы&raquo;</strong>), <strong>&laquo;Картридер&raquo;</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>&laquo;Симулятор телефона&raquo;</strong>.</li> <li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>Разбор C-APDU</strong>, <strong>&laquo;Парсер ответов&raquo;</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>RAM</strong>), <strong>SCP81</strong> (<strong>&laquo;Слушатель&raquo;</strong>, <strong>&laquo;Скрипты&raquo;</strong>), <strong>&laquo;Карты&raquo;</strong>, <strong>&laquo;Профайлер&raquo;</strong> (вкладки <strong>&laquo;Профили&raquo;</strong>, <strong>&laquo;Снимки карт&raquo;</strong>, <strong>&laquo;Пользовательские файлы&raquo;</strong>), <strong>&laquo;Картридер&raquo;</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>&laquo;Симулятор телефона&raquo;</strong>.</li>
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка &laquo;Профайлер&raquo; открывает &sect;5).</li> <li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка &laquo;Профайлер&raquo; открывает &sect;5).</li>
</ul> </ul>
@@ -241,7 +241,7 @@
<section class="mb-10"> <section class="mb-10">
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. Вкладка SCP80</h2> <h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. Вкладка SCP80</h2>
<p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — тремя переключателями: <strong>Secured Packet</strong>, <strong>Карты</strong> и <strong>RAM</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225.</p> <p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — двумя переключателями: <strong>Secured Packet</strong> и <strong>RAM</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225.</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">Собирает защищённые пакеты SCP80 по ETSI TS 102 225.</p> <p class="mb-2">Собирает защищённые пакеты SCP80 по ETSI TS 102 225.</p>
@@ -275,7 +275,7 @@
<p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>&laquo;Парсер ответов&raquo;</strong> (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.</p> <p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>&laquo;Парсер ответов&raquo;</strong> (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.</p>
<h3 id="cards" class="text-lg font-medium mb-2">3.2 Карты</h3> <h3 id="cards" class="text-lg font-medium mb-2">3.2 Карты</h3>
<p class="mb-2">Хранит предустановки карт локально в браузере (<code class="font-mono text-sm">localStorage</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры.</p> <p class="mb-2">Хранит предустановки карт локально в браузере (<code class="font-mono text-sm">localStorage</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, а слушатель SCP81 HTTP OTA — находить PSK-ключи. Вкладка &laquo;Карты&raquo; — верхнеуровневая.</p>
<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">Поле</th><th class="text-left py-1 px-2">Описание</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Поле</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
<tbody> <tbody>
@@ -285,20 +285,22 @@
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter</td><td class="py-1 px-2">Счётчик повторов (5 байт)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter</td><td class="py-1 px-2">Счётчик повторов (5 байт)</td></tr>
<tr><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex-символа (ключи 8/16/24 байта 3DES) или 32/48/64 hex-символа (ключи 16/24/32 байта AES)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex-символа (ключи 8/16/24 байта 3DES) или 32/48/64 hex-символа (ключи 16/24/32 байта AES)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">PSK identity</td><td class="py-1 px-2">SCP81 HTTP OTA: идентификатор, который карта присылает в TLS-рукопожатии (например, <code class="font-mono text-sm">89390100000129506903</code>)</td></tr>
<tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex-символа (16 байт). Слушатель выбирает этот ключ, когда карта предъявляет совпадающий идентификатор; предустановка с ключом без идентификатора игнорируется (и помечается в таблице)</td></tr>
</tbody> </tbody>
</table> </table>
<p class="text-sm mb-3">Обмен предустановками: <strong>Экспорт в JSON</strong> и <strong>Экспорт в файл</strong> для выгрузки, <strong>Импорт из файла</strong>, <strong>Вставить и импортировать</strong> или <strong>Импорт JSON из буфера</strong> для загрузки. Выбранная предустановка автоматически заполняет форму Secured Packet.</p> <p class="text-sm mb-3">Столбец <strong>SCP81</strong> показывает, задана ли в предустановке рабочая пара PSK. Кнопка <strong>Изменить</strong> загружает предустановку в форму (кнопка становится <strong>Сохранить</strong>; <strong>Отмена</strong> очищает форму), поэтому поля можно менять без повторного ввода карты. Обмен предустановками: <strong>Экспорт в JSON</strong> и <strong>Экспорт в файл</strong> для выгрузки, <strong>Импорт из файла</strong>, <strong>Вставить и импортировать</strong> или <strong>Импорт JSON из буфера</strong> для загрузки. Выбранная предустановка автоматически заполняет форму Secured Packet; изменения сразу передаются работающему слушателю SCP81.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.3 RAM</h3> <h3 id="ram" class="text-lg font-medium mb-2">3.3 RAM</h3>
<p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Предустановка карты из подвкладки <strong>Карты</strong> обеспечивает SPI, ключи, TAR и счётчик.</p> <p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Предустановка карты со вкладки <strong>Карты</strong> обеспечивает SPI, ключи, TAR и счётчик.</p>
<h4 id="ram-operations" class="font-medium mb-1">Операции</h4> <h4 id="ram-operations" class="font-medium mb-1">Операции</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">Операция</th><th class="text-left py-1 px-2">Описание</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Операция</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
<tbody> <tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Обзор карты (все данные GP)</td><td class="py-1 px-2">Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками <strong>Удалить</strong> для каждого элемента.</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Обзор карты (все данные GP)</td><td class="py-1 px-2">Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками <strong>Удалить</strong> для каждого элемента.</td></tr>
<tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)].</td></tr> <tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU, каждый из которых помещается в один SMS SCP80; поле <strong>размер блока LOAD</strong> переопределяет авто-подобранный размер (пусто = максимальный размер, чей secured-пакет укладывается в 140 октетов), так что большой <code>.cap</code> просто занимает несколько SMS.</td></tr>
</tbody> </tbody>
</table> </table>
@@ -378,7 +380,7 @@
<li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li> <li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
<li><strong>Профиль из снимка</strong> — выбирает сохранённый снимок карты и создаёт по правилу на каждый захваченный файл с теми же опциями сканирования (см. ниже), без картридера; имя профиля подставляется из имени снимка.</li> <li><strong>Профиль из снимка</strong> — выбирает сохранённый снимок карты и создаёт по правилу на каждый захваченный файл с теми же опциями сканирования (см. ниже), без картридера; имя профиля подставляется из имени снимка.</li>
<li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li> <li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li>
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li> <li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Клонировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>. <strong>Клонировать</strong> создаёт копию профиля с именем <em>Копия &lt;профиль&gt;</em> (с суффиксом <code class="font-mono text-sm">(2)</code>, <code class="font-mono text-sm">(3)</code>…, если такое имя уже занято) и открывает копию в редакторе, чтобы изменить имя и содержимое перед сохранением.</li>
</ul> </ul>
<h4 class="font-medium mb-1">Правила файловой системы</h4> <h4 class="font-medium mb-1">Правила файловой системы</h4>
<p class="text-sm mb-2">Правила выполняются последовательно. Редактор показывает символьное имя файла pySim (если известно) рядом с путём правила; <strong>Добавить правило</strong> добавляет правило, <strong>Сохранить</strong> сохраняет изменения. Правило файловой системы задаётся:</p> <p class="text-sm mb-2">Правила выполняются последовательно. Редактор показывает символьное имя файла pySim (если известно) рядом с путём правила; <strong>Добавить правило</strong> добавляет правило, <strong>Сохранить</strong> сохраняет изменения. Правило файловой системы задаётся:</p>
@@ -399,7 +401,7 @@
<li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li> <li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li>
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li> <li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты: фактическая сторона подписана именем снимка (<em>фактически (имя снимка)</em>), а в заголовке — <em>Результаты проверки профиля: &lt;профиль&gt; &rarr; &lt;имя снимка&gt;</em>; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li> <li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты: фактическая сторона подписана именем снимка (<em>фактически (имя снимка)</em>), а в заголовке — <em>Результаты проверки профиля: &lt;профиль&gt; &rarr; &lt;имя снимка&gt;</em>; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт; в этом отчёте заголовок — <em>Результаты сравнения снимков: &lt;эталон&gt; &rarr; &lt;проверяемый&gt;</em>, а поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li> <li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля, но <strong>всегда точно</strong> (маскирование содержимого не применяется): выберите <em>эталонный</em> снимок и <em>снимок для проверки</em> и получите такой же отчёт; в этом отчёте заголовок — <em>Результаты сравнения снимков: &lt;эталон&gt; &rarr; &lt;проверяемый&gt;</em>, а поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
</ul> </ul>
</section> </section>
+10 -8
View File
@@ -44,7 +44,7 @@
<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>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle.</li> <li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle.</li>
<li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li> <li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li>
<li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>C-APDU Parser</strong>, <strong>Response parser</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Cards</strong>, <strong>RAM</strong>), <strong>Profiler</strong> (list tabs <strong>Profiles</strong>, <strong>Card snapshots</strong>, <strong>Custom files</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), and <strong>Phone simulator</strong>.</li> <li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>C-APDU Parser</strong>, <strong>Response parser</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>RAM</strong>), <strong>SCP81</strong> (<strong>Listener</strong>, <strong>Scripts</strong>), <strong>Cards</strong>, <strong>Profiler</strong> (list tabs <strong>Profiles</strong>, <strong>Card snapshots</strong>, <strong>Custom files</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), and <strong>Phone simulator</strong>.</li>
<li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler tab opens &sect;5).</li> <li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler tab opens &sect;5).</li>
</ul> </ul>
@@ -275,7 +275,7 @@
<p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command&rsquo;s status word and response data are filled into the <strong>Response parser</strong> tab, and a successful PoR advances the replay counter and clears the packet.</p> <p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command&rsquo;s status word and response data are filled into the <strong>Response parser</strong> tab, and a successful PoR advances the replay counter and clears the packet.</p>
<h3 id="cards" class="text-lg font-medium mb-2">3.2 Cards</h3> <h3 id="cards" class="text-lg font-medium mb-2">3.2 Cards</h3>
<p class="mb-2">Stores card presets locally in the browser (<code class="font-mono text-sm">localStorage</code>) so the Secured Packet view can auto-fill keys and parameters.</p> <p class="mb-2">Stores card presets locally in the browser (<code class="font-mono text-sm">localStorage</code>) so the Secured Packet view can auto-fill keys and parameters, and so the SCP81 HTTP OTA listener can look up PSK keys. The Cards tab is a top-level tab.</p>
<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">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">Description</th></tr></thead>
<tbody> <tbody>
@@ -285,20 +285,22 @@
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter</td><td class="py-1 px-2">Replay counter (5 bytes)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter</td><td class="py-1 px-2">Replay counter (5 bytes)</td></tr>
<tr><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex chars (8/16/24-byte 3DES) or 32/48/64 hex chars (16/24/32-byte AES) keys</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex chars (8/16/24-byte 3DES) or 32/48/64 hex chars (16/24/32-byte AES) keys</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">PSK identity</td><td class="py-1 px-2">SCP81 HTTP OTA: the identity the card sends in the TLS handshake (e.g. <code class="font-mono text-sm">89390100000129506903</code>)</td></tr>
<tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex chars (16 bytes). The listener selects this key when the card presents the matching identity; a preset with a key but no identity is ignored (and flagged in the table)</td></tr>
</tbody> </tbody>
</table> </table>
<p class="text-sm mb-3">Presets can be shared with <strong>Export as JSON</strong> and <strong>Export to file</strong>, and restored with <strong>Import from file</strong>, <strong>Paste &amp; import</strong>, or <strong>Import JSON from clipboard</strong>. The selected card preset auto-fills the Secured Packet form.</p> <p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair. <strong>Edit</strong> loads a preset into the form (the button becomes <strong>Save</strong>; <strong>Cancel</strong> clears it) so fields can be changed without re-entering the card. Presets can be shared with <strong>Export as JSON</strong> and <strong>Export to file</strong>, and restored with <strong>Import from file</strong>, <strong>Paste &amp; import</strong>, or <strong>Import JSON from clipboard</strong>. The selected card preset auto-fills the Secured Packet form; edits are pushed into a running SCP81 listener automatically.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.3 RAM</h3> <h3 id="ram" class="text-lg font-medium mb-2">3.3 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> sub-tab provides the SPI, keys, TAR, and counter.</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 SPI, keys, TAR, and counter.</p>
<h4 id="ram-operations" class="font-medium mb-1">Operations</h4> <h4 id="ram-operations" class="font-medium mb-1">Operations</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">Operation</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">Operation</th><th class="text-left py-1 px-2">Description</th></tr></thead>
<tbody> <tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Explore Card (all GP data)</td><td class="py-1 px-2">Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 for memory info. Results appear in an explorer view with per-item <strong>Delete</strong> buttons.</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Explore Card (all GP data)</td><td class="py-1 px-2">Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 for memory info. Results appear in an explorer view with per-item <strong>Delete</strong> buttons.</td></tr>
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)].</td></tr> <tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs that each fit one SCP80 SMS; the <strong>LOAD block size</strong> field overrides the auto-fitted size (empty = largest size whose secured packet still encodes into 140 octets), so a large <code>.cap</code> simply takes several SMS.</td></tr>
</tbody> </tbody>
</table> </table>
@@ -378,7 +380,7 @@
<li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li> <li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li>
<li><strong>Profile from snapshot</strong> — picks a saved card snapshot and generates one rule per captured file using the same scan options (see below), without a card reader; the profile name is prefilled with the snapshot name.</li> <li><strong>Profile from snapshot</strong> — picks a saved card snapshot and generates one rule per captured file using the same scan options (see below), without a card reader; the profile name is prefilled with the snapshot name.</li>
<li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li> <li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li>
<li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li> <li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Clone</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions. <strong>Clone</strong> copies the profile under the name <em>Copy of &lt;profile&gt;</em> (with a <code class="font-mono text-sm">(2)</code>, <code class="font-mono text-sm">(3)</code>… suffix when that name already exists) and opens the copy in the editor, so both the name and the contents can be adjusted before saving.</li>
</ul> </ul>
<h4 class="font-medium mb-1">Filesystem rules</h4> <h4 class="font-medium mb-1">Filesystem rules</h4>
<p class="text-sm mb-2">Rules run sequentially. The editor shows the symbolic pySim name (when known) next to each rule&rsquo;s path; use <strong>Add rule</strong> to append one and <strong>Save</strong> to keep the changes. A filesystem rule is defined by:</p> <p class="text-sm mb-2">Rules run sequentially. The editor shows the symbolic pySim name (when known) next to each rule&rsquo;s path; use <strong>Add rule</strong> to append one and <strong>Save</strong> to keep the changes. A filesystem rule is defined by:</p>
@@ -399,7 +401,7 @@
<li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li> <li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li>
<li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li> <li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li>
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check: the actual side is labelled with the snapshot name (<em>actual (snapshot name)</em>) and the header reads <em>Profile verification results for: &lt;profile&gt; &rarr; &lt;snapshot name&gt;</em>; files whose contents were not captured during the scan are reported as unverifiable errors.</li> <li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check: the actual side is labelled with the snapshot name (<em>actual (snapshot name)</em>) and the header reads <em>Profile verification results for: &lt;profile&gt; &rarr; &lt;snapshot name&gt;</em>; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
<li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check: pick the <em>master</em> snapshot and the <em>snapshot to check</em>, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same pass/fail report; in that report the header reads <em>Snapshot comparison results: &lt;master&gt; &rarr; &lt;checked&gt;</em> and the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li> <li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check but <strong>always exact</strong> (no content masking): pick the <em>master</em> snapshot and the <em>snapshot to check</em> and get the same pass/fail report; in that report the header reads <em>Snapshot comparison results: &lt;master&gt; &rarr; &lt;checked&gt;</em> and the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li>
</ul> </ul>
</section> </section>
+692 -185
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v155'; const CACHE = 'otaman-v161';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+14 -1
View File
@@ -13,10 +13,23 @@ test('HTML <div> tags are balanced', () => {
test('top-level tabs match the rearranged views', () => { test('top-level tabs match the rearranged views', () => {
const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]); const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]);
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'scp81', 'profiler', 'pysim', 'phone']); assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'scp81', 'cards', 'profiler', 'pysim', 'phone']);
assert.match(html, /data-tab="c-apdu">Remote APDU</); assert.match(html, /data-tab="c-apdu">Remote APDU</);
}); });
test('cards list shows the SCP81 PSK column with blue/red row buttons', () => {
assert.match(html, /data-l10n="SCP81">SCP81</);
const fn = /function cardsRender\(\)[\s\S]*?\n\}/.exec(html);
assert.ok(fn, 'cardsRender not found');
assert.match(fn[0], /cardsEdit\(' \+ i \+ '\)" class="[^"]*bg-blue-600 text-white/);
assert.match(fn[0], /cardsRemove\(' \+ i \+ '\)" class="[^"]*bg-red-600 text-white/);
});
test('profile rows have a Clone action', () => {
assert.match(html, /onclick="profilerClone\(' \+ i \+ '\)"/);
assert.match(html, /t\('Clone'\)/);
});
test('response parser is a Remote APDU pill', () => { test('response parser is a Remote APDU pill', () => {
assert.match(html, /data-sub="response" onclick="cApduSwitchSubtab\('response'\)"/); assert.match(html, /data-sub="response" onclick="cApduSwitchSubtab\('response'\)"/);
assert.ok(html.includes('id="c-apdu-sub-response"')); assert.ok(html.includes('id="c-apdu-sub-response"'));
+77 -32
View File
@@ -21,7 +21,7 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1); return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
} }
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget']; const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'profilerMatchedRecordsText', 'profilerCloneName', 'profilerClone', 'profilerNewId', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget'];
let code = ''; let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n'; for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n'; code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
@@ -435,6 +435,63 @@ test('profilerNumRanges compresses consecutive record numbers', () => {
assert.strictEqual(profilerNumRanges([6]), '6'); assert.strictEqual(profilerNumRanges([6]), '6');
}); });
test('profilerMatchedRecordsText shows the count and prefixed record numbers', () => {
global.t = s => s;
assert.strictEqual(profilerMatchedRecordsText([8], 8), '1 of 8 (#8)');
assert.strictEqual(profilerMatchedRecordsText([1, 2, 3, 5], 8), '4 of 8 (#1\u2013#3, #5)');
assert.strictEqual(profilerMatchedRecordsText([1, 2, 3, 4, 5, 6, 7, 8, 10], 10),
'9 of 10 (#1\u2013#8, #10)');
// fallback for fixtures without a total: the count itself
assert.strictEqual(profilerMatchedRecordsText([2, 4], undefined), '2 of 2 (#2, #4)');
assert.strictEqual(profilerMatchedRecordsText([], 8), '');
delete global.t;
});
test('profilerCloneName makes a unique "Copy of" name', () => {
global.t = s => s;
assert.strictEqual(profilerCloneName('Foobar', []), 'Copy of Foobar');
assert.strictEqual(profilerCloneName('Foobar', ['Copy of Foobar']), 'Copy of Foobar (2)');
assert.strictEqual(profilerCloneName('Foobar', ['Copy of Foobar', 'Copy of Foobar (2)']), 'Copy of Foobar (3)');
assert.strictEqual(profilerCloneName('', []), 'Copy of ');
delete global.t;
});
test('profilerClone inserts a deep copy and opens the editor on it', () => {
global.t = s => s;
const original = {
id: 'orig-1', name: 'Foobar', created: '2020-01-01T00:00:00.000Z',
rules: [{ type: 'file', path: 'MF/3F00/2FE2', fciMode: 'exact',
content: { mode: 'exact', kind: 'transparent', expected: 'AA' } }],
};
global.profiles = [original, { id: 'orig-2', name: 'Other', created: '2020-01-01T00:00:00.000Z', rules: [] }];
let saved = 0, rendered = 0, editedWith = -1;
global.profilerSave = () => { saved++; };
global.profilerRenderList = () => { rendered++; };
global.profilerEdit = i => { editedWith = i; };
try {
profilerClone(0);
assert.strictEqual(global.profiles.length, 3);
const copy = global.profiles[1];
assert.strictEqual(copy.name, 'Copy of Foobar');
assert.notStrictEqual(copy.id, original.id);
assert.ok(copy.created > original.created);
assert.deepStrictEqual(copy.rules, original.rules);
// deep copy: editing the clone's rule leaves the original untouched
copy.rules[0].content.expected = 'BB';
assert.strictEqual(original.rules[0].content.expected, 'AA');
assert.strictEqual(global.profiles[2].name, 'Other');
assert.strictEqual(saved, 1);
assert.strictEqual(rendered, 1);
assert.strictEqual(editedWith, 1);
// a second clone of the same profile gets the (2) suffix
profilerClone(0);
assert.strictEqual(global.profiles[1].name, 'Copy of Foobar (2)');
} finally {
delete global.t; delete global.profiles;
delete global.profilerSave; delete global.profilerRenderList; delete global.profilerEdit;
}
});
test('profilerResultAspects groups checks and lets Exact FCI subsume type/size', () => { test('profilerResultAspects groups checks and lets Exact FCI subsume type/size', () => {
assert.deepStrictEqual( assert.deepStrictEqual(
profilerResultAspects({ checks: [ profilerResultAspects({ checks: [
@@ -511,7 +568,7 @@ test('record count mismatch is reported once when numRecords is checked', async
global.t = s => s; global.t = s => s;
global.pysimCustomFiles = []; global.pysimCustomFiles = [];
const report = profilerRenderReport([res]); const report = profilerRenderReport([res]);
assert.ok(report.includes('matching records: 1-10'), report); assert.ok(report.includes('matching records: 10 of 30 (#1\u2013#10)'), report);
delete global.t; delete global.t;
}); });
@@ -553,12 +610,12 @@ test('profilerRenderReport includes the checked-aspects summary and matching-rec
{ path: 'MF/7F20/6F4E', name: 'EF.Y', status: 'fail', checks: [ { path: 'MF/7F20/6F4E', name: 'EF.Y', status: 'fail', checks: [
{ label: 'content.rec6', ok: false, expected: 'BB', actual: 'XX' }, { label: 'content.rec6', ok: false, expected: 'BB', actual: 'XX' },
{ label: 'content.rec1', ok: true }, { label: 'content.rec2', ok: true }, { label: 'content.rec5', ok: true }, { label: 'content.rec1', ok: true }, { label: 'content.rec2', ok: true }, { label: 'content.rec5', ok: true },
], recordsMatched: [1, 2, 5] }, ], recordsMatched: [1, 2, 5], recordsTotal: 6 },
]); ]);
assert.ok(html.includes('filetype and size, contents')); assert.ok(html.includes('filetype and size, contents'));
assert.ok(html.includes('filetype ✓, size ✗, contents ✓')); assert.ok(html.includes('filetype ✓, size ✗, contents ✓'));
assert.ok(html.includes('matching records')); assert.ok(html.includes('matching records'));
assert.ok(html.includes('1-2, 5')); assert.ok(html.includes('3 of 6 (#1\u2013#2, #5)'));
delete global.t; delete global.t;
}); });
@@ -1116,7 +1173,7 @@ test('profilerRulesFromSnapshot builds exact-FCI rules from the master snapshot'
snapFile('MF/6F3A', { fileType: 'linear_fixed', fileSize: null, recordLen: 2, numRecords: 1, content: { kind: 'record', records: [{ num: 1, data: 'AABB' }] } }), snapFile('MF/6F3A', { fileType: 'linear_fixed', fileSize: null, recordLen: 2, numRecords: 1, content: { kind: 'record', records: [{ num: 1, data: 'AABB' }] } }),
snapFile('MF/6F3B', { content: null }), snapFile('MF/6F3B', { content: null }),
]); ]);
const rules = profilerRulesFromSnapshot(master, null); const rules = profilerRulesFromSnapshot(master);
assert.strictEqual(rules.length, 3); assert.strictEqual(rules.length, 3);
assert.strictEqual(rules[0].fciMode, 'exact'); assert.strictEqual(rules[0].fciMode, 'exact');
assert.strictEqual(rules[0].fciHex, '621082024021'); assert.strictEqual(rules[0].fciHex, '621082024021');
@@ -1125,27 +1182,24 @@ test('profilerRulesFromSnapshot builds exact-FCI rules from the master snapshot'
assert.strictEqual(rules[2].content, null); assert.strictEqual(rules[2].content, null);
}); });
test('profilerRulesFromSnapshot masks only the checked FIDs', () => { test('snapshot comparison is always exact (no IMSI/ICCID masking)', async () => {
const master = masterSnap([ const files = [snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } })];
snapFile('MF/7F20/6F07', { name: 'EF.IMSI', content: { kind: 'transparent', data: '082905911234567890' } }), const rules = profilerRulesFromSnapshot(masterSnap(files));
snapFile('MF/2FE2', { name: 'EF.ICCID', content: { kind: 'transparent', data: '98680012345678901234' } }), assert.deepStrictEqual(rules[0].content,
snapFile('MF/6F3A', { content: { kind: 'transparent', data: 'AABBCCDD' } }), { mode: 'exact', kind: 'transparent', expected: '082905911234567890' });
]); const same = profilerSnapshotSource(masterSnap([
const rules = profilerRulesFromSnapshot(master, new Set(['6F07'])); snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } })]));
assert.deepStrictEqual(rules[0].content, { mode: 'mask', kind: 'transparent', expected: '08290591??????????' }); const other = profilerSnapshotSource(masterSnap([
assert.deepStrictEqual(rules[1].content, { mode: 'exact', kind: 'transparent', expected: '98680012345678901234' }); snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567891' } })]));
assert.deepStrictEqual(rules[2].content, { mode: 'exact', kind: 'transparent', expected: 'AABBCCDD' }); assert.strictEqual((await profilerRunRule(rules[0], same)).status, 'pass');
}); const res = await profilerRunRule(rules[0], other);
assert.strictEqual(res.status, 'fail');
test('profilerRulesFromSnapshot falls back to the symbolic name for masking', () => { assert.ok(res.checks.some(c => c.label === 'content' && c.ok === false));
const master = masterSnap([snapFile('MF/CUSTOM1', { name: 'EF.ICCID', content: { kind: 'transparent', data: '98680012345678901234' } })]);
const rules = profilerRulesFromSnapshot(master, new Set(['2FE2']));
assert.strictEqual(rules[0].content.mode, 'mask');
}); });
test('snapshot comparison passes on an identical snapshot', async () => { test('snapshot comparison passes on an identical snapshot', async () => {
const files = [snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } })]; const files = [snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } })];
const rules = profilerRulesFromSnapshot(masterSnap(files), new Set(['6F07'])); const rules = profilerRulesFromSnapshot(masterSnap(files));
const source = profilerSnapshotSource(masterSnap(files.map(f => ({ ...f })))); const source = profilerSnapshotSource(masterSnap(files.map(f => ({ ...f }))));
for (const r of rules) { for (const r of rules) {
assert.strictEqual((await profilerRunRule(r, source)).status, 'pass'); assert.strictEqual((await profilerRunRule(r, source)).status, 'pass');
@@ -1155,21 +1209,12 @@ test('snapshot comparison passes on an identical snapshot', async () => {
test('snapshot comparison fails on a contents difference', async () => { test('snapshot comparison fails on a contents difference', async () => {
const master = masterSnap([snapFile('MF/6F3A')]); const master = masterSnap([snapFile('MF/6F3A')]);
const check = masterSnap([snapFile('MF/6F3A', { content: { kind: 'transparent', data: 'CCDD' } })]); const check = masterSnap([snapFile('MF/6F3A', { content: { kind: 'transparent', data: 'CCDD' } })]);
const rules = profilerRulesFromSnapshot(master, null); const rules = profilerRulesFromSnapshot(master);
const res = await profilerRunRule(rules[0], profilerSnapshotSource(check)); const res = await profilerRunRule(rules[0], profilerSnapshotSource(check));
assert.strictEqual(res.status, 'fail'); assert.strictEqual(res.status, 'fail');
assert.ok(res.checks.some(c => c.label === 'content' && c.ok === false)); assert.ok(res.checks.some(c => c.label === 'content' && c.ok === false));
}); });
test('snapshot comparison mask ignores only the first 4 bytes', async () => {
const master = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059112345678' } })]);
const rules = profilerRulesFromSnapshot(master, new Set(['6F07']));
const same = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059199999999' } })]);
const other = masterSnap([snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 8, content: { kind: 'transparent', data: '0829059912345678' } })]);
assert.strictEqual((await profilerRunRule(rules[0], profilerSnapshotSource(same))).status, 'pass');
assert.strictEqual((await profilerRunRule(rules[0], profilerSnapshotSource(other))).status, 'fail');
});
test('profilerExtraFileResults reports files missing from the master', () => { test('profilerExtraFileResults reports files missing from the master', () => {
const master = masterSnap([snapFile('MF/6F3A')]); const master = masterSnap([snapFile('MF/6F3A')]);
const check = masterSnap([snapFile('MF/6f3a'), snapFile('MF/6F3B')]); const check = masterSnap([snapFile('MF/6f3a'), snapFile('MF/6F3B')]);
+142
View File
@@ -0,0 +1,142 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
// The extracted functions run in this module's scope; their free variables
// (`cards`, `t`, ...) resolve to globals we stub here.
global.t = (s) => s;
eval(extractFunc(html, 'cardsPskMap'));
eval(extractFunc(html, 'scriptsParseApdus'));
eval(extractFunc(html, 'scp81DeleteApdus'));
eval(extractFunc(html, 'scp81LogLine'));
eval(extractFunc(html, 'scp81LogEntryHtml'));
eval(extractFunc(html, 'scp81GroupResults'));
eval(extractFunc(html, 'scp81ScriptStateText'));
global.esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
test('explore template is the reference administration sequence', () => {
const m = /const SCP81_EXPLORE_APDUS = \[(.*?)\];/s.exec(html);
assert.ok(m, 'SCP81_EXPLORE_APDUS not found');
const apdus = [...m[1].matchAll(/'([0-9A-F]+)'/g)].map(x => x[1]);
assert.deepStrictEqual(apdus, ['80CAFF2100', '80F28002024F0000', '80CA008500',
'80F24002024F0000', '80F22002024F0000', '80F21002024F0000']);
for (const a of apdus) {
assert.ok(/^[0-9A-F]+$/.test(a) && a.length % 2 === 0, a);
}
});
test('cardsPskMap keeps only cards with both identity and key', () => {
global.cards = [
{name: 'A', pskIdentity: 'id-1', pskKey: '00112233445566778899aabbccddeeff'},
{name: 'B', pskIdentity: 'id-2'}, // no key
{name: 'C', pskKey: '00112233'}, // no identity
{name: 'D', pskIdentity: 'id-4', pskKey: 'AA BB CC'}, // spaces stripped
{name: 'E', pskIdentity: 'id-5', pskKey: 'not-hex'},
];
assert.deepStrictEqual(cardsPskMap(), [
{identity: 'id-1', psk_hex: '00112233445566778899aabbccddeeff'},
{identity: 'id-4', psk_hex: 'AABBCC'},
]);
global.cards = [];
assert.deepStrictEqual(cardsPskMap(), []);
});
test('scriptsParseApdus accepts comments and whitespace, rejects bad lines', () => {
assert.deepStrictEqual(
scriptsParseApdus('80CAFF2100\n\n80F2 4002 024F 0000 # listing\n; note\n80E60200AB00'),
{apdus: ['80CAFF2100', '80F24002024F0000', '80E60200AB00']});
assert.strictEqual(scriptsParseApdus('80CAFF2100').error, undefined);
assert.deepStrictEqual(scriptsParseApdus('ZZ'), {error: 'ZZ'});
assert.deepStrictEqual(scriptsParseApdus('80CAF'), {error: '80CAF'});
assert.deepStrictEqual(scriptsParseApdus('80CAFF2'), {error: '80CAFF2'});
});
test('scp81DeleteApdus builds GP DELETE APDUs per AID', () => {
assert.deepStrictEqual(scp81DeleteApdus(['A000000003000000'], '00'),
['80E4000008A00000000300000000']);
assert.deepStrictEqual(scp81DeleteApdus(['A000000003000000', 'A000000100'], '80'),
['80E4800008A00000000300000000', '80E4800005A00000010000']);
assert.deepStrictEqual(scp81DeleteApdus([], '00'), []);
});
test('scp81LogEntryHtml marks matched and unknown PSK identities', () => {
global.cards = [{name: 'Foobar SIM', pskIdentity: 'id-1'}];
global.cardsPskName = (identity) =>
(global.cards.find(c => c.pskIdentity === identity) || {}).name || '';
const matched = scp81LogEntryHtml(
{seq: 1, kind: 'tls-handshake', cipher: 'PSK-AES128-CBC-SHA256',
identity: 'id-1', psk_match: true});
assert.match(matched, /id=id-1/);
assert.match(matched, /\[matched: Foobar SIM\]/);
const unknown = scp81LogEntryHtml(
{seq: 2, kind: 'tls-handshake', identity: 'who', psk_match: false});
assert.match(unknown, /\[unknown identity\]/);
const rejected = scp81LogEntryHtml({seq: 3, kind: 'tls-psk-unknown', identity: 'who'});
assert.match(rejected, /\[unknown identity\]/);
// non-handshake lines get no badge
const plain = scp81LogEntryHtml({seq: 4, kind: 'script-send', index: 1, apdu: '80CAFF2100'});
assert.doesNotMatch(plain, /\[\]/);
});
test('scp81ScriptStateText separates script progress from listing pages', () => {
// untouched / empty scripts show no state line
assert.strictEqual(scp81ScriptStateText({kind: 'none', total: 0}), '');
assert.strictEqual(scp81ScriptStateText({kind: 'Explore', total: 0}), '');
// still executing the configured APDUs
assert.strictEqual(
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3], script: []}),
'Explore: 4/6 executed');
// a configured APDU is awaiting the card's report
assert.strictEqual(
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4],
pending: {index: 9, pos: 5, page: false, apdu: '80F21002024F0000'}}),
'Explore: 5/6 executed · waiting for card');
// all script APDUs executed; only a listing page is in flight
assert.strictEqual(
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
pending: {index: 17, pos: null, page: true, apdu: '80F21003024F0000'},
pages: 11, complete: false}),
'Explore: 6/6 executed · listing pages (11)…');
// queued page, nothing sent yet
assert.strictEqual(
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
pending: null, pages: 11, pages_queued: 1, complete: false}),
'Explore: 6/6 executed · listing pages (11)…');
// everything drained
assert.strictEqual(
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
pending: null, pages: 11, pages_queued: 0, complete: true}),
'Explore: 6/6 executed · completed');
});
test('scp81GroupResults groups pages by originating command', () => {
const groups = scp81GroupResults({results: [
{index: 1, pos: 0, page: false, apdu: '80F24002024F0000', rapdu: 'E3', sw: 'CAFE'},
{index: 2, pos: null, page: true, apdu: '80F24003024F0000', rapdu: 'E3', sw: '9000'},
{index: 3, pos: 1, page: false, apdu: '80CAFF2100', rapdu: 'FF21', sw: '9000'},
]});
assert.strictEqual(groups.length, 2);
assert.strictEqual(groups[0].key, '80F240');
assert.strictEqual(groups[0].results.length, 2); // origin + continuation page
assert.strictEqual(groups[1].key, '80CAFF');
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-otaman-server" name = "pysim-otaman-server"
version = "2.1.18" version = "2.2.0"
description = "HTTP REST server wrapping pysim for the OTAMan PWA" description = "HTTP REST server wrapping pysim for the OTAMan 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.
+65 -17
View File
@@ -51,6 +51,16 @@ TLS_VERSIONS = {
} }
def _norm_identity(identity):
"""Normalize a PSK identity to the str OpenSSL reports (CPython hands it
to the PSK callback as a str; bytes are decoded byte-exact)."""
if identity is None:
return None
if isinstance(identity, (bytes, bytearray)):
return bytes(identity).decode('latin-1')
return str(identity)
def parse_http_request(data): def parse_http_request(data):
"""Parse an HTTP/1.1 request head (bytes up to CRLFCRLF) into """Parse an HTTP/1.1 request head (bytes up to CRLFCRLF) into
(method, target, headers dict with lower-case names).""" (method, target, headers dict with lower-case names)."""
@@ -120,13 +130,29 @@ def build_http_response(status, reason, headers, body=b'', chunked=False,
class PskTlsServer: class PskTlsServer:
"""PSK TLS listener speaking the GP remote administration HTTP dialog.""" """PSK TLS listener speaking the GP remote administration HTTP dialog."""
def __init__(self, host, port, psk, identity=None, on_log=None, def __init__(self, host, port, psk=None, identity=None, on_log=None,
responder=None, timeout=10.0, chunked=False, chunk_size=0, responder=None, timeout=10.0, chunked=False, chunk_size=0,
keep_alive=False, compact_headers=False, tls_version='1.2', keep_alive=False, compact_headers=False, tls_version='1.2',
cipher=None, on_before_close=None, keylog=None, cipher=None, on_before_close=None, keylog=None,
conn_header=None, half_close=False, answer_delay=0.0): conn_header=None, half_close=False, answer_delay=0.0,
psk_map=None):
# PSK lookup table: identity -> key. With an explicit psk_map a
# handshake is accepted only for a listed identity; the legacy
# single-key form (psk + optional identity pin, pin None = accept any
# identity) remains for scripts and tests.
self.wildcard_psk = None
self.psk_map = {}
if psk_map is not None:
self.psk_map = {_norm_identity(k): bytes(v)
for k, v in dict(psk_map).items() if v}
elif psk is not None:
pin = _norm_identity(identity)
if pin is None:
self.wildcard_psk = psk
else:
self.psk_map = {pin: bytes(psk)}
self.psk = psk self.psk = psk
self.identity = identity self.identity = _norm_identity(identity)
self.on_log = on_log self.on_log = on_log
self.responder = responder or self._default_responder self.responder = responder or self._default_responder
self.timeout = timeout self.timeout = timeout
@@ -165,6 +191,7 @@ class PskTlsServer:
# SEND-DATA conversation to settle before it accepts the response). # SEND-DATA conversation to settle before it accepts the response).
self.answer_delay = float(answer_delay or 0) self.answer_delay = float(answer_delay or 0)
self.identity_seen = None self.identity_seen = None
self.identity_matched = None
self.stopped = False self.stopped = False
self.conns = [] self.conns = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -223,14 +250,34 @@ class PskTlsServer:
return ctx return ctx
def _psk_cb(self, identity): def _psk_cb(self, identity):
"""OpenSSL asks for the key of the identity the client sent.""" """OpenSSL asks for the key of the identity the client sent.
self.identity_seen = identity
if self.identity is not None and identity != self.identity: The identity is looked up in the configured table (identity -> key);
self.log('tls-psk-unknown', identity=identity) without a match the handshake fails on the Finished MAC check with a
# A dummy key keeps the callback type-safe; the handshake then dummy key, and the attempt is logged as 'tls-psk-unknown'."""
# fails on the Finished MAC check. ident = _norm_identity(identity)
self.identity_seen = ident
key = self.psk_map.get(ident) if ident is not None else None
if key is None:
# Legacy single-key mode: no identity pin accepts any identity.
key = self.wildcard_psk
self.identity_matched = key is not None
if key is None:
self.log('tls-psk-unknown', identity=ident)
return b'\x00' * 16 return b'\x00' * 16
return self.psk return key
@property
def psk_identities(self):
"""Identities the listener looks up (keys are never exposed)."""
return sorted(self.psk_map)
def set_psk_map(self, psk_map):
"""Replace the identity -> key table of a running listener."""
self.psk_map = {_norm_identity(k): bytes(v)
for k, v in dict(psk_map).items() if v}
self.wildcard_psk = None
return self.psk_identities
@staticmethod @staticmethod
def _default_responder(method, target, headers, body): def _default_responder(method, target, headers, body):
@@ -285,7 +332,8 @@ class PskTlsServer:
try: try:
tls = self.ctx.wrap_socket(conn, server_side=True) tls = self.ctx.wrap_socket(conn, server_side=True)
self.log('tls-handshake', peer=peer, cipher=tls.cipher()[0], self.log('tls-handshake', peer=peer, cipher=tls.cipher()[0],
version=tls.version(), identity=self.identity_seen) version=tls.version(), identity=self.identity_seen,
psk_match=self.identity_matched)
while not self.stopped: while not self.stopped:
req = self._read_request(tls) req = self._read_request(tls)
if req is None: if req is None:
@@ -304,15 +352,15 @@ class PskTlsServer:
status, resp_headers, resp_body = self.responder( status, resp_headers, resp_body = self.responder(
method, target, headers, body) method, target, headers, body)
reason = {200: 'OK', 204: 'No Content'}.get(status, 'Status') reason = {200: 'OK', 204: 'No Content'}.get(status, 'Status')
conn = self.conn_header conn_hdr = self.conn_header
if conn == 'none': if conn_hdr == 'none':
conn = None conn_hdr = None
elif conn is None: elif conn_hdr is None:
conn = 'keep-alive' if self.keep_alive else 'close' conn_hdr = 'keep-alive' if self.keep_alive else 'close'
response = build_http_response( response = build_http_response(
status, reason, resp_headers, resp_body, status, reason, resp_headers, resp_body,
chunked=self.chunked, compact=self.compact_headers, chunked=self.chunked, compact=self.compact_headers,
connection=conn) connection=conn_hdr)
# The card's HTTP client reads its response record-by-record: # The card's HTTP client reads its response record-by-record:
# the whole response must arrive in ONE TLS record (chunk_size # the whole response must arrive in ONE TLS record (chunk_size
# 0), otherwise a split head stalls it and a head-only record # 0), otherwise a split head stalls it and a head-only record
+392 -126
View File
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE from osmocom.tlv import BER_TLV_IE
VERSION = '2.1.18' VERSION = '2.2.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
@@ -625,6 +625,28 @@ def _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex, kic_key_he
return b2h(out), spi return b2h(out), spi
def _max_load_block_size(spi1, spi2, kic, kid, tar_hex, cntr_hex,
kic_key_hex, kid_key_hex, requested=240):
"""Largest LOAD block payload that still fits one SMS (TS 31.115).
pySim's SMS dialect refuses to encode a secured packet above 140 octets,
so a LOAD APDU of the default 240-byte block cannot be sent over SCP80.
Trial-encode a synthetic LOAD APDU for decreasing payload sizes (the
cipher padding makes a closed-form bound unreliable) and return the
largest one that encodes; 0 = not even a 1-byte block fits."""
cap = max(1, min(int(requested or 240), 240))
for n in range(cap, 0, -1):
apdu = '80E80000%02X%s00' % (n, '00' * n)
try:
out_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex,
apdu, kic_key_hex, kid_key_hex)
except ValueError:
continue
if len(out_hex) // 2 <= 140:
return n
return 0
def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, response_hex): def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, response_hex):
from pySim.ota import OtaDialectSms, CompactRemoteResp from pySim.ota import OtaDialectSms, CompactRemoteResp
from osmocom.utils import h2b, b2h from osmocom.utils import h2b, b2h
@@ -826,7 +848,12 @@ _PLI_DATA = {q: '' for q in PLI_QUALIFIER_NAMES}
_BIP = httpota.BipTerminal() _BIP = httpota.BipTerminal()
_SCP81_LISTENER = None _SCP81_LISTENER = None
_SCP81_PSK = {} # PSK table of the TLS listener: identity -> key (memory only, never logged or
# persisted; the PWA sends it from the card presets at listener start).
# _SCP81_PSK_LEGACY keeps a single-key start (psk_hex [+ psk_identity]) so an
# API restart without psk_map/psk_hex can reuse it.
_SCP81_PSKS = {}
_SCP81_PSK_LEGACY = None
_POLL_ENABLED = False _POLL_ENABLED = False
_POLL_INTERVAL = 30 _POLL_INTERVAL = 30
@@ -1299,8 +1326,10 @@ def _scp81_listener_status():
return None return None
if isinstance(_SCP81_LISTENER, scp81.PskTlsServer): if isinstance(_SCP81_LISTENER, scp81.PskTlsServer):
return {'mode': 'tls', 'host': _SCP81_LISTENER.host, 'port': _SCP81_LISTENER.port, return {'mode': 'tls', 'host': _SCP81_LISTENER.host, 'port': _SCP81_LISTENER.port,
'psk_identity': _SCP81_LISTENER.identity, 'psk_identities': _SCP81_LISTENER.psk_identities,
'psk_wildcard': _SCP81_LISTENER.wildcard_psk is not None,
'identity_seen': _SCP81_LISTENER.identity_seen, 'identity_seen': _SCP81_LISTENER.identity_seen,
'identity_matched': _SCP81_LISTENER.identity_matched,
'chunked': _SCP81_LISTENER.chunked, 'chunked': _SCP81_LISTENER.chunked,
'chunk_size': _SCP81_LISTENER.chunk_size, 'chunk_size': _SCP81_LISTENER.chunk_size,
'keep_alive': _SCP81_LISTENER.keep_alive, 'keep_alive': _SCP81_LISTENER.keep_alive,
@@ -1376,49 +1405,73 @@ def _bip_data_available(ch):
# from the next POST's Response Scripting template ('AB'/'AF', with '80' # from the next POST's Response Scripting template ('AB'/'AF', with '80'
# executed-count and '23' R-APDU TLVs whose last two bytes are SW1 SW2). # executed-count and '23' R-APDU TLVs whose last two bytes are SW1 SW2).
_SCP81_SCRIPTS = { # The command script served to the card, set by the PWA at listener start (an
# The command sequence of the reference administration server # explicit APDU list) or via /api/scp81/queue. The server is agnostic to what
# (samples/HTTP_OTA/httpota_adminserver_php_v2, get_next_apdu), extended # the APDUs do: it serves them one per administration POST and tracks
# with the registries: GET DATA FF21 (extended card resources / free # execution so a resumed session sends only the leftover APDUs.
# memory), GET DATA 0085, then GET STATUS with P2=02 (TLV structure, # _SCP81_SCRIPT_BASE the configured APDU list (never mutated)
# 'first or all') and data '4F00' (match all): P1=80 (Issuer Security # _SCP81_SCRIPT_NEXT index in BASE of the next APDU to send
# Domain), P1=40 (applications and supplementary security domains), # _SCP81_SCRIPT_DONE BASE indices the card reported (executed, any result)
# P1=20 (executable load files), P1=10 (ELF and their modules); # _SCP81_SCRIPT_PENDING the APDU sent in the previous POST, waiting for the
# Le=00 so no GET RESPONSE is needed. Long listings answer SW CAFE and # card's X-Admin-Script-Status report (None = none); a
# are auto-continued with the same command carrying P2.b1=1 ('next'). # session that dies before the report resends it
'explore': ['80CAFF2100', '80F28002024F0000', '80CA008500', # _SCP81_SCRIPT_RESULTS one entry per reported APDU
'80F24002024F0000', '80F22002024F0000', '80F21002024F0000'], # {index, pos, page, apdu, rapdu, sw}
'none': [], # _SCP81_SCRIPT_PAGE_QUEUE continuation pages auto-inserted for truncated
} # GET STATUS listings (sent before BASE[NEXT])
_SCP81_SCRIPT = list(_SCP81_SCRIPTS['explore']) _SCP81_SCRIPT_BASE = []
_SCP81_SCRIPT_SENT = 0 _SCP81_SCRIPT_NEXT = 0
_SCP81_SCRIPT_DONE = set()
_SCP81_SCRIPT_PENDING = None
_SCP81_SCRIPT_RESULTS = [] _SCP81_SCRIPT_RESULTS = []
# Continuation pages: long GET STATUS listings answer SW CAFE with 127-byte _SCP81_SCRIPT_PAGE_QUEUE = []
# pages; the responder auto-inserts a GET STATUS (P2=02, last AID as search _SCP81_SCRIPT_SENT_NO = 0
# criterion) after each page until the listing ends.
_SCP81_SCRIPT_INSERTED = []
_SCP81_PAGES = 0 _SCP81_PAGES = 0
SCP81_MAX_PAGES = 24 SCP81_MAX_PAGES = 24
# What the queued script is ('explore', 'none', 'custom' or 'ram-install'). # What the queued script is ('none', 'custom' or the name the PWA sent).
_SCP81_SCRIPT_KIND = 'explore' _SCP81_SCRIPT_KIND = 'none'
def _scp81_restart_run():
"""Start a new run over the configured script (fresh dialog): clear the
execution marks, the continuation pages and the results."""
global _SCP81_SCRIPT_NEXT, _SCP81_SCRIPT_DONE, _SCP81_SCRIPT_PENDING
global _SCP81_SCRIPT_RESULTS, _SCP81_SCRIPT_PAGE_QUEUE, _SCP81_PAGES
global _SCP81_SCRIPT_SENT_NO
_SCP81_SCRIPT_NEXT = 0
_SCP81_SCRIPT_DONE = set()
_SCP81_SCRIPT_PENDING = None
_SCP81_SCRIPT_RESULTS = []
_SCP81_SCRIPT_PAGE_QUEUE = []
_SCP81_SCRIPT_SENT_NO = 0
_SCP81_PAGES = 0
def _scp81_script_mid_run():
"""True while a run has started and not finished (queue replaces it only
with force)."""
return (0 < _SCP81_SCRIPT_NEXT < len(_SCP81_SCRIPT_BASE)
or _SCP81_SCRIPT_PENDING is not None
or bool(_SCP81_SCRIPT_PAGE_QUEUE))
def _scp81_reset_script(script, kind):
"""Install a new APDU script and clear all execution progress."""
global _SCP81_SCRIPT_BASE, _SCP81_SCRIPT_KIND
_SCP81_SCRIPT_BASE = [re.sub(r'\s', '', a).upper() for a in script if a]
_SCP81_SCRIPT_KIND = kind
_scp81_restart_run()
_BIP.log('script-queued', script_kind=kind, apdus=len(_SCP81_SCRIPT_BASE))
def _scp81_queue_script(apdus, kind='custom', force=False): def _scp81_queue_script(apdus, kind='custom', force=False):
"""Replace the SCP81 command script with a new APDU list. Refuses while """Replace the SCP81 command script with a new APDU list. Refuses while
a script is mid-run unless forced; the list runs on the card's next POST.""" a script is mid-run unless forced; the list runs on the card's next POST."""
global _SCP81_SCRIPT, _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS if not force and _scp81_script_mid_run():
global _SCP81_SCRIPT_INSERTED, _SCP81_PAGES, _SCP81_SCRIPT_KIND
if not force and 0 < _SCP81_SCRIPT_SENT < len(_SCP81_SCRIPT):
return {'queued': False, 'reason': 'script in progress', return {'queued': False, 'reason': 'script in progress',
'sent': _SCP81_SCRIPT_SENT, 'of': len(_SCP81_SCRIPT)} 'next': _SCP81_SCRIPT_NEXT, 'of': len(_SCP81_SCRIPT_BASE)}
_SCP81_SCRIPT = [a.upper().replace(' ', '') for a in apdus] _scp81_reset_script(apdus, kind)
_SCP81_SCRIPT_KIND = kind return {'queued': True, 'apdus': len(_SCP81_SCRIPT_BASE)}
_SCP81_SCRIPT_SENT = 0
_SCP81_SCRIPT_RESULTS = []
_SCP81_SCRIPT_INSERTED = []
_SCP81_PAGES = 0
_BIP.log('script-queued', script_kind=kind, apdus=len(_SCP81_SCRIPT))
return {'queued': True, 'apdus': len(_SCP81_SCRIPT)}
_SCP81_SCRIPT_TEMPLATE = 'indefinite' _SCP81_SCRIPT_TEMPLATE = 'indefinite'
_SCP81_SCRIPT_CR_TAG = False _SCP81_SCRIPT_CR_TAG = False
# None = short per-command Next-URI ('/N'); '' = omit the header (spec: the # None = short per-command Next-URI ('/N'); '' = omit the header (spec: the
@@ -1531,54 +1584,125 @@ def _scp81_continuation(apdu):
return '%s%02X%s' % (u[:6], p2, u[8:]) return '%s%02X%s' % (u[:6], p2, u[8:])
def _scp81_script_state():
"""State of the configured command script for `GET /api/scp81/script`.
Progress counts only the configured APDUs (`total`/`done`); the C-APDU
awaiting the card's report is reported as `pending` ({index, pos, page,
apdu} or null) and the auto-inserted listing continuation pages as
`pages`/`pages_queued`, so a run whose script APDUs are all executed is
not mistaken for 'still pending' while a page is in flight."""
pending = None
if _SCP81_SCRIPT_PENDING is not None:
p = _SCP81_SCRIPT_PENDING
pending = {'index': p['index'], 'pos': p.get('pos'),
'page': bool(p.get('page')), 'apdu': p['apdu']}
return {'script': list(_SCP81_SCRIPT_BASE),
'next': _SCP81_SCRIPT_NEXT,
'total': len(_SCP81_SCRIPT_BASE),
'done': sorted(_SCP81_SCRIPT_DONE),
'pending': pending,
'pages': _SCP81_PAGES,
'pages_queued': len(_SCP81_SCRIPT_PAGE_QUEUE),
'complete': (len(_SCP81_SCRIPT_DONE) >= len(_SCP81_SCRIPT_BASE)
and _SCP81_SCRIPT_PENDING is None
and not _SCP81_SCRIPT_PAGE_QUEUE),
'kind': _SCP81_SCRIPT_KIND,
'template': _SCP81_SCRIPT_TEMPLATE,
'cr_tag': _SCP81_SCRIPT_CR_TAG,
'results': _SCP81_SCRIPT_RESULTS}
def _scp81_script_responder(method, target, headers, body): def _scp81_script_responder(method, target, headers, body):
"""Remote Administration Server side of the administration session: send """Remote Administration Server side of the administration session: send
the next scripted C-APDU or close the session (TS 102 226 / GP 4.4.2).""" the next scripted C-APDU or close the session (TS 102 226 / GP 4.4.2).
global _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS, _SCP81_PAGES
global _SCP81_SCRIPT_INSERTED Execution tracking: an APDU counts as executed only when the card reports
it in the next POST (X-Admin-Script-Status, with the Response Scripting
template on success). A session that dies before the report leaves the
APDU pending: a resumed dialog (X-Admin-Resume) resends it, while a POST
without that header is a fresh dialog where the script runs from the
start (so a completed script runs again on a new trigger)."""
global _SCP81_SCRIPT_NEXT, _SCP81_SCRIPT_DONE, _SCP81_SCRIPT_PENDING
global _SCP81_SCRIPT_RESULTS, _SCP81_SCRIPT_PAGE_QUEUE, _SCP81_PAGES
global _SCP81_SCRIPT_SENT_NO
status = headers.get('x-admin-script-status') status = headers.get('x-admin-script-status')
resume = headers.get('x-admin-resume')
pending = _SCP81_SCRIPT_PENDING
if pending is not None:
_SCP81_SCRIPT_PENDING = None
index = pending['index']
if status is not None: if status is not None:
index = _SCP81_SCRIPT_SENT # The card reports the outcome of the pending C-APDU.
if status != 'ok': if status != 'ok':
_BIP.log('script-status', index=index, status=status) _BIP.log('script-status', index=index, status=status)
else: else:
count, rapdus = _scp81_parse_response(body) count, rapdus = _scp81_parse_response(body)
apdu = _SCP81_SCRIPT[index - 1].upper() if (_SCP81_SCRIPT and index >= 1) else ''
for rapdu, sw in rapdus: for rapdu, sw in rapdus:
_BIP.log('script-rapdu', index=index, sw=sw, bytes=len(rapdu), _BIP.log('script-rapdu', index=index, sw=sw, bytes=len(rapdu),
hex=rapdu.hex().upper()[:2000]) hex=rapdu.hex().upper()[:2000])
_SCP81_SCRIPT_RESULTS.append({'index': index, 'sw': sw, _SCP81_SCRIPT_RESULTS.append(
'apdu': apdu, {'index': index, 'pos': pending.get('pos'),
'page': bool(pending.get('page')), 'sw': sw,
'apdu': pending['apdu'],
'rapdu': rapdu.hex().upper()}) 'rapdu': rapdu.hex().upper()})
if rapdus and _SCP81_SCRIPT and index >= 1: if rapdus:
if apdu.startswith('80CAFF21'): if pending['apdu'].startswith('80CAFF21'):
decoded = _scp81_decode_memory(rapdus[-1][0]) decoded = _scp81_decode_memory(rapdus[-1][0])
if decoded: if decoded:
_BIP.log('script-memory', **decoded) _BIP.log('script-memory', **decoded)
# '63 10' = "more data available" (GP Table 11-38); the live # '63 10' = "more data available" (GP Table 11-38); the
# card uses a proprietary 'CA FE' for the same condition. # live card uses a proprietary 'CA FE' for the same case.
if rapdus[-1][1].upper() in ('CAFE', '6310') and _SCP81_PAGES < SCP81_MAX_PAGES: if (rapdus[-1][1].upper() in ('CAFE', '6310')
cont = _scp81_continuation(apdu) and _SCP81_PAGES < SCP81_MAX_PAGES):
cont = _scp81_continuation(pending['apdu'])
if cont: if cont:
_SCP81_PAGES += 1 _SCP81_PAGES += 1
_SCP81_SCRIPT.insert(_SCP81_SCRIPT_SENT, cont) _SCP81_SCRIPT_PAGE_QUEUE.append(cont)
_SCP81_SCRIPT_INSERTED.append(cont) _BIP.log('script-page', index=index,
_BIP.log('script-page', index=index, page=_SCP81_PAGES, page=_SCP81_PAGES, apdu=cont)
apdu=cont) if pending.get('pos') is not None:
_SCP81_SCRIPT_DONE.add(pending['pos'])
elif resume:
# Resumed dialog: the pending APDU was never reported, so it is
# still unexecuted - put it back in line and resend it.
if pending.get('page'):
_SCP81_SCRIPT_PAGE_QUEUE.insert(0, pending['apdu'])
else: else:
# First (or resumed) POST of a session: run the script from the start. _SCP81_SCRIPT_NEXT = pending['pos']
# Drop continuation pages inserted by a previous session. _SCP81_SCRIPT_DONE.discard(pending['pos'])
if _SCP81_SCRIPT_INSERTED: _BIP.log('script-resend', index=index, apdu=pending['apdu'])
_SCP81_SCRIPT[:] = [a for a in _SCP81_SCRIPT else:
if a not in _SCP81_SCRIPT_INSERTED] # Fresh dialog (new trigger): the script runs from the start.
_SCP81_SCRIPT_INSERTED = [] _scp81_restart_run()
_SCP81_SCRIPT_SENT = 0 elif status is not None:
_SCP81_SCRIPT_RESULTS = [] # A report without a pending APDU (the server may have restarted
_SCP81_PAGES = 0 # mid-session): log the R-APDUs but do not advance anything.
if _SCP81_SCRIPT_SENT < len(_SCP81_SCRIPT): if status != 'ok':
apdu = _SCP81_SCRIPT[_SCP81_SCRIPT_SENT] _BIP.log('script-status', index=None, status=status)
_SCP81_SCRIPT_SENT += 1 else:
_BIP.log('script-send', index=_SCP81_SCRIPT_SENT, apdu=apdu) count, rapdus = _scp81_parse_response(body)
for rapdu, sw in rapdus:
_BIP.log('script-rapdu', index=None, sw=sw, bytes=len(rapdu),
hex=rapdu.hex().upper()[:2000])
elif not resume:
# First POST of a fresh dialog: reset a previous run.
_scp81_restart_run()
apdu = None
if _SCP81_SCRIPT_PAGE_QUEUE:
apdu = _SCP81_SCRIPT_PAGE_QUEUE.pop(0)
_SCP81_SCRIPT_PENDING = {'index': _SCP81_SCRIPT_SENT_NO + 1,
'pos': None, 'page': True, 'apdu': apdu}
elif _SCP81_SCRIPT_NEXT < len(_SCP81_SCRIPT_BASE):
pos = _SCP81_SCRIPT_NEXT
apdu = _SCP81_SCRIPT_BASE[pos]
_SCP81_SCRIPT_NEXT = pos + 1
_SCP81_SCRIPT_PENDING = {'index': _SCP81_SCRIPT_SENT_NO + 1,
'pos': pos, 'page': False, 'apdu': apdu}
if apdu is not None:
index = _SCP81_SCRIPT_PENDING['index']
_SCP81_SCRIPT_SENT_NO = index
_BIP.log('script-send', index=index, apdu=apdu)
headers = _scp81_response_headers() headers = _scp81_response_headers()
if _SCP81_TARGETED_APP: if _SCP81_TARGETED_APP:
headers['X-Admin-Targeted-Application'] = _SCP81_TARGETED_APP headers['X-Admin-Targeted-Application'] = _SCP81_TARGETED_APP
@@ -1587,7 +1711,7 @@ def _scp81_script_responder(method, target, headers, body):
# query-less Next-URI makes the card abort the TLS session. A '%d' # query-less Next-URI makes the card abort the TLS session. A '%d'
# in the configured/default URI is replaced with the command number. # in the configured/default URI is replaced with the command number.
template = _SCP81_NEXT_URI if _SCP81_NEXT_URI is not None else '/api/scp81?req=%d' template = _SCP81_NEXT_URI if _SCP81_NEXT_URI is not None else '/api/scp81?req=%d'
next_uri = template % _SCP81_SCRIPT_SENT if '%d' in template else template next_uri = template % index if '%d' in template else template
if next_uri: if next_uri:
headers['X-Admin-Next-URI'] = next_uri headers['X-Admin-Next-URI'] = next_uri
u = apdu.upper() u = apdu.upper()
@@ -1606,7 +1730,7 @@ def _scp81_script_responder(method, target, headers, body):
headers['Content-Length'] = str(len(body_out)) headers['Content-Length'] = str(len(body_out))
headers['Content-Type'] = scp81.GP_CT_COMMAND headers['Content-Type'] = scp81.GP_CT_COMMAND
return 200, headers, body_out return 200, headers, body_out
_BIP.log('script-done', sent=_SCP81_SCRIPT_SENT, _BIP.log('script-done', sent=_SCP81_SCRIPT_SENT_NO,
results=len(_SCP81_SCRIPT_RESULTS)) results=len(_SCP81_SCRIPT_RESULTS))
headers = _scp81_response_headers() headers = _scp81_response_headers()
if _SCP81_APACHE_HEADERS: if _SCP81_APACHE_HEADERS:
@@ -1627,10 +1751,101 @@ def _scp81_response_headers():
return headers return headers
def _parse_psk_map(raw):
"""Parse a PSK lookup table from an API request.
Accepts a list of {identity, psk_hex} objects (the PWA form) or a
{identity: psk_hex} map; entries without a usable identity or key are
skipped. Returns (table, error)."""
if raw is None:
return None, None
if isinstance(raw, dict):
raw = [{'identity': k, 'psk_hex': v} for k, v in raw.items()]
if not isinstance(raw, list):
return None, 'psk_map must be a list of {identity, psk_hex}'
table = {}
for item in raw:
if not isinstance(item, dict):
return None, 'psk_map entries must be {identity, psk_hex} objects'
ident = str(item.get('identity') or '').strip()
key_hex = re.sub(r'\s', '', str(item.get('psk_hex') or ''))
if not key_hex or not ident:
continue
try:
key = bytes.fromhex(key_hex)
except ValueError:
return None, 'psk_map[%s]: psk_hex is not valid hex' % ident
if key:
table[ident] = key
return table, None
def _redact_psk_fields(body):
"""Copy of a request body with PSK key material masked (keys must never
reach the logs; identities stay visible for diagnostics)."""
if not isinstance(body, dict):
return body
out = dict(body)
if out.get('psk_hex'):
out['psk_hex'] = '<redacted>'
psk_map = out.get('psk_map')
if isinstance(psk_map, dict):
out['psk_map'] = {k: '<redacted>' for k in psk_map}
elif isinstance(psk_map, list):
out['psk_map'] = [
dict(e, psk_hex='<redacted>')
if isinstance(e, dict) and e.get('psk_hex') else e
for e in psk_map]
return out
def _scp81_update_psk_map(body):
"""Replace the PSK table of the running TLS listener. The card presets are
the source of truth; the PWA pushes edits without a listener restart."""
global _SCP81_PSKS, _SCP81_PSK_LEGACY
table, err = _parse_psk_map((body or {}).get('psk_map'))
if err:
return {'ok': False, 'error': err}
if not table:
return {'ok': False, 'error': 'no usable PSK entries (identity + key required)'}
if not isinstance(_SCP81_LISTENER, scp81.PskTlsServer):
return {'ok': False, 'error': 'PSK TLS listener is not running'}
_SCP81_LISTENER.set_psk_map(table)
_SCP81_PSKS = dict(table)
_SCP81_PSK_LEGACY = None
_BIP.log('tls-psk-map', identities=sorted(table))
return {'ok': True, 'identities': sorted(table),
'listener': _scp81_listener_status()}
def _scp81_gen_install(body):
"""Generate the RAM APDU sequence for a .cap without touching the listener
or the running script. The PWA uses it to turn an 'Install from .cap'
script template into INSTALL [for load] / LOAD blocks / INSTALL [for
install] APDUs; the .cap itself is never stored."""
body = body or {}
cap_hex = re.sub(r'\s', '', body.get('cap_hex') or '')
if not cap_hex:
return {'ok': False, 'error': 'No cap_hex provided'}
try:
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
seq = _cap_apdu_sequence(
loadfile_aid, module_aid, loadfile_data,
sd_aid=re.sub(r'\s', '', body.get('sd_aid') or ''),
privileges=re.sub(r'\s', '', body.get('privileges') or '') or '00',
install_params=re.sub(r'\s', '', body.get('install_params') or ''),
stk_params=re.sub(r'\s', '', body.get('stk_params') or ''),
make_selectable=bool(body.get('make_selectable', True)))
except Exception as e:
return {'ok': False, 'error': 'cap parse failed: %s' % e}
_BIP.log('gen-install', apdus=len(seq), load_file_aid=loadfile_aid,
module_aid=module_aid)
return {'ok': True, 'apdus': seq, 'load_file_aid': loadfile_aid,
'module_aid': module_aid}
def _scp81_bip_control(body): def _scp81_bip_control(body):
global _SCP81_LISTENER, _SCP81_PSK global _SCP81_LISTENER, _SCP81_PSKS, _SCP81_PSK_LEGACY
global _SCP81_SCRIPT, _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS
global _SCP81_SCRIPT_INSERTED, _SCP81_PAGES, _SCP81_SCRIPT_KIND
global _SCP81_SCRIPT_TEMPLATE, _SCP81_SCRIPT_CR_TAG, _SCP81_NEXT_URI global _SCP81_SCRIPT_TEMPLATE, _SCP81_SCRIPT_CR_TAG, _SCP81_NEXT_URI
global _SCP81_LINK_EVENTS, _SCP81_TARGETED_APP, _SCP81_APACHE_HEADERS global _SCP81_LINK_EVENTS, _SCP81_TARGETED_APP, _SCP81_APACHE_HEADERS
global _SCP81_CHUNKED global _SCP81_CHUNKED
@@ -1651,9 +1866,20 @@ def _scp81_bip_control(body):
_SCP81_LISTENER = None _SCP81_LISTENER = None
_BIP.disable() _BIP.disable()
if mode == 'tls': if mode == 'tls':
psk_hex = body.get('psk_hex') or _SCP81_PSK.get('psk_hex') raw_map = body.get('psk_map')
if not psk_hex: table, err = _parse_psk_map(raw_map)
return {'ok': False, 'error': 'psk_hex is required for tls mode'} if err:
return {'ok': False, 'error': err}
if raw_map is not None and not table:
return {'ok': False,
'error': 'psk_map has no usable entries (identity + key required)'}
psk = None
identity = None
psk_hex = body.get('psk_hex') or ''
if table:
pass # table sent by the PWA from the presets
elif psk_hex:
# Legacy single-key form (API/tests): psk_hex [+ psk_identity].
try: try:
psk = bytes.fromhex(re.sub(r'\s', '', psk_hex)) psk = bytes.fromhex(re.sub(r'\s', '', psk_hex))
except ValueError: except ValueError:
@@ -1663,22 +1889,24 @@ def _scp81_bip_control(body):
identity = body.get('psk_identity') identity = body.get('psk_identity')
if identity is not None: if identity is not None:
identity = identity.strip() or None # empty clears the pin identity = identity.strip() or None # empty clears the pin
elif _SCP81_PSKS:
table = dict(_SCP81_PSKS) # reuse the table of the last start
elif _SCP81_PSK_LEGACY:
psk, identity = _SCP81_PSK_LEGACY # reuse the last single key
else: else:
identity = _SCP81_PSK.get('psk_identity') return {'ok': False, 'error': 'no PSK configured: send psk_map '
_SCP81_PSK = {'psk_hex': psk_hex, 'psk_identity': identity} '(card presets) or psk_hex'}
script = body.get('script', 'explore') script = body.get('script')
if isinstance(script, list): if isinstance(script, str):
_SCP81_SCRIPT = [re.sub(r'\s', '', s) for s in script if s] if script in ('none', ''):
_SCP81_SCRIPT_KIND = 'custom' _scp81_reset_script([], 'none')
elif script in _SCP81_SCRIPTS:
_SCP81_SCRIPT = list(_SCP81_SCRIPTS[script])
_SCP81_SCRIPT_KIND = script
else: else:
return {'ok': False, 'error': 'unknown script preset: %s' % script} return {'ok': False, 'error': 'unknown script preset: %s; send an '
_SCP81_SCRIPT_SENT = 0 'explicit APDU list or none' % script}
_SCP81_SCRIPT_RESULTS = [] elif isinstance(script, list):
_SCP81_SCRIPT_INSERTED = [] _scp81_reset_script(script, body.get('script_kind') or 'custom')
_SCP81_PAGES = 0 # No 'script' key: keep the configured script and its run progress
# (an explicit list starts a fresh run).
template = body.get('script_template', 'indefinite') template = body.get('script_template', 'indefinite')
if template not in ('indefinite', 'definite'): if template not in ('indefinite', 'definite'):
return {'ok': False, 'error': 'script_template must be indefinite or definite'} return {'ok': False, 'error': 'script_template must be indefinite or definite'}
@@ -1698,7 +1926,7 @@ def _scp81_bip_control(body):
cs = body.get('chunk_size') cs = body.get('chunk_size')
chunk_size = int(cs) if cs not in (None, '') else 0 chunk_size = int(cs) if cs not in (None, '') else 0
_SCP81_LISTENER = scp81.PskTlsServer( _SCP81_LISTENER = scp81.PskTlsServer(
host, port, psk, identity=identity, host, port, psk, identity=identity, psk_map=(table or None),
responder=_scp81_script_responder, responder=_scp81_script_responder,
chunked=bool(body.get('chunked', True)), chunked=bool(body.get('chunked', True)),
chunk_size=chunk_size, chunk_size=chunk_size,
@@ -1711,10 +1939,14 @@ def _scp81_bip_control(body):
conn_header=(body.get('conn_header') or 'none'), conn_header=(body.get('conn_header') or 'none'),
answer_delay=(body.get('answer_delay') or 0), answer_delay=(body.get('answer_delay') or 0),
on_log=lambda kind, **fields: _BIP.log(kind, **fields)) on_log=lambda kind, **fields: _BIP.log(kind, **fields))
_SCP81_PSKS = dict(_SCP81_LISTENER.psk_map)
_SCP81_PSK_LEGACY = None if table else (psk, identity)
_BIP.on_data = _bip_data_available _BIP.on_data = _bip_data_available
_BIP.enable(host, _SCP81_LISTENER.port) _BIP.enable(host, _SCP81_LISTENER.port)
return {'ok': True, 'bip': _BIP.status(), 'listener': _scp81_listener_status(), return {'ok': True, 'bip': _BIP.status(), 'listener': _scp81_listener_status(),
'script': _SCP81_SCRIPT, 'script_template': _SCP81_SCRIPT_TEMPLATE, 'script': list(_SCP81_SCRIPT_BASE),
'script_kind': _SCP81_SCRIPT_KIND,
'script_template': _SCP81_SCRIPT_TEMPLATE,
'cr_tag': _SCP81_SCRIPT_CR_TAG, 'link_events': _SCP81_LINK_EVENTS, 'cr_tag': _SCP81_SCRIPT_CR_TAG, 'link_events': _SCP81_LINK_EVENTS,
'targeted_app': _SCP81_TARGETED_APP, 'targeted_app': _SCP81_TARGETED_APP,
'apache_headers': _SCP81_APACHE_HEADERS, 'apache_headers': _SCP81_APACHE_HEADERS,
@@ -2782,10 +3014,7 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp(resp) self._log_resp(resp)
elif self.path == '/api/scp81/script': elif self.path == '/api/scp81/script':
self._log_req() self._log_req()
resp = {'script': _SCP81_SCRIPT, 'sent': _SCP81_SCRIPT_SENT, resp = _scp81_script_state()
'kind': _SCP81_SCRIPT_KIND,
'template': _SCP81_SCRIPT_TEMPLATE, 'cr_tag': _SCP81_SCRIPT_CR_TAG,
'results': _SCP81_SCRIPT_RESULTS}
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
elif self.path.startswith('/api/'): elif self.path.startswith('/api/'):
@@ -3453,14 +3682,57 @@ class PysimHandler(BaseHTTPRequestHandler):
make_selectable = body.get('make_selectable', True) make_selectable = body.get('make_selectable', True)
privileges_hex = body.get('privileges', '').replace(' ', '') or '00' privileges_hex = body.get('privileges', '').replace(' ', '') or '00'
# LOAD block size: the default 240-byte payload cannot be sent
# over SCP80 (pySim refuses a secured packet above one SMS).
# Fit it automatically, or honour an explicit override capped
# to what still encodes into a single SMS.
block_size_req = body.get('load_block_size')
if block_size_req in (None, ''):
block_size_req = None
else:
try:
block_size_req = int(block_size_req)
except (TypeError, ValueError):
block_size_req = -1
if not 1 <= block_size_req <= 240:
err = {'success': False,
'error': 'load_block_size must be 1..240'}
self._send_json(err, 400)
self._log_resp(err)
return
max_block = _max_load_block_size(spi1, spi2, kic, kid, tar, cntr,
kic_key, kid_key,
requested=block_size_req or 240)
if max_block < 1:
err = {'success': False,
'error': 'no LOAD block fits a single SMS with these '
'SCP80 parameters'}
self._send_json(err, 500)
self._log_resp(err)
return
block_size = min(block_size_req, max_block) if block_size_req else max_block
block_clamped = block_size_req is not None and block_size != block_size_req
sys.stderr.write('RAM-INSTALL: LOAD block size %d bytes%s\n' % (
block_size,
(' (requested %d, clamped to fit one SMS)' % block_size_req)
if block_clamped else ''))
steps = [] steps = []
encode_error = None
include_cpi = body.get('includeCpi', True) include_cpi = body.get('includeCpi', True)
spi2_val = int(spi2, 16) spi2_val = int(spi2, 16)
por_in_submit = bool(spi2_val & 0x20) por_in_submit = bool(spi2_val & 0x20)
def _send_gp_apdu(apdu_hex, step_name): def _send_gp_apdu(apdu_hex, step_name):
nonlocal cntr nonlocal cntr, encode_error
try:
sp_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar, cntr, apdu_hex, kic_key, kid_key) sp_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar, cntr, apdu_hex, kic_key, kid_key)
except ValueError as e:
encode_error = str(e)
steps.append({'name': step_name, 'por_status': 'encode_error',
'sw': encode_error})
sys.stderr.write('RAM-INSTALL: %s encode failed: %s\n' % (step_name, e))
return False
sp_bytes = bytes.fromhex(sp_hex) sp_bytes = bytes.fromhex(sp_hex)
max_chunk = 130 max_chunk = 130
chunks = [sp_bytes[i:i + max_chunk] for i in range(0, len(sp_bytes), max_chunk)] chunks = [sp_bytes[i:i + max_chunk] for i in range(0, len(sp_bytes), max_chunk)]
@@ -3514,7 +3786,7 @@ class PysimHandler(BaseHTTPRequestHandler):
loadfile_aid, module_aid, loadfile_data, sd_aid=sd_aid, loadfile_aid, module_aid, loadfile_data, sd_aid=sd_aid,
privileges=privileges_hex, privileges=privileges_hex,
install_params=install_params_hex, stk_params=stk_params_hex, install_params=install_params_hex, stk_params=stk_params_hex,
make_selectable=make_selectable) make_selectable=make_selectable, block_size=block_size)
sys.stderr.write('RAM-INSTALL: %d APDUs (INSTALL / %d x LOAD / INSTALL) loadfile_aid=%s\n' % ( sys.stderr.write('RAM-INSTALL: %d APDUs (INSTALL / %d x LOAD / INSTALL) loadfile_aid=%s\n' % (
len(seq), len(seq) - 2, loadfile_aid)) len(seq), len(seq) - 2, loadfile_aid))
for apdu_idx, gp_apdu in enumerate(seq): for apdu_idx, gp_apdu in enumerate(seq):
@@ -3526,14 +3798,20 @@ class PysimHandler(BaseHTTPRequestHandler):
step_name = 'LOAD (%d/%d)' % (apdu_idx, len(seq) - 2) step_name = 'LOAD (%d/%d)' % (apdu_idx, len(seq) - 2)
if not _send_gp_apdu(gp_apdu, step_name): if not _send_gp_apdu(gp_apdu, step_name):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps), resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': '%s failed' % step_name, 'error': encode_error or ('%s failed' % step_name),
'load_file_aid': loadfile_aid, 'module_aid': module_aid} 'load_file_aid': loadfile_aid, 'module_aid': module_aid,
'load_block_size': block_size,
'load_block_size_requested': block_size_req,
'load_block_size_clamped': block_clamped}
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
return return
resp = {'success': True, 'steps': steps, 'load_file_aid': loadfile_aid, resp = {'success': True, 'steps': steps, 'load_file_aid': loadfile_aid,
'module_aid': module_aid, 'final_cntr': cntr} 'module_aid': module_aid, 'final_cntr': cntr,
'load_block_size': block_size,
'load_block_size_requested': block_size_req,
'load_block_size_clamped': block_clamped}
sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % ( sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
loadfile_aid, module_aid, cntr)) loadfile_aid, module_aid, cntr))
self._send_json(resp) self._send_json(resp)
@@ -3545,14 +3823,23 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp(err) self._log_resp(err)
elif self.path == '/api/scp81/bip': elif self.path == '/api/scp81/bip':
body = self._read_body() body = self._read_body()
# Never log the pre-shared key. # Never log the pre-shared keys.
self._log_req(dict(body, psk_hex='<redacted>') if isinstance(body, dict) and body.get('psk_hex') else body) self._log_req(_redact_psk_fields(body))
try: try:
resp = _scp81_bip_control(body) resp = _scp81_bip_control(body)
except Exception as e: except Exception as e:
resp = {'ok': False, 'error': str(e)} resp = {'ok': False, 'error': str(e)}
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
elif self.path == '/api/scp81/psk-map':
body = self._read_body()
self._log_req(_redact_psk_fields(body))
try:
resp = _scp81_update_psk_map(body)
except Exception as e:
resp = {'ok': False, 'error': str(e)}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/scp81/queue': elif self.path == '/api/scp81/queue':
body = self._read_body() body = self._read_body()
self._log_req(body) self._log_req(body)
@@ -3572,34 +3859,13 @@ class PysimHandler(BaseHTTPRequestHandler):
'runs on the card next POST (push/trigger)') 'runs on the card next POST (push/trigger)')
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
elif self.path == '/api/scp81/ram-install': elif self.path == '/api/scp81/gen-install':
body = self._read_body() body = self._read_body()
self._log_req(body) self._log_req(body)
cap_hex = (body.get('cap_hex') or '').replace(' ', '')
if not cap_hex:
resp = {'ok': False, 'error': 'No cap_hex provided'}
elif _SCP81_LISTENER is None:
resp = {'ok': False, 'error': 'SCP81 listener is not running'}
else:
try: try:
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex) resp = _scp81_gen_install(body)
seq = _cap_apdu_sequence(
loadfile_aid, module_aid, loadfile_data,
sd_aid=(body.get('sd_aid') or '').replace(' ', ''),
privileges=(body.get('privileges') or '').replace(' ', '') or '00',
install_params=(body.get('install_params') or '').replace(' ', ''),
stk_params=(body.get('stk_params') or '').replace(' ', ''),
make_selectable=bool(body.get('make_selectable', True)))
queued = _scp81_queue_script(seq, kind='ram-install',
force=bool(body.get('force', False)))
resp = dict(queued, ok=bool(queued.get('queued')),
load_file_aid=loadfile_aid,
module_aid=module_aid, apdus=len(seq))
if queued.get('queued'):
resp['note'] = ('queued as the SCP81 command script; '
'runs on the card next POST (push/trigger)')
except Exception as e: except Exception as e:
resp = {'ok': False, 'error': 'cap parse failed: %s' % e} resp = {'ok': False, 'error': str(e)}
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
elif self.path == '/api/scp81/log-clear': elif self.path == '/api/scp81/log-clear':
+66
View File
@@ -26,6 +26,7 @@ from pysim_otaman_server.server import (
_decode_por, _decode_por,
_decode_tr, _decode_tr,
_log_proactive, _log_proactive,
_max_load_block_size,
_ota_reference, _ota_reference,
_record_tr, _record_tr,
_spi_from_bytes, _spi_from_bytes,
@@ -176,6 +177,29 @@ class TestOtaReference(unittest.TestCase):
self.assertEqual(out, AES_REFERENCE_VECTORS[('1e', '19')]) self.assertEqual(out, AES_REFERENCE_VECTORS[('1e', '19')])
self.assertEqual(spi['counter'], 'counter_must_be_lower') self.assertEqual(spi['counter'], 'counter_must_be_lower')
def test_max_load_block_size_fits_one_sms(self):
# LOAD blocks are too large for SCP80 at the 240-byte default (pySim
# refuses a secured packet above 140 octets), so the helper finds the
# largest payload that still encodes into a single SMS.
mx = _max_load_block_size('16', '01', '15', '15', 'b00000',
'0000000001', K, K)
self.assertGreater(mx, 0)
self.assertLessEqual(mx, 240)
def load_apdu(n):
return '80E80000%02X%s00' % (n, '00' * n)
out, _ = _ota_reference('16', '01', '15', '15', 'b00000',
'0000000001', load_apdu(mx), K, K)
self.assertLessEqual(len(out) // 2, 140)
with self.assertRaises(ValueError):
_ota_reference('16', '01', '15', '15', 'b00000',
'0000000001', load_apdu(mx + 1), K, K)
def test_max_load_block_size_respects_the_requested_cap(self):
mx = _max_load_block_size('16', '01', '15', '15', 'b00000',
'0000000001', K, K, requested=50)
self.assertLessEqual(mx, 50)
self.assertGreater(mx, 0)
class TestDecodePor(unittest.TestCase): class TestDecodePor(unittest.TestCase):
def test_plaintext_no_cc_synthetic(self): def test_plaintext_no_cc_synthetic(self):
@@ -896,3 +920,45 @@ class CapApduSequenceTest(unittest.TestCase):
expected = 'C4' + _ber_len_lower(700) + data # 700 = 0x2BC expected = 'C4' + _ber_len_lower(700) + data # 700 = 0x2BC
self.assertEqual(joined.upper(), expected.upper()) self.assertEqual(joined.upper(), expected.upper())
self.assertEqual(int(seq[3][8:10], 16), len(expected) // 2 - 480) self.assertEqual(int(seq[3][8:10], 16), len(expected) // 2 - 480)
def test_custom_block_size_splits_into_more_blocks(self):
# A smaller block size (SCP80: fit one SMS) slices the load file TLV
# into consecutive chunks of that size, the last block marked P1=0x80
# with the block counter in P2.
from pysim_otaman_server.server import _cap_apdu_sequence
data = ''.join('%02X' % (i % 256) for i in range(700)) # TLV = 704 bytes
seq = _cap_apdu_sequence('A00000010001', 'A000000100', data, block_size=100)
self.assertEqual(len(seq), 10) # INSTALL + 8 LOAD + INSTALL
loads = seq[1:-1]
self.assertEqual(len(loads), 8)
for i, apdu in enumerate(loads):
self.assertEqual(apdu[:8], '80E8%s%02X' % ('80' if i == 7 else '00', i))
def payload(apdu):
lc = int(apdu[8:10], 16)
return apdu[10:10 + lc * 2]
joined = ''.join(payload(a) for a in loads)
self.assertEqual(len(joined) // 2, 704) # C4 82 02BC + 700 data bytes
self.assertTrue(joined.startswith('C482'))
self.assertEqual(int(loads[0][8:10], 16), 100)
self.assertEqual(int(loads[-1][8:10], 16), 4) # 704 = 7*100 + 4
def test_gen_install_returns_the_apdu_list(self):
# /api/scp81/gen-install: build the INSTALL/LOAD/INSTALL list for a
# .cap without touching any listener or script state.
from pysim_otaman_server.server import _scp81_gen_install
resp = _scp81_gen_install({'cap_hex': self._mini_cap(), 'privileges': '01'})
self.assertTrue(resp['ok'], resp)
self.assertEqual(resp['load_file_aid'], 'A00000010001')
self.assertEqual(resp['module_aid'], 'A000000100')
self.assertEqual(len(resp['apdus']), 3)
self.assertTrue(resp['apdus'][0].startswith('80E60200'))
self.assertTrue(resp['apdus'][1].startswith('80E88000'))
self.assertTrue(resp['apdus'][2].startswith('80E60C00'))
self.assertNotIn('queued', resp) # generation only, no queueing
def test_gen_install_rejects_bad_input(self):
from pysim_otaman_server.server import _scp81_gen_install
self.assertFalse(_scp81_gen_install({})['ok'])
resp = _scp81_gen_install({'cap_hex': '00'})
self.assertFalse(resp['ok'])
self.assertIn('cap parse failed', resp['error'])
+373 -92
View File
@@ -25,6 +25,15 @@ import pysim_otaman_server.server as server
PSK = bytes.fromhex('00112233445566778899aabbccddeeff') PSK = bytes.fromhex('00112233445566778899aabbccddeeff')
IDENT = '89012345678901234567' IDENT = '89012345678901234567'
# The reference administration sequence (now a PWA-side 'Explore' template);
# tests use it as a generic multi-APDU script.
EXPLORE = ['80CAFF2100', '80F28002024F0000', '80CA008500',
'80F24002024F0000', '80F22002024F0000', '80F21002024F0000']
def reset_script(script=None, kind='test'):
server._scp81_reset_script(EXPLORE if script is None else script, kind)
class HttpParseTest(unittest.TestCase): class HttpParseTest(unittest.TestCase):
def test_parse_request(self): def test_parse_request(self):
@@ -156,8 +165,8 @@ class PskTlsServerTest(unittest.TestCase):
old_bip = server._BIP old_bip = server._BIP
server._BIP = mock.Mock() server._BIP = mock.Mock()
server._BIP.log = lambda *a, **k: None server._BIP.log = lambda *a, **k: None
server._SCP81_SCRIPT = ['80CAFF2100'] reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
server._SCP81_SCRIPT_RESULTS = [] server._SCP81_SCRIPT_RESULTS = []
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, srv = scp81.PskTlsServer('127.0.0.1', 0, PSK,
responder=server._scp81_script_responder, responder=server._scp81_script_responder,
@@ -182,8 +191,8 @@ class PskTlsServerTest(unittest.TestCase):
tls.close() tls.close()
finally: finally:
server._BIP = old_bip server._BIP = old_bip
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
server._SCP81_SCRIPT_RESULTS = [] server._SCP81_SCRIPT_RESULTS = []
srv.stop() srv.stop()
@@ -244,6 +253,75 @@ class PskTlsServerTest(unittest.TestCase):
finally: finally:
srv.stop() srv.stop()
def test_psk_map_selects_key_by_identity(self):
psk2 = bytes.fromhex('ffeeddccbbaa99887766554433221100')
logs = []
srv = scp81.PskTlsServer('127.0.0.1', 0, psk_map={IDENT: PSK, 'id-2': psk2},
on_log=lambda k, **f: logs.append((k, f)))
try:
tls = self._connect(srv, self._client_ctx())
self.assertEqual(tls.version(), 'TLSv1.2')
tls.close()
deadline = time.time() + 3
while time.time() < deadline and srv.identity_seen is None:
time.sleep(0.05)
self.assertEqual(srv.identity_seen, IDENT)
self.assertIs(srv.identity_matched, True)
# a second identity in the table uses its own key
tls = self._connect(srv, self._client_ctx(identity='id-2', psk=psk2))
tls.close()
# an unlisted identity is rejected and logged
with self.assertRaises(ssl.SSLError):
self._connect(srv, self._client_ctx(identity='unknown-id'))
self.assertEqual(srv.identity_seen, 'unknown-id')
self.assertIs(srv.identity_matched, False)
self.assertIn('tls-psk-unknown', [k for k, _ in logs])
self.assertEqual(srv.psk_identities, [IDENT, 'id-2'])
hs = [f for k, f in logs if k == 'tls-handshake'][0]
self.assertTrue(hs['psk_match'])
finally:
srv.stop()
def test_psk_map_listed_identity_with_wrong_key_fails(self):
srv = scp81.PskTlsServer('127.0.0.1', 0, psk_map={IDENT: PSK})
try:
with self.assertRaises(ssl.SSLError):
self._connect(srv, self._client_ctx(psk=bytes(16)))
self.assertIs(srv.identity_matched, True)
finally:
srv.stop()
def test_set_psk_map_swaps_keys(self):
srv = scp81.PskTlsServer('127.0.0.1', 0, psk_map={IDENT: PSK})
try:
new_psk = bytes.fromhex('ffeeddccbbaa99887766554433221100')
self.assertEqual(srv.set_psk_map({'id-9': new_psk}), ['id-9'])
tls = self._connect(srv, self._client_ctx(identity='id-9', psk=new_psk))
tls.close()
with self.assertRaises(ssl.SSLError):
self._connect(srv)
finally:
srv.stop()
def test_wildcard_key_accepts_any_identity(self):
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK)
try:
tls = self._connect(srv, self._client_ctx(identity='whoever'))
tls.close()
deadline = time.time() + 3
while time.time() < deadline and srv.identity_seen is None:
time.sleep(0.05)
self.assertEqual(srv.identity_seen, 'whoever')
self.assertIs(srv.identity_matched, True)
self.assertEqual(srv.psk_identities, [])
finally:
srv.stop()
def test_norm_identity(self):
self.assertIsNone(scp81._norm_identity(None))
self.assertEqual(scp81._norm_identity(b'abc'), 'abc')
self.assertEqual(scp81._norm_identity('abc'), 'abc')
def test_server_hello_omits_encrypt_then_mac(self): def test_server_hello_omits_encrypt_then_mac(self):
# The live card offers encrypt_then_mac but aborts with # The live card offers encrypt_then_mac but aborts with
# SSLV3_ALERT_UNEXPECTED_MESSAGE when the server echoes it. # SSLV3_ALERT_UNEXPECTED_MESSAGE when the server echoes it.
@@ -305,13 +383,11 @@ class ScriptResponderTest(unittest.TestCase):
"""RAM over HTTP command scripting (TS 102 226 5.2, GP 4.4.2).""" """RAM over HTTP command scripting (TS 102 226 5.2, GP 4.4.2)."""
def setUp(self): def setUp(self):
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = [] server._SCP81_SCRIPT_RESULTS = []
def tearDown(self): def tearDown(self):
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = [] server._SCP81_SCRIPT_RESULTS = []
def test_command_body_is_indefinite_scripting_template(self): def test_command_body_is_indefinite_scripting_template(self):
@@ -355,7 +431,7 @@ class ScriptResponderTest(unittest.TestCase):
def test_responder_sends_script_then_204(self): def test_responder_sends_script_then_204(self):
# Use an explicit two-command script, independent of the presets. # Use an explicit two-command script, independent of the presets.
server._SCP81_SCRIPT = ['80CAFF2100', '80F22002024F0000'] reset_script(['80CAFF2100', '80F22002024F0000'])
logs = [] logs = []
old_bip = server._BIP old_bip = server._BIP
server._BIP = mock.Mock() server._BIP = mock.Mock()
@@ -402,16 +478,142 @@ class ScriptResponderTest(unittest.TestCase):
finally: finally:
server._BIP = old_bip server._BIP = old_bip
def test_resumed_dialog_resends_unreported_apdu(self):
# The card never reported APDU 1 (the session died): a resumed dialog
# resends it instead of skipping to the next one.
reset_script(['80CAFF2100', '80F22002024F0000'])
try:
status, headers, body = server._scp81_script_responder(
'POST', '/x', {}, b'')
self.assertEqual(body.hex(), 'ae80220580caff21000000')
status, headers, body = server._scp81_script_responder(
'POST', '/x', {'x-admin-resume': 'true'}, b'')
self.assertEqual(status, 200)
self.assertEqual(body.hex(), 'ae80220580caff21000000')
self.assertEqual(server._SCP81_SCRIPT_PENDING['pos'], 0)
self.assertNotIn(0, server._SCP81_SCRIPT_DONE)
finally:
reset_script()
def test_resumed_dialog_sends_leftover_tail(self):
# APDU 1 was reported; the resumed dialog continues with APDU 2 only.
reset_script(['80CAFF2100', '80F22002024F0000'])
try:
server._scp81_script_responder('POST', '/x', {}, b'')
status, headers, body = server._scp81_script_responder(
'POST', '/x', {'x-admin-script-status': 'ok'},
bytes.fromhex('af80' '800101' '2304' '93059000' '0000'))
self.assertIn(bytes.fromhex('80f22002024f0000'), body)
self.assertEqual(server._SCP81_SCRIPT_DONE, {0})
# The session dies before APDU 2 is reported; resume resends it.
status, headers, body = server._scp81_script_responder(
'POST', '/x', {'x-admin-resume': 'true'}, b'')
self.assertIn(bytes.fromhex('80f22002024f0000'), body)
self.assertNotIn(bytes.fromhex('80caff2100'), body)
finally:
reset_script()
def test_fresh_dialog_restarts_completed_script(self):
reset_script(['80CAFF2100'])
try:
server._scp81_script_responder('POST', '/x', {}, b'')
status, headers, body = server._scp81_script_responder(
'POST', '/x', {'x-admin-script-status': 'ok'},
bytes.fromhex('af80' '800101' '2304' '93059000' '0000'))
self.assertEqual(status, 204)
# A stale resumed dialog stays closed ...
status, headers, body = server._scp81_script_responder(
'POST', '/x', {'x-admin-resume': 'true'}, b'')
self.assertEqual(status, 204)
# ... while a fresh dialog (new trigger) runs the script again.
status, headers, body = server._scp81_script_responder(
'POST', '/x', {}, b'')
self.assertEqual(status, 200)
self.assertIn(bytes.fromhex('80caff2100'), body)
finally:
reset_script()
def test_script_status_error_consumes_the_apdu(self):
# A reported failure still counts as processed: the run moves on and a
# resumed dialog resends only the unreported tail.
reset_script(['80CAFF2100', '80F22002024F0000'])
try:
server._scp81_script_responder('POST', '/x', {}, b'')
server._scp81_script_responder(
'POST', '/x', {'x-admin-script-status': 'security-error'}, b'')
self.assertEqual(server._SCP81_SCRIPT_DONE, {0})
self.assertEqual(server._SCP81_SCRIPT_PENDING['pos'], 1)
server._scp81_script_responder(
'POST', '/x', {'x-admin-resume': 'true'}, b'')
self.assertEqual(server._SCP81_SCRIPT_PENDING['pos'], 1)
finally:
reset_script()
class ScriptStateTest(unittest.TestCase):
"""`GET /api/scp81/script` state: script progress vs listing pages."""
def test_state_progress_pages_and_completion(self):
reset_script(['80CAFF2100', '80F22002024F0000'])
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
cafe = b'\xAF\x80' + bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE' + b'\x00\x00'
ok = bytes.fromhex('af80' '800101' '2304' '93059000' '0000')
try:
st = server._scp81_script_state()
self.assertEqual(st['total'], 2)
self.assertEqual(st['next'], 0)
self.assertEqual(st['done'], [])
self.assertIsNone(st['pending'])
self.assertEqual(st['pages'], 0)
self.assertFalse(st['complete'])
# first POST sends script APDU 1: pending is an object now
server._scp81_script_responder('POST', '/x', {}, b'')
st = server._scp81_script_state()
self.assertEqual(st['pending'], {'index': 1, 'pos': 0, 'page': False,
'apdu': '80CAFF2100'})
self.assertFalse(st['complete'])
# report it -> done[0], APDU 2 sent
server._scp81_script_responder(
'POST', '/x', {'x-admin-script-status': 'ok'}, ok)
st = server._scp81_script_state()
self.assertEqual(st['done'], [0])
self.assertEqual(st['pending']['pos'], 1)
self.assertFalse(st['pending']['page'])
self.assertFalse(st['complete'])
# APDU 2 truncates the listing: done[1], a page is sent
server._scp81_script_responder(
'POST', '/x', {'x-admin-script-status': 'ok'}, cafe)
st = server._scp81_script_state()
self.assertEqual(st['done'], [0, 1])
self.assertEqual(st['pages'], 1)
self.assertTrue(st['pending']['page'])
self.assertIsNone(st['pending']['pos'])
self.assertEqual(st['pending']['apdu'], '80F22003024F0000')
self.assertFalse(st['complete'])
# the page is reported: nothing left -> 204, state complete
status, headers, body = server._scp81_script_responder(
'POST', '/x', {'x-admin-script-status': 'ok'}, ok)
self.assertEqual(status, 204)
st = server._scp81_script_state()
self.assertIsNone(st['pending'])
self.assertEqual(st['pages_queued'], 0)
self.assertTrue(st['complete'])
finally:
reset_script()
class BipControlTest(unittest.TestCase): class BipControlTest(unittest.TestCase):
def tearDown(self): def tearDown(self):
server._scp81_bip_control({'action': 'stop'}) server._scp81_bip_control({'action': 'stop'})
server._SCP81_PSK = {} server._SCP81_PSKS = {}
server._SCP81_PSK_LEGACY = None
def test_tls_mode_requires_psk(self): def test_tls_mode_requires_psk(self):
server._SCP81_PSK = {} server._SCP81_PSKS = {}
server._SCP81_PSK_LEGACY = None
resp = server._scp81_bip_control({'action': 'start', 'mode': 'tls'}) resp = server._scp81_bip_control({'action': 'start', 'mode': 'tls'})
self.assertFalse(resp['ok']) self.assertFalse(resp['ok'])
self.assertIn('psk_map', resp['error'])
self.assertIn('psk_hex', resp['error']) self.assertIn('psk_hex', resp['error'])
def test_tls_mode_starts_and_reports_status(self): def test_tls_mode_starts_and_reports_status(self):
@@ -421,17 +623,88 @@ class BipControlTest(unittest.TestCase):
self.assertTrue(resp['ok'], resp) self.assertTrue(resp['ok'], resp)
listener = resp['listener'] listener = resp['listener']
self.assertEqual(listener['mode'], 'tls') self.assertEqual(listener['mode'], 'tls')
self.assertEqual(listener['psk_identity'], 'id-1') self.assertEqual(listener['psk_identities'], ['id-1'])
self.assertFalse(listener['psk_wildcard'])
self.assertIsNone(listener['identity_seen']) self.assertIsNone(listener['identity_seen'])
self.assertTrue(resp['bip']['enabled']) self.assertTrue(resp['bip']['enabled'])
# the key never leaves the server # the key never leaves the server
self.assertNotIn('psk_hex', listener) self.assertNotIn('psk_hex', listener)
def test_psk_map_start_and_update(self):
server._SCP81_PSKS = {}
server._SCP81_PSK_LEGACY = None
resp = server._scp81_bip_control({
'action': 'start', 'mode': 'tls', 'host': '127.0.0.1', 'port': 0,
'psk_map': [{'identity': 'id-1', 'psk_hex': '00112233'},
{'identity': 'id-2', 'psk_hex': '44556677'},
{'identity': '', 'psk_hex': '99'}, # skipped
{'identity': 'id-3', 'psk_hex': ''}]}) # skipped
self.assertTrue(resp['ok'], resp)
self.assertEqual(resp['listener']['psk_identities'], ['id-1', 'id-2'])
# a preset edit pushes the new table without a listener restart
upd = server._scp81_update_psk_map(
{'psk_map': [{'identity': 'id-9', 'psk_hex': 'aabbccdd'}]})
self.assertTrue(upd['ok'], upd)
self.assertEqual(upd['identities'], ['id-9'])
self.assertEqual(upd['listener']['psk_identities'], ['id-9'])
def test_psk_map_requires_a_listener_for_update(self):
server._scp81_bip_control({'action': 'stop'})
resp = server._scp81_update_psk_map(
{'psk_map': [{'identity': 'id-1', 'psk_hex': '00112233'}]})
self.assertFalse(resp['ok'])
self.assertIn('not running', resp['error'])
def test_psk_map_empty_entries_rejected(self):
server._SCP81_PSKS = {}
server._SCP81_PSK_LEGACY = None
resp = server._scp81_bip_control({
'action': 'start', 'mode': 'tls', 'host': '127.0.0.1', 'port': 0,
'psk_map': [{'identity': '', 'psk_hex': '00112233'}]})
self.assertFalse(resp['ok'])
self.assertIn('no usable entries', resp['error'])
def test_psk_request_redaction(self):
body = {'action': 'start',
'psk_hex': '00112233445566778899aabbccddeeff',
'psk_map': [{'identity': 'id-1', 'psk_hex': '00112233'},
{'identity': ''}]}
red = server._redact_psk_fields(body)
self.assertEqual(red['psk_hex'], '<redacted>')
self.assertEqual(red['psk_map'][0]['psk_hex'], '<redacted>')
self.assertEqual(red['psk_map'][0]['identity'], 'id-1')
self.assertEqual(red['psk_map'][1], {'identity': ''})
# the original body is untouched
self.assertEqual(body['psk_map'][0]['psk_hex'], '00112233')
def test_unknown_mode_rejected(self): def test_unknown_mode_rejected(self):
resp = server._scp81_bip_control({'action': 'start', 'mode': 'nope'}) resp = server._scp81_bip_control({'action': 'start', 'mode': 'nope'})
self.assertFalse(resp['ok']) self.assertFalse(resp['ok'])
self.assertIn('unsupported mode', resp['error']) self.assertIn('unsupported mode', resp['error'])
def test_start_accepts_explicit_script_list(self):
server._SCP81_PSKS = {}
server._SCP81_PSK_LEGACY = None
resp = server._scp81_bip_control({
'action': 'start', 'mode': 'tls', 'host': '127.0.0.1', 'port': 0,
'psk_map': [{'identity': 'id-1', 'psk_hex': '00112233'}],
'script': ['80CAFF2100'], 'script_kind': 'Explore'})
self.assertTrue(resp['ok'], resp)
self.assertEqual(resp['script'], ['80CAFF2100'])
self.assertEqual(resp['script_kind'], 'Explore')
self.assertEqual(server._SCP81_SCRIPT_BASE, ['80CAFF2100'])
self.assertEqual(server._SCP81_SCRIPT_NEXT, 0)
def test_start_rejects_named_script_presets(self):
# Scripts live in the PWA now: the server only takes an explicit list.
server._SCP81_PSKS = {}
server._SCP81_PSK_LEGACY = None
resp = server._scp81_bip_control({
'action': 'start', 'mode': 'tls', 'host': '127.0.0.1', 'port': 0,
'psk_hex': '00112233', 'script': 'explore'})
self.assertFalse(resp['ok'])
self.assertIn('unknown script preset', resp['error'])
class DataAvailableTest(unittest.TestCase): class DataAvailableTest(unittest.TestCase):
def _channel(self, cid=1, rx=b'\x16\x03\x03'): def _channel(self, cid=1, rx=b'\x16\x03\x03'):
@@ -609,8 +882,8 @@ class ConnHeaderTest(unittest.TestCase):
class TargetedAppTest(unittest.TestCase): class TargetedAppTest(unittest.TestCase):
def test_targeted_app_header(self): def test_targeted_app_header(self):
server._SCP81_SCRIPT = ['80CAFF2100'] reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
server._SCP81_TARGETED_APP = '//aid/A000000151000000' server._SCP81_TARGETED_APP = '//aid/A000000151000000'
try: try:
status, headers, body = server._scp81_script_responder( status, headers, body = server._scp81_script_responder(
@@ -620,12 +893,12 @@ class TargetedAppTest(unittest.TestCase):
'//aid/A000000151000000') '//aid/A000000151000000')
finally: finally:
server._SCP81_TARGETED_APP = None server._SCP81_TARGETED_APP = None
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
def test_apache_headers(self): def test_apache_headers(self):
server._SCP81_SCRIPT = ['80CAFF2100'] reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
server._SCP81_APACHE_HEADERS = True server._SCP81_APACHE_HEADERS = True
server._SCP81_CHUNKED = False server._SCP81_CHUNKED = False
try: try:
@@ -640,14 +913,14 @@ class TargetedAppTest(unittest.TestCase):
finally: finally:
server._SCP81_APACHE_HEADERS = False server._SCP81_APACHE_HEADERS = False
server._SCP81_CHUNKED = False server._SCP81_CHUNKED = False
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
def test_chunked_apache_has_no_content_length(self): def test_chunked_apache_has_no_content_length(self):
# The reference (RAM/HTTPOTA_test5.pcap, decryptable) sends chunked # The reference (RAM/HTTPOTA_test5.pcap, decryptable) sends chunked
# without Content-Length, Transfer-Encoding before Content-Type. # without Content-Length, Transfer-Encoding before Content-Type.
server._SCP81_SCRIPT = ['80CAFF2100'] reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
server._SCP81_APACHE_HEADERS = True server._SCP81_APACHE_HEADERS = True
server._SCP81_CHUNKED = True server._SCP81_CHUNKED = True
try: try:
@@ -664,8 +937,8 @@ class TargetedAppTest(unittest.TestCase):
finally: finally:
server._SCP81_APACHE_HEADERS = False server._SCP81_APACHE_HEADERS = False
server._SCP81_CHUNKED = False server._SCP81_CHUNKED = False
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
def test_continuation_sets_p2_next_bit(self): def test_continuation_sets_p2_next_bit(self):
# P2.b1: 0 = first/all, 1 = next batch of the SAME search criteria # P2.b1: 0 = first/all, 1 = next batch of the SAME search criteria
@@ -676,58 +949,50 @@ class TargetedAppTest(unittest.TestCase):
self.assertIsNone(server._scp81_continuation('80CAFF2100')) self.assertIsNone(server._scp81_continuation('80CAFF2100'))
def test_cafe_page_auto_continuation(self): def test_cafe_page_auto_continuation(self):
server._SCP81_SCRIPT = ['80F24002024F0000'] reset_script(['80F24002024F0000'])
server._SCP81_SCRIPT_SENT = 1 server._SCP81_SCRIPT_NEXT = 1
server._SCP81_SCRIPT_RESULTS = [] server._SCP81_SCRIPT_PENDING = {'index': 1, 'pos': 0, 'page': False,
server._SCP81_SCRIPT_INSERTED = [] 'apdu': '80F24002024F0000'}
server._SCP81_PAGES = 0
try: try:
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100') page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE' tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE'
body = b'\xAF\x80' + tlv + b'\x00\x00' body = b'\xAF\x80' + tlv + b'\x00\x00'
status, headers, out = server._scp81_script_responder( status, headers, out = server._scp81_script_responder(
'POST', '/api/scp81?req=1', {'x-admin-script-status': 'ok'}, body) 'POST', '/api/scp81?req=1', {'x-admin-script-status': 'ok'}, body)
# The continuation was appended and sent as the next command. # The continuation was queued and sent as the next command.
self.assertEqual(server._SCP81_SCRIPT[1], '80F24003024F0000') self.assertEqual(server._SCP81_SCRIPT_BASE, ['80F24002024F0000'])
self.assertEqual(server._SCP81_SCRIPT_PENDING['apdu'],
'80F24003024F0000')
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertIn(bytes.fromhex('80F24003024F0000'), out) self.assertIn(bytes.fromhex('80F24003024F0000'), out)
finally: finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0
def test_standard_more_data_sw_also_pages(self): def test_standard_more_data_sw_also_pages(self):
# GP Table 11-38: SW '63 10' = more data available, continue with # GP Table 11-38: SW '63 10' = more data available, continue with
# GET STATUS [next occurrence] - same handling as the card's 'CA FE'. # GET STATUS [next occurrence] - same handling as the card's 'CA FE'.
server._SCP81_SCRIPT = ['80F2 4002 024F 0000'.replace(' ', '')] reset_script(['80F2 4002 024F 0000'.replace(' ', '')])
server._SCP81_SCRIPT_SENT = 1 server._SCP81_SCRIPT_NEXT = 1
server._SCP81_SCRIPT_RESULTS = [] server._SCP81_SCRIPT_PENDING = {'index': 1, 'pos': 0, 'page': False,
server._SCP81_SCRIPT_INSERTED = [] 'apdu': '80F24002024F0000'}
server._SCP81_PAGES = 0
try: try:
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100') page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
tlv = bytes([0x23, len(page) + 2]) + page + b'\x63\x10' tlv = bytes([0x23, len(page) + 2]) + page + b'\x63\x10'
body = b'\xAF\x80' + tlv + b'\x00\x00' body = b'\xAF\x80' + tlv + b'\x00\x00'
server._scp81_script_responder( server._scp81_script_responder(
'POST', '/api/scp81?req=1', {'x-admin-script-status': 'ok'}, body) 'POST', '/api/scp81?req=1', {'x-admin-script-status': 'ok'}, body)
self.assertEqual(server._SCP81_SCRIPT[1], '80F24003024F0000') self.assertEqual(server._SCP81_SCRIPT_PENDING['apdu'],
'80F24003024F0000')
finally: finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0
def test_repeated_pages_keep_paging(self): def test_repeated_pages_keep_paging(self):
# The continuation is stateful (P2=03): the same APDU legitimately # The continuation is stateful (P2=03): the same APDU legitimately
# repeats until the card answers 9000; the page counter caps it. # repeats until the card answers 9000; the page counter caps it.
server._SCP81_SCRIPT = ['80F24002024F0000'] reset_script(['80F24002024F0000'])
server._SCP81_SCRIPT_SENT = 1 server._SCP81_SCRIPT_NEXT = 1
server._SCP81_SCRIPT_RESULTS = [] server._SCP81_SCRIPT_PENDING = {'index': 1, 'pos': 0, 'page': False,
server._SCP81_SCRIPT_INSERTED = [] 'apdu': '80F24002024F0000'}
server._SCP81_PAGES = 0
try: try:
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100') page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE' tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE'
@@ -737,24 +1002,31 @@ class TargetedAppTest(unittest.TestCase):
'POST', '/api/scp81?req=2', {'x-admin-script-status': 'ok'}, body) 'POST', '/api/scp81?req=2', {'x-admin-script-status': 'ok'}, body)
self.assertEqual(server._SCP81_PAGES, 3) self.assertEqual(server._SCP81_PAGES, 3)
finally: finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0
def test_new_session_drops_inserted_pages(self): def test_resumed_dialog_keeps_pages_fresh_dialog_resets(self):
server._SCP81_SCRIPT = ['80F24002024F0000', '80F24003024F0000'] # The continuation pages belong to one run: a resumed dialog keeps
server._SCP81_SCRIPT_INSERTED = ['80F24003024F0000'] # them, a fresh dialog starts the script over.
server._SCP81_SCRIPT_SENT = 2 reset_script(['80F24002024F0000'])
server._SCP81_SCRIPT_NEXT = 1
server._SCP81_SCRIPT_PENDING = {'index': 1, 'pos': 0, 'page': False,
'apdu': '80F24002024F0000'}
server._SCP81_SCRIPT_PAGE_QUEUE = ['80F24003024F0000']
try: try:
server._scp81_script_responder('POST', '/api/scp81', {}, b'') # Resumed dialog: the queued page is sent as the next command.
self.assertEqual(server._SCP81_SCRIPT, ['80F24002024F0000']) server._scp81_script_responder(
self.assertEqual(server._SCP81_SCRIPT_SENT, 1) 'POST', '/api/scp81', {'x-admin-resume': 'true'}, b'')
self.assertEqual(server._SCP81_SCRIPT_PENDING['apdu'],
'80F24003024F0000')
self.assertEqual(server._SCP81_SCRIPT_PENDING['page'], True)
# Fresh dialog: the run restarts from the first APDU.
status, headers, out = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'')
self.assertIn(bytes.fromhex('80F24002024F0000'), out)
self.assertEqual(server._SCP81_SCRIPT_PENDING['pos'], 0)
self.assertEqual(server._SCP81_SCRIPT_PAGE_QUEUE, [])
finally: finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_INSERTED = []
def test_exact_wire_bodies_from_reference_log(self): def test_exact_wire_bodies_from_reference_log(self):
# De-chunked bodies captured in adminserver.log (2019-09-05). # De-chunked bodies captured in adminserver.log (2019-09-05).
@@ -772,37 +1044,46 @@ class TargetedAppTest(unittest.TestCase):
class QueueScriptTest(unittest.TestCase): class QueueScriptTest(unittest.TestCase):
def test_queue_replaces_and_resets(self): def test_queue_replaces_and_resets(self):
server._SCP81_SCRIPT = ['80CAFF2100'] reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_SENT = 1 server._SCP81_SCRIPT_NEXT = 1
server._SCP81_SCRIPT_RESULTS = [{'index': 1, 'sw': '9000', 'apdu': '80CAFF2100', 'rapdu': ''}] server._SCP81_SCRIPT_RESULTS = [{'index': 1, 'sw': '9000',
'apdu': '80CAFF2100', 'rapdu': ''}]
try: try:
r = server._scp81_queue_script(['80E6020013' + '00' * 20, '80E88000' + '00' * 4], r = server._scp81_queue_script(['80E6020013' + '00' * 20,
'80E88000' + '00' * 4],
kind='ram-install') kind='ram-install')
self.assertTrue(r['queued']) self.assertTrue(r['queued'])
self.assertEqual(server._SCP81_SCRIPT_KIND, 'ram-install') self.assertEqual(server._SCP81_SCRIPT_KIND, 'ram-install')
self.assertEqual(server._SCP81_SCRIPT_SENT, 0) self.assertEqual(server._SCP81_SCRIPT_NEXT, 0)
self.assertEqual(server._SCP81_SCRIPT_RESULTS, []) self.assertEqual(server._SCP81_SCRIPT_RESULTS, [])
self.assertEqual(len(server._SCP81_SCRIPT), 2) self.assertEqual(len(server._SCP81_SCRIPT_BASE), 2)
finally: finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_KIND = 'explore'
def test_queue_refuses_while_running(self): def test_queue_refuses_while_running(self):
server._SCP81_SCRIPT = ['80CAFF2100', '80F28002024F0000'] reset_script(['80CAFF2100', '80F28002024F0000'])
server._SCP81_SCRIPT_SENT = 1 server._SCP81_SCRIPT_NEXT = 1
server._SCP81_SCRIPT_PENDING = {'index': 1, 'pos': 0, 'page': False,
'apdu': '80CAFF2100'}
try: try:
r = server._scp81_queue_script(['80E60200'], kind='ram-install') r = server._scp81_queue_script(['80E60200'], kind='ram-install')
self.assertFalse(r['queued']) self.assertFalse(r['queued'])
self.assertEqual(r['sent'], 1) self.assertEqual(r['next'], 1)
self.assertTrue(r['of'])
r = server._scp81_queue_script(['80E60200'], kind='ram-install', force=True) r = server._scp81_queue_script(['80E60200'], kind='ram-install', force=True)
self.assertTrue(r['queued']) self.assertTrue(r['queued'])
self.assertEqual(server._SCP81_SCRIPT, ['80E60200']) self.assertEqual(server._SCP81_SCRIPT_BASE, ['80E60200'])
finally: finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_KIND = 'explore' def test_queue_allowed_before_first_send(self):
reset_script(['80CAFF2100'])
try:
r = server._scp81_queue_script(['80E60200'], kind='install')
self.assertTrue(r['queued'])
self.assertEqual(server._SCP81_SCRIPT_BASE, ['80E60200'])
finally:
reset_script()
class ScriptBodyLengthTest(unittest.TestCase): class ScriptBodyLengthTest(unittest.TestCase):
@@ -830,8 +1111,8 @@ class ScriptBodyLengthTest(unittest.TestCase):
class VerbatimScriptTest(unittest.TestCase): class VerbatimScriptTest(unittest.TestCase):
def test_expanded_templates_sent_verbatim(self): def test_expanded_templates_sent_verbatim(self):
server._SCP81_SCRIPT = ['AA0B2208 80CAFF2100'.replace(' ', '')] reset_script(['AA0B2208 80CAFF2100'.replace(' ', '')])
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
server._SCP81_SCRIPT_RESULTS = [] server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = [] server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0 server._SCP81_PAGES = 0
@@ -842,19 +1123,19 @@ class VerbatimScriptTest(unittest.TestCase):
# Sent as-is (no AE80/22 wrapper added) # Sent as-is (no AE80/22 wrapper added)
self.assertEqual(body.hex().upper(), 'AA0B220880CAFF2100') self.assertEqual(body.hex().upper(), 'AA0B220880CAFF2100')
finally: finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
def test_plain_apdu_still_wrapped(self): def test_plain_apdu_still_wrapped(self):
server._SCP81_SCRIPT = ['80CAFF2100'] reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
try: try:
status, headers, body = server._scp81_script_responder( status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'') 'POST', '/api/scp81', {}, b'')
self.assertEqual(body.hex().upper(), 'AE80220580CAFF21000000') self.assertEqual(body.hex().upper(), 'AE80220580CAFF21000000')
finally: finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) reset_script()
server._SCP81_SCRIPT_SENT = 0 server._SCP81_SCRIPT_NEXT = 0
class ResponseTlvLengthTest(unittest.TestCase): class ResponseTlvLengthTest(unittest.TestCase):