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:
+99
-38
@@ -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, 1–240. 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
|
||||
|
||||
@@ -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
@@ -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>«Парсер ответов»</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Карты</strong>, <strong>RAM</strong>), <strong>«Профайлер»</strong> (вкладки <strong>«Профили»</strong>, <strong>«Снимки карт»</strong>, <strong>«Пользовательские файлы»</strong>), <strong>«Картридер»</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>«Симулятор телефона»</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>«Парсер ответов»</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>RAM</strong>), <strong>SCP81</strong> (<strong>«Слушатель»</strong>, <strong>«Скрипты»</strong>), <strong>«Карты»</strong>, <strong>«Профайлер»</strong> (вкладки <strong>«Профили»</strong>, <strong>«Снимки карт»</strong>, <strong>«Пользовательские файлы»</strong>), <strong>«Картридер»</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>«Симулятор телефона»</strong>.</li>
|
||||
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка «Профайлер» открывает §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>«Парсер ответов»</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-ключи. Вкладка «Карты» — верхнеуровневая.</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] → LOAD ×N → 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] → LOAD ×N → 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>Копия <профиль></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> — загружает снимок из JSON-файла.</li>
|
||||
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
|
||||
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты: фактическая сторона подписана именем снимка (<em>фактически (имя снимка)</em>), а в заголовке — <em>Результаты проверки профиля: <профиль> → <имя снимка></em>; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
|
||||
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт; в этом отчёте заголовок — <em>Результаты сравнения снимков: <эталон> → <проверяемый></em>, а поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
|
||||
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля, но <strong>всегда точно</strong> (маскирование содержимого не применяется): выберите <em>эталонный</em> снимок и <em>снимок для проверки</em> и получите такой же отчёт; в этом отчёте заголовок — <em>Результаты сравнения снимков: <эталон> → <проверяемый></em>, а поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
|
||||
</ul>
|
||||
|
||||
</section>
|
||||
|
||||
+10
-8
@@ -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 §5).</li>
|
||||
</ul>
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
<p class="text-sm mb-3">A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A “Send to Card” button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command’s status word and response data are filled into the <strong>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 & 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 & 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] → LOAD ×N → 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] → LOAD ×N → 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 <profile></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’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> — 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: <profile> → <snapshot name></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: <master> → <checked></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: <master> → <checked></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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v155';
|
||||
const CACHE = 'otaman-v161';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -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"'));
|
||||
|
||||
@@ -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')]);
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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':
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user