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` | POST | Update dictionary entries |
| `/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-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
@@ -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 |
| `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) |
| `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):**
```json
@@ -186,9 +191,17 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL
"final_cntr": "0000000004",
"load_file_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):**
```json
{"success": false, "failed_step": "load_1",
@@ -467,19 +480,29 @@ ClientHello) without answering:
{"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
and optional identity are applied to the TLS handshake, and the GP HTTP
administration dialog (`X-Admin-*` headers, 200 with a command string or 204
No Content) is served. `psk_hex` is required (the previous key is reused when
omitted); `psk_identity` restricts the accepted identity. The key is never
stored or logged.
TLS mode runs the Phase B PSK TLS server (GPC v2.2 Amendment B): the PSK
table is applied to the TLS handshake, and the GP HTTP administration dialog
(`X-Admin-*` headers, 200 with a command string or 204 No Content) is served.
`psk_map` is the lookup table for the identity the card presents in the TLS
handshake — the PWA sends it from the card presets (`{identity, psk_hex}`
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
{"action": "start", "mode": "tls", "host": "127.0.0.1", "port": 8443,
"psk_hex": "00112233445566778899aabbccddeeff",
"psk_identity": "89012345678901234567"}
"psk_map": [{"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).
### `GET /api/scp81/status`
@@ -487,9 +510,29 @@ Stop either mode with `{"action": "stop"}` (also disables the BIP terminal).
```json
{"bip": {"enabled": true, "target": "127.0.0.1:8443", "channels": [], "seq": 12},
"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`
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`
Queue explicit commands as the SCP81 script (used by the Remote APDU tab's
RAM chain "Queue in SCP81"). Body `{"apdus": ["80E60C002E...", ...]}` (or a
single `apdu`), optional `kind` and `force`. Entries that already are
Command Scripting templates (`AA...`/`AE80...`, the expanded format) are
sent verbatim instead of being wrapped again. Refused while a script is
mid-run unless forced.
Replace the SCP81 command script (used by the Remote APDU tab's RAM chain
"Queue in SCP81" and the PWA's "Restart script"). Body
`{"apdus": ["80E60C002E...", ...]}` (or a single `apdu`), optional `kind` and
`force`. Entries that already are Command Scripting templates
(`AA...`/`AE80...`, the expanded format) are sent verbatim instead of being
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
server-side (same parser as `/api/ram-install`) and expanded to the APDU
sequence INSTALL [for load] -> LOAD blocks (240-byte payloads) -> INSTALL
[for install]; the list runs on the card's next POST, one C-APDU per request.
Generate the RAM (GP) APDU sequence for a `.cap` without touching the listener
or the running script; the PWA's "Install from .cap" script template stores
the returned list. The `.cap` is parsed server-side (same parser as
`/api/ram-install`) and expanded to INSTALL [for load] -> LOAD blocks
(240-byte payloads) -> INSTALL [for install]; the file itself is never stored.
```json
{"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
true. Responds with `{"ok": true, "queued": true, "apdus": N, "load_file_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`).
`sd_aid` empty = the ISD. Responds with `{"ok": true, "apdus": [...],
"load_file_aid": ..., "module_aid": ...}`.
### `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
{"script": ["80CAFF2100", "80F28002024F0000"], "sent": 1,
"results": [{"index": 1, "sw": "9000", "rapdu": "FF210C810102..."}]}
{"script": ["80CAFF2100", "80F28002024F0000"], "next": 2, "total": 2,
"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`
parameter: `explore` (default — the reference administration server's command
sequence: GET DATA FF21 extended resources / free memory, GET STATUS P1=80
Issuer Security Domain, GET DATA 0085 HTTP administration parameters, GET
STATUS P1=40 executable load files and P1=10 applications), `none` (answer
every POST with 204), or an explicit list of APDU hex strings. Each APDU is
`next` is the index of the next script APDU to send; `done` lists the script
indices the card reported. `pending` describes the C-APDU awaiting the card's
`X-Admin-Script-Status` report as `{index, pos, page, apdu}` (`pos` = script
index, `null` for an auto continuation page) or `null`; `pages` counts the
continuation pages queued so far and `pages_queued` those not yet sent.
`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
(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
(`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
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
SCP81 tab (page merging/decoding for ELF and application listings);
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
(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
pending (needs a push with a suitable applet).
returns the APDU list, which the PWA stores as an "Install from .cap"
script (the `.cap` itself is never stored). Live install verified
2026-09-16.
## Tooling
+11 -9
View File
@@ -44,7 +44,7 @@
<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>Выбор языка и темы хранится в <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>
</ul>
@@ -241,7 +241,7 @@
<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>
<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>
<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>
<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">
<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>
@@ -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">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><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>
</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>
<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>
<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>
<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><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>
</table>
@@ -378,7 +380,7 @@
<li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
<li><strong>Профиль из снимка</strong> — выбирает сохранённый снимок карты и создаёт по правилу на каждый захваченный файл с теми же опциями сканирования (см. ниже), без картридера; имя профиля подставляется из имени снимка.</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>
<h4 class="font-medium mb-1">Правила файловой системы</h4>
<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>, <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>снимок для проверки</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>
</section>
+10 -8
View File
@@ -44,7 +44,7 @@
<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>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>
</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>
<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">
<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>
@@ -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">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><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>
</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>
<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>
<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>
<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><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>
</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 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>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>
<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>
@@ -399,7 +401,7 @@
<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><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>
</section>
+720 -213
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 = [
'index.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', () => {
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</);
});
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', () => {
assert.match(html, /data-sub="response" onclick="cApduSwitchSubtab\('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);
}
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 = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
@@ -435,6 +435,63 @@ test('profilerNumRanges compresses consecutive record numbers', () => {
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', () => {
assert.deepStrictEqual(
profilerResultAspects({ checks: [
@@ -511,7 +568,7 @@ test('record count mismatch is reported once when numRecords is checked', async
global.t = s => s;
global.pysimCustomFiles = [];
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;
});
@@ -553,12 +610,12 @@ test('profilerRenderReport includes the checked-aspects summary and matching-rec
{ path: 'MF/7F20/6F4E', name: 'EF.Y', status: 'fail', checks: [
{ label: 'content.rec6', ok: false, expected: 'BB', actual: 'XX' },
{ 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 ✓, size ✗, contents ✓'));
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;
});
@@ -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/6F3B', { content: null }),
]);
const rules = profilerRulesFromSnapshot(master, null);
const rules = profilerRulesFromSnapshot(master);
assert.strictEqual(rules.length, 3);
assert.strictEqual(rules[0].fciMode, 'exact');
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);
});
test('profilerRulesFromSnapshot masks only the checked FIDs', () => {
const master = masterSnap([
snapFile('MF/7F20/6F07', { name: 'EF.IMSI', content: { kind: 'transparent', data: '082905911234567890' } }),
snapFile('MF/2FE2', { name: 'EF.ICCID', content: { kind: 'transparent', data: '98680012345678901234' } }),
snapFile('MF/6F3A', { content: { kind: 'transparent', data: 'AABBCCDD' } }),
]);
const rules = profilerRulesFromSnapshot(master, new Set(['6F07']));
assert.deepStrictEqual(rules[0].content, { mode: 'mask', kind: 'transparent', expected: '08290591??????????' });
assert.deepStrictEqual(rules[1].content, { mode: 'exact', kind: 'transparent', expected: '98680012345678901234' });
assert.deepStrictEqual(rules[2].content, { mode: 'exact', kind: 'transparent', expected: 'AABBCCDD' });
});
test('profilerRulesFromSnapshot falls back to the symbolic name for masking', () => {
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 is always exact (no IMSI/ICCID masking)', async () => {
const files = [snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } })];
const rules = profilerRulesFromSnapshot(masterSnap(files));
assert.deepStrictEqual(rules[0].content,
{ mode: 'exact', kind: 'transparent', expected: '082905911234567890' });
const same = profilerSnapshotSource(masterSnap([
snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567890' } })]));
const other = profilerSnapshotSource(masterSnap([
snapFile('MF/7F20/6F07', { name: 'EF.IMSI', fileSize: 9, content: { kind: 'transparent', data: '082905911234567891' } })]));
assert.strictEqual((await profilerRunRule(rules[0], same)).status, 'pass');
const res = await profilerRunRule(rules[0], other);
assert.strictEqual(res.status, 'fail');
assert.ok(res.checks.some(c => c.label === 'content' && c.ok === false));
});
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 rules = profilerRulesFromSnapshot(masterSnap(files), new Set(['6F07']));
const rules = profilerRulesFromSnapshot(masterSnap(files));
const source = profilerSnapshotSource(masterSnap(files.map(f => ({ ...f }))));
for (const r of rules) {
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 () => {
const master = masterSnap([snapFile('MF/6F3A')]);
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));
assert.strictEqual(res.status, 'fail');
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', () => {
const master = masterSnap([snapFile('MF/6F3A')]);
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]
name = "pysim-otaman-server"
version = "2.1.18"
version = "2.2.0"
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8"
# 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):
"""Parse an HTTP/1.1 request head (bytes up to CRLFCRLF) into
(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:
"""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,
keep_alive=False, compact_headers=False, tls_version='1.2',
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.identity = identity
self.identity = _norm_identity(identity)
self.on_log = on_log
self.responder = responder or self._default_responder
self.timeout = timeout
@@ -165,6 +191,7 @@ class PskTlsServer:
# SEND-DATA conversation to settle before it accepts the response).
self.answer_delay = float(answer_delay or 0)
self.identity_seen = None
self.identity_matched = None
self.stopped = False
self.conns = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -223,14 +250,34 @@ class PskTlsServer:
return ctx
def _psk_cb(self, identity):
"""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:
self.log('tls-psk-unknown', identity=identity)
# A dummy key keeps the callback type-safe; the handshake then
# fails on the Finished MAC check.
"""OpenSSL asks for the key of the identity the client sent.
The identity is looked up in the configured table (identity -> key);
without a match the handshake fails on the Finished MAC check with a
dummy key, and the attempt is logged as 'tls-psk-unknown'."""
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 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
def _default_responder(method, target, headers, body):
@@ -285,7 +332,8 @@ class PskTlsServer:
try:
tls = self.ctx.wrap_socket(conn, server_side=True)
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:
req = self._read_request(tls)
if req is None:
@@ -304,15 +352,15 @@ class PskTlsServer:
status, resp_headers, resp_body = self.responder(
method, target, headers, body)
reason = {200: 'OK', 204: 'No Content'}.get(status, 'Status')
conn = self.conn_header
if conn == 'none':
conn = None
elif conn is None:
conn = 'keep-alive' if self.keep_alive else 'close'
conn_hdr = self.conn_header
if conn_hdr == 'none':
conn_hdr = None
elif conn_hdr is None:
conn_hdr = 'keep-alive' if self.keep_alive else 'close'
response = build_http_response(
status, reason, resp_headers, resp_body,
chunked=self.chunked, compact=self.compact_headers,
connection=conn)
connection=conn_hdr)
# The card's HTTP client reads its response record-by-record:
# the whole response must arrive in ONE TLS record (chunk_size
# 0), otherwise a split head stalls it and a head-only record
+415 -149
View File
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
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
@@ -625,6 +625,28 @@ def _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex, kic_key_he
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):
from pySim.ota import OtaDialectSms, CompactRemoteResp
from osmocom.utils import h2b, b2h
@@ -826,7 +848,12 @@ _PLI_DATA = {q: '' for q in PLI_QUALIFIER_NAMES}
_BIP = httpota.BipTerminal()
_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_INTERVAL = 30
@@ -1299,8 +1326,10 @@ def _scp81_listener_status():
return None
if isinstance(_SCP81_LISTENER, scp81.PskTlsServer):
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_matched': _SCP81_LISTENER.identity_matched,
'chunked': _SCP81_LISTENER.chunked,
'chunk_size': _SCP81_LISTENER.chunk_size,
'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'
# executed-count and '23' R-APDU TLVs whose last two bytes are SW1 SW2).
_SCP81_SCRIPTS = {
# The command sequence of the reference administration server
# (samples/HTTP_OTA/httpota_adminserver_php_v2, get_next_apdu), extended
# with the registries: GET DATA FF21 (extended card resources / free
# memory), GET DATA 0085, then GET STATUS with P2=02 (TLV structure,
# 'first or all') and data '4F00' (match all): P1=80 (Issuer Security
# Domain), P1=40 (applications and supplementary security domains),
# P1=20 (executable load files), P1=10 (ELF and their modules);
# Le=00 so no GET RESPONSE is needed. Long listings answer SW CAFE and
# are auto-continued with the same command carrying P2.b1=1 ('next').
'explore': ['80CAFF2100', '80F28002024F0000', '80CA008500',
'80F24002024F0000', '80F22002024F0000', '80F21002024F0000'],
'none': [],
}
_SCP81_SCRIPT = list(_SCP81_SCRIPTS['explore'])
_SCP81_SCRIPT_SENT = 0
# The command script served to the card, set by the PWA at listener start (an
# explicit APDU list) or via /api/scp81/queue. The server is agnostic to what
# the APDUs do: it serves them one per administration POST and tracks
# execution so a resumed session sends only the leftover APDUs.
# _SCP81_SCRIPT_BASE the configured APDU list (never mutated)
# _SCP81_SCRIPT_NEXT index in BASE of the next APDU to send
# _SCP81_SCRIPT_DONE BASE indices the card reported (executed, any result)
# _SCP81_SCRIPT_PENDING the APDU sent in the previous POST, waiting for the
# card's X-Admin-Script-Status report (None = none); a
# session that dies before the report resends it
# _SCP81_SCRIPT_RESULTS one entry per reported APDU
# {index, pos, page, apdu, rapdu, sw}
# _SCP81_SCRIPT_PAGE_QUEUE continuation pages auto-inserted for truncated
# GET STATUS listings (sent before BASE[NEXT])
_SCP81_SCRIPT_BASE = []
_SCP81_SCRIPT_NEXT = 0
_SCP81_SCRIPT_DONE = set()
_SCP81_SCRIPT_PENDING = None
_SCP81_SCRIPT_RESULTS = []
# Continuation pages: long GET STATUS listings answer SW CAFE with 127-byte
# pages; the responder auto-inserts a GET STATUS (P2=02, last AID as search
# criterion) after each page until the listing ends.
_SCP81_SCRIPT_INSERTED = []
_SCP81_SCRIPT_PAGE_QUEUE = []
_SCP81_SCRIPT_SENT_NO = 0
_SCP81_PAGES = 0
SCP81_MAX_PAGES = 24
# What the queued script is ('explore', 'none', 'custom' or 'ram-install').
_SCP81_SCRIPT_KIND = 'explore'
# What the queued script is ('none', 'custom' or the name the PWA sent).
_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):
"""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."""
global _SCP81_SCRIPT, _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS
global _SCP81_SCRIPT_INSERTED, _SCP81_PAGES, _SCP81_SCRIPT_KIND
if not force and 0 < _SCP81_SCRIPT_SENT < len(_SCP81_SCRIPT):
if not force and _scp81_script_mid_run():
return {'queued': False, 'reason': 'script in progress',
'sent': _SCP81_SCRIPT_SENT, 'of': len(_SCP81_SCRIPT)}
_SCP81_SCRIPT = [a.upper().replace(' ', '') for a in apdus]
_SCP81_SCRIPT_KIND = kind
_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)}
'next': _SCP81_SCRIPT_NEXT, 'of': len(_SCP81_SCRIPT_BASE)}
_scp81_reset_script(apdus, kind)
return {'queued': True, 'apdus': len(_SCP81_SCRIPT_BASE)}
_SCP81_SCRIPT_TEMPLATE = 'indefinite'
_SCP81_SCRIPT_CR_TAG = False
# 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:])
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):
"""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)."""
global _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS, _SCP81_PAGES
global _SCP81_SCRIPT_INSERTED
the next scripted C-APDU or close the session (TS 102 226 / GP 4.4.2).
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')
if status is not None:
index = _SCP81_SCRIPT_SENT
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:
# The card reports the outcome of the pending C-APDU.
if status != 'ok':
_BIP.log('script-status', index=index, status=status)
else:
count, rapdus = _scp81_parse_response(body)
for rapdu, sw in rapdus:
_BIP.log('script-rapdu', index=index, sw=sw, bytes=len(rapdu),
hex=rapdu.hex().upper()[:2000])
_SCP81_SCRIPT_RESULTS.append(
{'index': index, 'pos': pending.get('pos'),
'page': bool(pending.get('page')), 'sw': sw,
'apdu': pending['apdu'],
'rapdu': rapdu.hex().upper()})
if rapdus:
if pending['apdu'].startswith('80CAFF21'):
decoded = _scp81_decode_memory(rapdus[-1][0])
if decoded:
_BIP.log('script-memory', **decoded)
# '63 10' = "more data available" (GP Table 11-38); the
# 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):
cont = _scp81_continuation(pending['apdu'])
if cont:
_SCP81_PAGES += 1
_SCP81_SCRIPT_PAGE_QUEUE.append(cont)
_BIP.log('script-page', index=index,
page=_SCP81_PAGES, 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:
_SCP81_SCRIPT_NEXT = pending['pos']
_SCP81_SCRIPT_DONE.discard(pending['pos'])
_BIP.log('script-resend', index=index, apdu=pending['apdu'])
else:
# Fresh dialog (new trigger): the script runs from the start.
_scp81_restart_run()
elif status is not None:
# A report without a pending APDU (the server may have restarted
# mid-session): log the R-APDUs but do not advance anything.
if status != 'ok':
_BIP.log('script-status', index=index, status=status)
_BIP.log('script-status', index=None, status=status)
else:
count, rapdus = _scp81_parse_response(body)
apdu = _SCP81_SCRIPT[index - 1].upper() if (_SCP81_SCRIPT and index >= 1) else ''
for rapdu, sw in rapdus:
_BIP.log('script-rapdu', index=index, sw=sw, bytes=len(rapdu),
_BIP.log('script-rapdu', index=None, sw=sw, bytes=len(rapdu),
hex=rapdu.hex().upper()[:2000])
_SCP81_SCRIPT_RESULTS.append({'index': index, 'sw': sw,
'apdu': apdu,
'rapdu': rapdu.hex().upper()})
if rapdus and _SCP81_SCRIPT and index >= 1:
if apdu.startswith('80CAFF21'):
decoded = _scp81_decode_memory(rapdus[-1][0])
if decoded:
_BIP.log('script-memory', **decoded)
# '63 10' = "more data available" (GP Table 11-38); the live
# card uses a proprietary 'CA FE' for the same condition.
if rapdus[-1][1].upper() in ('CAFE', '6310') and _SCP81_PAGES < SCP81_MAX_PAGES:
cont = _scp81_continuation(apdu)
if cont:
_SCP81_PAGES += 1
_SCP81_SCRIPT.insert(_SCP81_SCRIPT_SENT, cont)
_SCP81_SCRIPT_INSERTED.append(cont)
_BIP.log('script-page', index=index, page=_SCP81_PAGES,
apdu=cont)
else:
# First (or resumed) POST of a session: run the script from the start.
# Drop continuation pages inserted by a previous session.
if _SCP81_SCRIPT_INSERTED:
_SCP81_SCRIPT[:] = [a for a in _SCP81_SCRIPT
if a not in _SCP81_SCRIPT_INSERTED]
_SCP81_SCRIPT_INSERTED = []
_SCP81_SCRIPT_SENT = 0
_SCP81_SCRIPT_RESULTS = []
_SCP81_PAGES = 0
if _SCP81_SCRIPT_SENT < len(_SCP81_SCRIPT):
apdu = _SCP81_SCRIPT[_SCP81_SCRIPT_SENT]
_SCP81_SCRIPT_SENT += 1
_BIP.log('script-send', index=_SCP81_SCRIPT_SENT, apdu=apdu)
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()
if _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'
# 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'
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:
headers['X-Admin-Next-URI'] = next_uri
u = apdu.upper()
@@ -1606,7 +1730,7 @@ def _scp81_script_responder(method, target, headers, body):
headers['Content-Length'] = str(len(body_out))
headers['Content-Type'] = scp81.GP_CT_COMMAND
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))
headers = _scp81_response_headers()
if _SCP81_APACHE_HEADERS:
@@ -1627,10 +1751,101 @@ def _scp81_response_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):
global _SCP81_LISTENER, _SCP81_PSK
global _SCP81_SCRIPT, _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS
global _SCP81_SCRIPT_INSERTED, _SCP81_PAGES, _SCP81_SCRIPT_KIND
global _SCP81_LISTENER, _SCP81_PSKS, _SCP81_PSK_LEGACY
global _SCP81_SCRIPT_TEMPLATE, _SCP81_SCRIPT_CR_TAG, _SCP81_NEXT_URI
global _SCP81_LINK_EVENTS, _SCP81_TARGETED_APP, _SCP81_APACHE_HEADERS
global _SCP81_CHUNKED
@@ -1651,34 +1866,47 @@ def _scp81_bip_control(body):
_SCP81_LISTENER = None
_BIP.disable()
if mode == 'tls':
psk_hex = body.get('psk_hex') or _SCP81_PSK.get('psk_hex')
if not psk_hex:
return {'ok': False, 'error': 'psk_hex is required for tls mode'}
try:
psk = bytes.fromhex(re.sub(r'\s', '', psk_hex))
except ValueError:
return {'ok': False, 'error': 'psk_hex is not valid hex'}
if not psk:
return {'ok': False, 'error': 'psk_hex is empty'}
identity = body.get('psk_identity')
if identity is not None:
identity = identity.strip() or None # empty clears the pin
raw_map = body.get('psk_map')
table, err = _parse_psk_map(raw_map)
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:
psk = bytes.fromhex(re.sub(r'\s', '', psk_hex))
except ValueError:
return {'ok': False, 'error': 'psk_hex is not valid hex'}
if not psk:
return {'ok': False, 'error': 'psk_hex is empty'}
identity = body.get('psk_identity')
if identity is not None:
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:
identity = _SCP81_PSK.get('psk_identity')
_SCP81_PSK = {'psk_hex': psk_hex, 'psk_identity': identity}
script = body.get('script', 'explore')
if isinstance(script, list):
_SCP81_SCRIPT = [re.sub(r'\s', '', s) for s in script if s]
_SCP81_SCRIPT_KIND = 'custom'
elif script in _SCP81_SCRIPTS:
_SCP81_SCRIPT = list(_SCP81_SCRIPTS[script])
_SCP81_SCRIPT_KIND = script
else:
return {'ok': False, 'error': 'unknown script preset: %s' % script}
_SCP81_SCRIPT_SENT = 0
_SCP81_SCRIPT_RESULTS = []
_SCP81_SCRIPT_INSERTED = []
_SCP81_PAGES = 0
return {'ok': False, 'error': 'no PSK configured: send psk_map '
'(card presets) or psk_hex'}
script = body.get('script')
if isinstance(script, str):
if script in ('none', ''):
_scp81_reset_script([], 'none')
else:
return {'ok': False, 'error': 'unknown script preset: %s; send an '
'explicit APDU list or none' % script}
elif isinstance(script, list):
_scp81_reset_script(script, body.get('script_kind') or 'custom')
# No 'script' key: keep the configured script and its run progress
# (an explicit list starts a fresh run).
template = body.get('script_template', 'indefinite')
if template not in ('indefinite', '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')
chunk_size = int(cs) if cs not in (None, '') else 0
_SCP81_LISTENER = scp81.PskTlsServer(
host, port, psk, identity=identity,
host, port, psk, identity=identity, psk_map=(table or None),
responder=_scp81_script_responder,
chunked=bool(body.get('chunked', True)),
chunk_size=chunk_size,
@@ -1711,10 +1939,14 @@ def _scp81_bip_control(body):
conn_header=(body.get('conn_header') or 'none'),
answer_delay=(body.get('answer_delay') or 0),
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.enable(host, _SCP81_LISTENER.port)
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,
'targeted_app': _SCP81_TARGETED_APP,
'apache_headers': _SCP81_APACHE_HEADERS,
@@ -2782,10 +3014,7 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp(resp)
elif self.path == '/api/scp81/script':
self._log_req()
resp = {'script': _SCP81_SCRIPT, 'sent': _SCP81_SCRIPT_SENT,
'kind': _SCP81_SCRIPT_KIND,
'template': _SCP81_SCRIPT_TEMPLATE, 'cr_tag': _SCP81_SCRIPT_CR_TAG,
'results': _SCP81_SCRIPT_RESULTS}
resp = _scp81_script_state()
self._send_json(resp)
self._log_resp(resp)
elif self.path.startswith('/api/'):
@@ -3453,14 +3682,57 @@ class PysimHandler(BaseHTTPRequestHandler):
make_selectable = body.get('make_selectable', True)
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 = []
encode_error = None
include_cpi = body.get('includeCpi', True)
spi2_val = int(spi2, 16)
por_in_submit = bool(spi2_val & 0x20)
def _send_gp_apdu(apdu_hex, step_name):
nonlocal cntr
sp_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar, cntr, apdu_hex, kic_key, kid_key)
nonlocal cntr, encode_error
try:
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)
max_chunk = 130
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,
privileges=privileges_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' % (
len(seq), len(seq) - 2, loadfile_aid))
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)
if not _send_gp_apdu(gp_apdu, step_name):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': '%s failed' % step_name,
'load_file_aid': loadfile_aid, 'module_aid': module_aid}
'error': encode_error or ('%s failed' % step_name),
'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._log_resp(resp)
return
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' % (
loadfile_aid, module_aid, cntr))
self._send_json(resp)
@@ -3545,14 +3823,23 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp(err)
elif self.path == '/api/scp81/bip':
body = self._read_body()
# Never log the pre-shared key.
self._log_req(dict(body, psk_hex='<redacted>') if isinstance(body, dict) and body.get('psk_hex') else body)
# Never log the pre-shared keys.
self._log_req(_redact_psk_fields(body))
try:
resp = _scp81_bip_control(body)
except Exception as e:
resp = {'ok': False, 'error': str(e)}
self._send_json(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':
body = self._read_body()
self._log_req(body)
@@ -3572,34 +3859,13 @@ class PysimHandler(BaseHTTPRequestHandler):
'runs on the card next POST (push/trigger)')
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/scp81/ram-install':
elif self.path == '/api/scp81/gen-install':
body = self._read_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:
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
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:
resp = {'ok': False, 'error': 'cap parse failed: %s' % e}
try:
resp = _scp81_gen_install(body)
except Exception as e:
resp = {'ok': False, 'error': str(e)}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/scp81/log-clear':
+66
View File
@@ -26,6 +26,7 @@ from pysim_otaman_server.server import (
_decode_por,
_decode_tr,
_log_proactive,
_max_load_block_size,
_ota_reference,
_record_tr,
_spi_from_bytes,
@@ -176,6 +177,29 @@ class TestOtaReference(unittest.TestCase):
self.assertEqual(out, AES_REFERENCE_VECTORS[('1e', '19')])
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):
def test_plaintext_no_cc_synthetic(self):
@@ -896,3 +920,45 @@ class CapApduSequenceTest(unittest.TestCase):
expected = 'C4' + _ber_len_lower(700) + data # 700 = 0x2BC
self.assertEqual(joined.upper(), expected.upper())
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')
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):
def test_parse_request(self):
@@ -156,8 +165,8 @@ class PskTlsServerTest(unittest.TestCase):
old_bip = server._BIP
server._BIP = mock.Mock()
server._BIP.log = lambda *a, **k: None
server._SCP81_SCRIPT = ['80CAFF2100']
server._SCP81_SCRIPT_SENT = 0
reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_NEXT = 0
server._SCP81_SCRIPT_RESULTS = []
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK,
responder=server._scp81_script_responder,
@@ -182,8 +191,8 @@ class PskTlsServerTest(unittest.TestCase):
tls.close()
finally:
server._BIP = old_bip
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
reset_script()
server._SCP81_SCRIPT_NEXT = 0
server._SCP81_SCRIPT_RESULTS = []
srv.stop()
@@ -244,6 +253,75 @@ class PskTlsServerTest(unittest.TestCase):
finally:
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):
# The live card offers encrypt_then_mac but aborts with
# 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)."""
def setUp(self):
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
reset_script()
server._SCP81_SCRIPT_RESULTS = []
def tearDown(self):
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
reset_script()
server._SCP81_SCRIPT_RESULTS = []
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):
# Use an explicit two-command script, independent of the presets.
server._SCP81_SCRIPT = ['80CAFF2100', '80F22002024F0000']
reset_script(['80CAFF2100', '80F22002024F0000'])
logs = []
old_bip = server._BIP
server._BIP = mock.Mock()
@@ -402,16 +478,142 @@ class ScriptResponderTest(unittest.TestCase):
finally:
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):
def tearDown(self):
server._scp81_bip_control({'action': 'stop'})
server._SCP81_PSK = {}
server._SCP81_PSKS = {}
server._SCP81_PSK_LEGACY = None
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'})
self.assertFalse(resp['ok'])
self.assertIn('psk_map', resp['error'])
self.assertIn('psk_hex', resp['error'])
def test_tls_mode_starts_and_reports_status(self):
@@ -421,17 +623,88 @@ class BipControlTest(unittest.TestCase):
self.assertTrue(resp['ok'], resp)
listener = resp['listener']
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.assertTrue(resp['bip']['enabled'])
# the key never leaves the server
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):
resp = server._scp81_bip_control({'action': 'start', 'mode': 'nope'})
self.assertFalse(resp['ok'])
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):
def _channel(self, cid=1, rx=b'\x16\x03\x03'):
@@ -609,8 +882,8 @@ class ConnHeaderTest(unittest.TestCase):
class TargetedAppTest(unittest.TestCase):
def test_targeted_app_header(self):
server._SCP81_SCRIPT = ['80CAFF2100']
server._SCP81_SCRIPT_SENT = 0
reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_NEXT = 0
server._SCP81_TARGETED_APP = '//aid/A000000151000000'
try:
status, headers, body = server._scp81_script_responder(
@@ -620,12 +893,12 @@ class TargetedAppTest(unittest.TestCase):
'//aid/A000000151000000')
finally:
server._SCP81_TARGETED_APP = None
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
reset_script()
server._SCP81_SCRIPT_NEXT = 0
def test_apache_headers(self):
server._SCP81_SCRIPT = ['80CAFF2100']
server._SCP81_SCRIPT_SENT = 0
reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_NEXT = 0
server._SCP81_APACHE_HEADERS = True
server._SCP81_CHUNKED = False
try:
@@ -640,14 +913,14 @@ class TargetedAppTest(unittest.TestCase):
finally:
server._SCP81_APACHE_HEADERS = False
server._SCP81_CHUNKED = False
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
reset_script()
server._SCP81_SCRIPT_NEXT = 0
def test_chunked_apache_has_no_content_length(self):
# The reference (RAM/HTTPOTA_test5.pcap, decryptable) sends chunked
# without Content-Length, Transfer-Encoding before Content-Type.
server._SCP81_SCRIPT = ['80CAFF2100']
server._SCP81_SCRIPT_SENT = 0
reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_NEXT = 0
server._SCP81_APACHE_HEADERS = True
server._SCP81_CHUNKED = True
try:
@@ -664,8 +937,8 @@ class TargetedAppTest(unittest.TestCase):
finally:
server._SCP81_APACHE_HEADERS = False
server._SCP81_CHUNKED = False
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
reset_script()
server._SCP81_SCRIPT_NEXT = 0
def test_continuation_sets_p2_next_bit(self):
# 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'))
def test_cafe_page_auto_continuation(self):
server._SCP81_SCRIPT = ['80F24002024F0000']
server._SCP81_SCRIPT_SENT = 1
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0
reset_script(['80F24002024F0000'])
server._SCP81_SCRIPT_NEXT = 1
server._SCP81_SCRIPT_PENDING = {'index': 1, 'pos': 0, 'page': False,
'apdu': '80F24002024F0000'}
try:
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE'
body = b'\xAF\x80' + tlv + b'\x00\x00'
status, headers, out = server._scp81_script_responder(
'POST', '/api/scp81?req=1', {'x-admin-script-status': 'ok'}, body)
# The continuation was appended and sent as the next command.
self.assertEqual(server._SCP81_SCRIPT[1], '80F24003024F0000')
# The continuation was queued and sent as the next command.
self.assertEqual(server._SCP81_SCRIPT_BASE, ['80F24002024F0000'])
self.assertEqual(server._SCP81_SCRIPT_PENDING['apdu'],
'80F24003024F0000')
self.assertEqual(status, 200)
self.assertIn(bytes.fromhex('80F24003024F0000'), out)
finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0
reset_script()
def test_standard_more_data_sw_also_pages(self):
# GP Table 11-38: SW '63 10' = more data available, continue with
# GET STATUS [next occurrence] - same handling as the card's 'CA FE'.
server._SCP81_SCRIPT = ['80F2 4002 024F 0000'.replace(' ', '')]
server._SCP81_SCRIPT_SENT = 1
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0
reset_script(['80F2 4002 024F 0000'.replace(' ', '')])
server._SCP81_SCRIPT_NEXT = 1
server._SCP81_SCRIPT_PENDING = {'index': 1, 'pos': 0, 'page': False,
'apdu': '80F24002024F0000'}
try:
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
tlv = bytes([0x23, len(page) + 2]) + page + b'\x63\x10'
body = b'\xAF\x80' + tlv + b'\x00\x00'
server._scp81_script_responder(
'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:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0
reset_script()
def test_repeated_pages_keep_paging(self):
# The continuation is stateful (P2=03): the same APDU legitimately
# repeats until the card answers 9000; the page counter caps it.
server._SCP81_SCRIPT = ['80F24002024F0000']
server._SCP81_SCRIPT_SENT = 1
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0
reset_script(['80F24002024F0000'])
server._SCP81_SCRIPT_NEXT = 1
server._SCP81_SCRIPT_PENDING = {'index': 1, 'pos': 0, 'page': False,
'apdu': '80F24002024F0000'}
try:
page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100')
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)
self.assertEqual(server._SCP81_PAGES, 3)
finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0
reset_script()
def test_new_session_drops_inserted_pages(self):
server._SCP81_SCRIPT = ['80F24002024F0000', '80F24003024F0000']
server._SCP81_SCRIPT_INSERTED = ['80F24003024F0000']
server._SCP81_SCRIPT_SENT = 2
def test_resumed_dialog_keeps_pages_fresh_dialog_resets(self):
# The continuation pages belong to one run: a resumed dialog keeps
# them, a fresh dialog starts the script over.
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:
server._scp81_script_responder('POST', '/api/scp81', {}, b'')
self.assertEqual(server._SCP81_SCRIPT, ['80F24002024F0000'])
self.assertEqual(server._SCP81_SCRIPT_SENT, 1)
# Resumed dialog: the queued page is sent as the next command.
server._scp81_script_responder(
'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:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_INSERTED = []
reset_script()
def test_exact_wire_bodies_from_reference_log(self):
# De-chunked bodies captured in adminserver.log (2019-09-05).
@@ -772,37 +1044,46 @@ class TargetedAppTest(unittest.TestCase):
class QueueScriptTest(unittest.TestCase):
def test_queue_replaces_and_resets(self):
server._SCP81_SCRIPT = ['80CAFF2100']
server._SCP81_SCRIPT_SENT = 1
server._SCP81_SCRIPT_RESULTS = [{'index': 1, 'sw': '9000', 'apdu': '80CAFF2100', 'rapdu': ''}]
reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_NEXT = 1
server._SCP81_SCRIPT_RESULTS = [{'index': 1, 'sw': '9000',
'apdu': '80CAFF2100', 'rapdu': ''}]
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')
self.assertTrue(r['queued'])
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(len(server._SCP81_SCRIPT), 2)
self.assertEqual(len(server._SCP81_SCRIPT_BASE), 2)
finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_KIND = 'explore'
reset_script()
def test_queue_refuses_while_running(self):
server._SCP81_SCRIPT = ['80CAFF2100', '80F28002024F0000']
server._SCP81_SCRIPT_SENT = 1
reset_script(['80CAFF2100', '80F28002024F0000'])
server._SCP81_SCRIPT_NEXT = 1
server._SCP81_SCRIPT_PENDING = {'index': 1, 'pos': 0, 'page': False,
'apdu': '80CAFF2100'}
try:
r = server._scp81_queue_script(['80E60200'], kind='ram-install')
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)
self.assertTrue(r['queued'])
self.assertEqual(server._SCP81_SCRIPT, ['80E60200'])
self.assertEqual(server._SCP81_SCRIPT_BASE, ['80E60200'])
finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_KIND = 'explore'
reset_script()
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):
@@ -830,8 +1111,8 @@ class ScriptBodyLengthTest(unittest.TestCase):
class VerbatimScriptTest(unittest.TestCase):
def test_expanded_templates_sent_verbatim(self):
server._SCP81_SCRIPT = ['AA0B2208 80CAFF2100'.replace(' ', '')]
server._SCP81_SCRIPT_SENT = 0
reset_script(['AA0B2208 80CAFF2100'.replace(' ', '')])
server._SCP81_SCRIPT_NEXT = 0
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_INSERTED = []
server._SCP81_PAGES = 0
@@ -842,19 +1123,19 @@ class VerbatimScriptTest(unittest.TestCase):
# Sent as-is (no AE80/22 wrapper added)
self.assertEqual(body.hex().upper(), 'AA0B220880CAFF2100')
finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
reset_script()
server._SCP81_SCRIPT_NEXT = 0
def test_plain_apdu_still_wrapped(self):
server._SCP81_SCRIPT = ['80CAFF2100']
server._SCP81_SCRIPT_SENT = 0
reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_NEXT = 0
try:
status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'')
self.assertEqual(body.hex().upper(), 'AE80220580CAFF21000000')
finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
reset_script()
server._SCP81_SCRIPT_NEXT = 0
class ResponseTlvLengthTest(unittest.TestCase):