Compare commits
35 Commits
dfb9551eb7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 59630623e9 | |||
| dd7eecb279 | |||
| b0b8d02fe7 | |||
| 0698c9e90c | |||
| c734b46731 | |||
| 8e68673509 | |||
| aa33d25466 | |||
| 20fbfd99a5 | |||
| 8c90958718 | |||
| ef8b651f28 | |||
| f6c1dd0751 | |||
| e957757fda | |||
| c341571300 | |||
| 3403abd6b9 | |||
| 5e3fac260b | |||
| b811906751 | |||
| e32a6e17e4 | |||
| ff7a1ad5d3 | |||
| 92f646df08 | |||
| f411f12c56 | |||
| 4dd09740b7 | |||
| 3463a68292 | |||
| e255677dd9 | |||
| a352d8fe3f | |||
| 175ca934d8 | |||
| f07ccc5229 | |||
| ea1730b206 | |||
| c9ae494f62 | |||
| 0370fa58ad | |||
| 2213e25385 | |||
| bf8abbb0fd | |||
| a9a4d03b09 | |||
| fb6f80ef1d | |||
| 2494e04984 | |||
| b49e6728b4 |
@@ -0,0 +1 @@
|
||||
*.html linguist-detectable=false
|
||||
@@ -31,7 +31,7 @@ npm run build
|
||||
|
||||
## Interface
|
||||
|
||||
Five top-level tabs: **Remote APDU**, **SCP80**, **Profiler**, **Card reader**, and **Phone simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the Card reader tab has three sub-tabs: **File manager**, **pySim command line**, and **Raw APDU**; the Profiler tab lists **Profiles**, **Card snapshots**, and **Custom files**.
|
||||
Six top-level tabs: **Remote APDU**, **SCP80**, **SCP81**, **Profiler**, **Card reader**, and **Phone simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the Card reader tab has three sub-tabs: **File manager**, **pySim command line**, and **Raw APDU**; the Profiler tab lists **Profiles**, **Card snapshots**, and **Custom files**.
|
||||
|
||||
---
|
||||
|
||||
@@ -504,7 +504,7 @@ Type a command name in the **pySim command line** input. Usage hints appear as a
|
||||
|
||||
Verifies that a card matches a named **profile** — an ordered set of rules describing the expected file system and, optionally, file contents. Profiles are stored in `localStorage`.
|
||||
|
||||
- **New profile** creates an empty ruleset; **Profile from card** scans the equipped card and generates one rule per existing file; **Import profile** loads a ruleset from JSON (the name is stored inside the file).
|
||||
- **New profile** creates an empty ruleset; **Profile from card** scans the equipped card and generates one rule per existing file; **Profile from snapshot** generates the same ruleset from a saved snapshot (same ignore/mask/FCP-FCI options, no card reader, name prefilled from the snapshot); **Import profile** loads a ruleset from JSON (the name is stored inside the file).
|
||||
- Each profile row has **Check card ▶** (run against the equipped card), **Check card snapshot** (run offline against a saved snapshot), **Edit**, **Export**, and **Delete**.
|
||||
|
||||
A filesystem rule is defined by:
|
||||
@@ -514,7 +514,7 @@ A filesystem rule is defined by:
|
||||
- **File attributes** — file type, size, record length and record count, taken from the FCP template (any may be left unset).
|
||||
- **Check contents** (optional) — **Exact** hex equality, or **Mask** where `?` is a per-nibble wildcard (a mask with no `?` is a prefix match, e.g. `0891` for the IMSI MCC/MNC). Record files store a per-record list.
|
||||
|
||||
The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓*), lists mismatches as read-only monospace expected/actual fields aligned in one column, and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. **Only mismatches** in the results header hides all passing files and keeps failures and errors only.
|
||||
The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓*), lists mismatches as read-only monospace expected/actual fields aligned in one column, and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. In the report the mismatch fields and FCI comparison columns are labelled `expected (profile name)` and `actual (card ICCID)` for a live check, or `actual (snapshot name)` for a snapshot check; the results header reads `Profile verification results for: <profile> → <card ICCID>` (or `… → <snapshot name>`; snapshot comparison: `Snapshot comparison results: <master> → <checked>`). **Only mismatches** in the results header hides all passing files and keeps failures and errors only.
|
||||
|
||||
#### “Profile from card” scan options
|
||||
|
||||
@@ -556,12 +556,16 @@ The **Phone simulator** tab provides real-time CAT session interaction. It has t
|
||||
- **Location Status** — dropdown for Normal / Limited / No service
|
||||
- **Access Technology Change** — dropdown for all 13 RAT types
|
||||
- **Card Reader Status, Language, UICC Access** — appropriate inputs
|
||||
- **Channel Status** — channel selector, link state (not established / TCP
|
||||
LISTEN / established) and info (no further info / link dropped), per TS 102 223 8.56
|
||||
- **Network Rejection** — full adaptive form with registration type dropdown
|
||||
(LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access
|
||||
technology selection, and 53-cause unified rejection cause code dropdown
|
||||
covering EMM, GMM, 5GMM, and LU causes
|
||||
|
||||
**Proactive Command Log** — chronological list of proactive commands encountered (seconds elapsed, type code, name, byte count). Covers SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, and PROVIDE LOCAL INFORMATION.
|
||||
**Proactive Command Log** — chronological list of proactive commands encountered (seconds elapsed, type code, name, byte count). Covers SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, PROVIDE LOCAL INFORMATION, TIMER MANAGEMENT, and the BIP commands (OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA, GET CHANNEL STATUS); BIP commands are decoded with both plain and comprehension-required TLV tags.
|
||||
|
||||
**Timer management** — the server acts as the terminal for TIMER MANAGEMENT (TS 102 223 §6.6.21/§7.4): started timers are tracked per card session, deactivate/get TERMINAL RESPONSEs carry the remaining value, and on expiry the card receives ENVELOPE (TIMER EXPIRATION). The live card uses this to retry the OTA session after a failed OPEN CHANNEL.
|
||||
|
||||
**TR Config: PLI data dictionary** — editable per-qualifier hex values for all 22 PROVIDE LOCAL INFORMATION qualifiers (TS 102 223 + TS 131 111). 10 qualifiers have inline decode/encode forms (toggle):
|
||||
|
||||
@@ -580,6 +584,16 @@ The **Phone simulator** tab provides real-time CAT session interaction. It has t
|
||||
|
||||
Values persist on the server until restart. Apply → hex updates; Save → POSTs to server. The server will use these values to populate TERMINAL RESPONSE data for future PLI proactive commands.
|
||||
|
||||
## SCP81
|
||||
|
||||
The **SCP81** tab drives HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B). The card's BIP channel is always redirected to a local listener on the server:
|
||||
|
||||
- **Capture (dump)** — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering. Use it to inspect what the card asks for.
|
||||
- **PSK TLS server** — answers the handshake with the TLS 1.2 PSK cipher suites of the spec and speaks the GP HTTP administration dialog (`X-Admin-*` headers, `200` with a command string or `204 No Content`). Enter the **PSK Identity** the card uses and the **PSK key (hex)**; the key is only sent to the local server, never stored or logged.
|
||||
- **Script** — the command script served over the session: **Memory + ELF info** (default) sends `GET DATA FF21` (available non-volatile/volatile memory, applet count) and `GET STATUS P1=20/10` (Executable Load Files and modules registry) as RAM/GP commands in TS 102 226 Command Scripting templates, one C-APDU per request; **None** closes every session with `204`. Custom APDU lists are accepted by the API.
|
||||
|
||||
The state line shows the listener, the negotiated identity and live channels (bytes in/out); the log records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step, including each R-APDU (`script-rapdu`, `script-memory`). The same controls are available through `POST /api/scp81/bip` and `GET /api/scp81/script` (see `docs/api.md`).
|
||||
|
||||
## PWA
|
||||
|
||||
OTAMan is a Progressive Web App and can be installed for offline use. Use the **INSTALL PWA** button in the header, or use the browser's install prompt.
|
||||
@@ -637,7 +651,7 @@ pysim-otaman-server --http-port 8080
|
||||
| `--apdu-trace` | Log APDU-level traces to stderr |
|
||||
| `--log-requests` | Log request/response payloads to stderr |
|
||||
| `--sms-oa` / `--sms-sm-sc` | SMS-DELIVER originating address / SM-SC for PoR-in-submit |
|
||||
| `--terminal-profile` | TERMINAL PROFILE payload hex (default 10-byte GSM profile) |
|
||||
| `--terminal-profile` | TERMINAL PROFILE payload hex (default: 33-byte real-handset profile that advertises BIP events/commands; the live card ignores HTTP OTA without it) |
|
||||
| `--poll-interval` | Idle interval before automatic STATUS polling (default 30s; `0` disables polling) |
|
||||
| `--full-pysim-init` | Use pysim's stock init/equip (redundant card resets). The default init/equip is reset-free — only explicit equip/reset reconnect the card |
|
||||
| `--no-auto-equip` | Do not initialize a card automatically right after it is inserted (default: auto-equip on) |
|
||||
|
||||
+18
-4
@@ -31,7 +31,7 @@ npm run build
|
||||
|
||||
## Интерфейс
|
||||
|
||||
Пять вкладок: **Remote APDU**, **SCP80**, **Profiler**, **Card reader** и **Phone simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
|
||||
Шесть вкладок: **Remote APDU**, **SCP80**, **SCP81**, **Profiler**, **Card reader** и **Phone simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
|
||||
|
||||
---
|
||||
|
||||
@@ -478,7 +478,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
Проверка соответствия карты именованному **профилю** — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в `localStorage`.
|
||||
|
||||
- **Новый профиль** создаёт пустой набор правил; **Профиль с карты** сканирует подключённую карту и создаёт по правилу на каждый существующий файл; **Импорт профиля** загружает набор из JSON (имя хранится внутри файла).
|
||||
- **Новый профиль** создаёт пустой набор правил; **Профиль с карты** сканирует подключённую карту и создаёт по правилу на каждый существующий файл; **Профиль из снимка** создаёт тот же набор правил из сохранённого снимка (те же опции игнорирования/масок/FCP-FCI, без картридера, имя подставляется из снимка); **Импорт профиля** загружает набор из JSON (имя хранится внутри файла).
|
||||
- В каждой строке профиля: **Проверить карту ▶** (на подключённой карте), **Проверить снимок карты** (offline по сохранённому снимку), **Редактировать**, **Экспорт** и **Удалить**.
|
||||
|
||||
Правило файловой системы задаётся:
|
||||
@@ -488,7 +488,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
- **Атрибуты файла** — тип, размер, длина и число записей из шаблона FCP (любое можно не задавать).
|
||||
- **Проверка содержимого** (опционально) — **Exact** (точное равенство hex) или **Mask**, где `?` — пониббловый джокер (маска без `?` — префиксное совпадение, напр. `0891` для MCC/MNC IMSI). Для record-файлов хранится список записей.
|
||||
|
||||
Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки.
|
||||
Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. В отчёте поля расхождений и колонки сравнения FCI подписаны `ожидалось (имя профиля)` и `фактически (ICCID карты)` для проверки карты либо `фактически (имя снимка)` для проверки снимка; в заголовке отчёта — `Результаты проверки профиля: <профиль> → <ICCID карты>` (или `… → <имя снимка>`; для сравнения снимков — `Результаты сравнения снимков: <эталон> → <проверяемый>`). Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки.
|
||||
|
||||
#### Опции сканирования «Профиль с карты»
|
||||
|
||||
@@ -529,9 +529,13 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
- **События без данных** (User Activity, Idle Screen и др.) — однократное уведомление
|
||||
- **Location Status** — выпадающий список: Normal / Limited / No service
|
||||
- **Access Technology Change** — 13 типов RAT
|
||||
- **Channel Status** — выбор канала, состояние линии (не установлена / TCP
|
||||
LISTEN / установлена) и информация (нет данных / линия разорвана), TS 102 223 8.56
|
||||
- **Network Rejection** — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля локации (MCC, MNC, LAC, RAC, TAC), доступные технологии, 53-позиционный выпадающий список причин отказа (EMM, GMM, 5GMM, LU)
|
||||
|
||||
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор.
|
||||
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор. Поддерживаются SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, PROVIDE LOCAL INFORMATION, TIMER MANAGEMENT и BIP-команды (OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA, GET CHANNEL STATUS); BIP-команды декодируются с обычными и comprehension-required TLV-тегами.
|
||||
|
||||
**Управление таймерами** — сервер выполняет роль терминала для TIMER MANAGEMENT (TS 102 223 §6.6.21/§7.4): запущенные картой таймеры отслеживаются в рамках сессии, TERMINAL RESPONSE на deactivate/get содержит остаток, а по истечении карта получает ENVELOPE (TIMER EXPIRATION). Живая карта использует это для повторения OTA-сессии после неудачного OPEN CHANNEL.
|
||||
|
||||
**Конфигурация TR: словарь PLI** — редактируемые hex-значения для всех 22 квалификаторов PROVIDE LOCAL INFORMATION (TS 102 223 + TS 131 111). 10 квалификаторов имеют встроенные формы декодирования/кодирования:
|
||||
|
||||
@@ -550,6 +554,16 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
Значения сохраняются на сервере до перезапуска. Apply → hex обновляется; Save → POST на сервер.
|
||||
|
||||
## SCP81
|
||||
|
||||
Вкладка **SCP81** управляет HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B). BIP-канал карты всегда перенаправляется на локальный слушатель сервера:
|
||||
|
||||
- **Capture (dump)** — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая. Удобно для изучения запросов карты.
|
||||
- **PSK TLS server** — отвечает на рукопожатие PSK-наборами TLS 1.2 из спецификации и ведёт HTTP-диалог административной сессии GP (заголовки `X-Admin-*`, `200` со строкой команд или `204 No Content`). Укажите **PSK Identity**, которую использует карта, и **PSK ключ (hex)**; ключ передаётся только локальному серверу, не сохраняется и не записывается в журнал.
|
||||
- **Script** — сценарий команд, отдаваемых в сессии: **Память + ELF** (по умолчанию) отправляет `GET DATA FF21` (доступная энергонезависимая/энергозависимая память, число апплетов) и `GET STATUS P1=20/10` (реестр Executable Load File и модулей) как RAM/GP-команды в Command Scripting template по TS 102 226, по одной C-APDU на запрос; **None** закрывает каждую сессию ответом `204`. Свой список APDU можно задать через API.
|
||||
|
||||
Строка состояния показывает слушатель, согласованную identity и активные каналы (байты in/out); журнал фиксирует OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA и каждый шаг TLS/HTTP. Те же функции доступны через `POST /api/scp81/bip` (см. `docs/api.md`).
|
||||
|
||||
## PWA
|
||||
|
||||
OTAMan — Progressive Web App. Можно установить для offline-использования через кнопку **INSTALL PWA** или через браузер.
|
||||
|
||||
+229
-4
@@ -41,11 +41,21 @@ connect and warns if versions are incompatible.
|
||||
| `/api/proactive-log` | GET | Last 50 proactive commands |
|
||||
| `/api/status-poll` | POST | Manual STATUS poll + FETCH if 91XX |
|
||||
| `/api/rescue` | POST | Re-send TERMINAL PROFILE to recover CAT session |
|
||||
| `/api/terminal-profile` | GET | Current TERMINAL PROFILE (hex) + CLI default |
|
||||
| `/api/terminal-profile` | POST | Set and re-send the TERMINAL PROFILE at runtime (in-memory) |
|
||||
| `/api/poll-status` | GET | Background STATUS polling state |
|
||||
| `/api/poll-toggle` | POST | Enable/disable background polling |
|
||||
| `/api/pli-qualifiers` | GET | List of qualifier codes with descriptions |
|
||||
| `/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 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
|
||||
|
||||
@@ -55,7 +65,7 @@ Returns server version for compatibility checking.
|
||||
|
||||
**Example response:**
|
||||
```json
|
||||
{"version": "2.1.1"}
|
||||
{"version": "2.1.2"}
|
||||
```
|
||||
|
||||
### `GET /api/status`
|
||||
@@ -172,6 +182,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
|
||||
@@ -182,9 +193,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",
|
||||
@@ -378,6 +397,13 @@ optional hex for events that carry data. Returns the SW and any response data:
|
||||
{"sw": "9000", "data": "..."}
|
||||
```
|
||||
|
||||
Channel status (event `0x0A`, TS 102 223 §8.56) carries the Channel status TLV
|
||||
`B8 02 <status> <info>`, where the status byte is the channel id (1–7) OR-ed
|
||||
with the state bits (0x00 link not established / 0x40 TCP LISTEN / 0x80 link
|
||||
established) and the info byte is `00` (no further info) or `05` (link
|
||||
dropped). The server also sends this event automatically when a BIP link drops
|
||||
outside a proactive command and the card subscribed to `0x0A`.
|
||||
|
||||
### `GET /api/proactive-log`
|
||||
|
||||
Returns the last 50 proactive commands fetched during CAT sessions, newest
|
||||
@@ -399,12 +425,37 @@ proactive chain (FETCH → TERMINAL RESPONSE) until it settles. Returns:
|
||||
### `POST /api/rescue`
|
||||
|
||||
Recovers a stuck CAT session by clearing the pending state and re-sending the
|
||||
TERMINAL PROFILE. Returns whether a menu and event list were captured again:
|
||||
TERMINAL PROFILE. Returns whether a menu and event list were captured again
|
||||
(plus the profile used):
|
||||
|
||||
```json
|
||||
{"menu": true, "events": [4, 5]}
|
||||
{"ok": true, "profile": "FFFF...", "menu": true, "events": [4, 5]}
|
||||
```
|
||||
|
||||
### `GET /api/terminal-profile`
|
||||
|
||||
The TERMINAL PROFILE currently in effect and the CLI default (for reference;
|
||||
runtime changes are in-memory only):
|
||||
|
||||
```json
|
||||
{"profile": "FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003",
|
||||
"bytes": 33,
|
||||
"cli_default": "FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003"}
|
||||
```
|
||||
|
||||
### `POST /api/terminal-profile`
|
||||
|
||||
Sets the TERMINAL PROFILE at runtime (in-memory) and re-sends it to the card,
|
||||
resetting the STK session state exactly like `/api/rescue`. Body with a new
|
||||
profile, or `{}` to re-send the current one:
|
||||
|
||||
```json
|
||||
{"profile": "FFFFFFFF7F1F007FFF00001F230811060700"}
|
||||
```
|
||||
|
||||
Hex, even number of digits, 1–255 bytes. Response is the same shape as
|
||||
`/api/rescue` (with `ok: true`); invalid hex is a 400, no reader a 503.
|
||||
|
||||
### `GET /api/poll-status`
|
||||
|
||||
Background STATUS polling state.
|
||||
@@ -444,3 +495,177 @@ Returns the current PLI data dictionary as a qualifier-code map.
|
||||
Updates dictionary entries. Body is a map of qualifier code to hex value; keys
|
||||
must be known qualifiers and values valid hex, otherwise they are ignored.
|
||||
Returns the updated dictionary.
|
||||
|
||||
### `POST /api/scp81/bip`
|
||||
|
||||
Starts or stops the local target the card's BIP channel is redirected to.
|
||||
|
||||
Dump mode captures whatever the card sends (e.g. its TLS ClientHello)
|
||||
without answering:
|
||||
|
||||
```json
|
||||
{"action": "start", "mode": "dump", "host": "127.0.0.1", "port": 8443}
|
||||
```
|
||||
|
||||
Pass-through mode (`mode: "passthru"`) starts **no local listener**: every BIP
|
||||
channel the card opens is connected to the configured external platform
|
||||
(`host`/`port` are required — no defaults), which terminates TLS and runs the
|
||||
administration dialog; the address the card requests is only logged. The
|
||||
status API reports `mode: "passthru"` with the target while it runs.
|
||||
|
||||
```json
|
||||
{"action": "start", "mode": "passthru", "host": "203.0.113.10", "port": 10174}
|
||||
```
|
||||
|
||||
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_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`
|
||||
|
||||
```json
|
||||
{"bip": {"enabled": true, "target": "127.0.0.1:8443", "channels": [], "seq": 12},
|
||||
"listener": {"mode": "tls", "host": "127.0.0.1", "port": 8443,
|
||||
"psk_identities": ["89012345678901234567"], "psk_wildcard": false,
|
||||
"identity_seen": "89012345678901234567", "identity_matched": true}}
|
||||
```
|
||||
|
||||
Listener modes: `tls` (local PSK TLS server), `dump` (capture-only TCP
|
||||
listener) and `passthru` (no local listener; the BIP channels go straight to
|
||||
`host:port`, e.g. an external HTTP OTA platform — reported as
|
||||
`{"mode": "passthru", "host": ..., "port": ..., "target": "host:port"}`).
|
||||
|
||||
`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
|
||||
handshake and HTTP request/response records). `?after=<seq>` returns only
|
||||
newer entries; `seq` echoes the latest sequence number.
|
||||
|
||||
### `POST /api/scp81/queue`
|
||||
|
||||
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/gen-install`
|
||||
|
||||
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}
|
||||
```
|
||||
|
||||
`sd_aid` empty = the ISD. Responds with `{"ok": true, "apdus": [...],
|
||||
"load_file_aid": ..., "module_aid": ...}`.
|
||||
|
||||
### `GET /api/scp81/script`
|
||||
|
||||
Returns the configured command script and the execution state:
|
||||
|
||||
```json
|
||||
{"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..."}]}
|
||||
```
|
||||
|
||||
`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`). 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
|
||||
Content-Length) and `chunk_size` (default `0` — the whole response in one TLS
|
||||
record, as in the decrypted reference session; a positive value writes the
|
||||
head and each body piece as its own record). Both are echoed by
|
||||
`GET /api/scp81/status`.
|
||||
|
||||
`keep_alive` (default `true`, matching the reference session: the card sends
|
||||
all its POSTs on one connection until the 204) ends the TLS connection after
|
||||
each response
|
||||
(after the card drained the BIP buffer, with `close_notify`, so the card
|
||||
processes the script and opens a new connection for its next POST);
|
||||
`compact_headers` (default `false`) drops the space after each header colon,
|
||||
`apache_headers` (default `true`) adds Date/Server/X-Powered-By like the
|
||||
reference servers and puts Transfer-Encoding before Content-Type,
|
||||
`conn_header` (default `'none'` = omit the header, like the reference)
|
||||
declares the connection fate, `tls_version` pins `1.1`/`1.0` for cards that
|
||||
only speak the older record layer, `cipher` pins one suite, `next_uri`
|
||||
overrides the per-command `X-Admin-Next-URI` (`%d` = command id; empty string
|
||||
omits the header), `link_events` (default `true`) controls the automatic
|
||||
Channel status events, `answer_delay` waits before answering a request. `keylog` writes the TLS traffic secrets to
|
||||
the given file (SSLKEYLOGFILE format) for debugging captures — it contains key
|
||||
material, use a temporary path.
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
# SCP81 / HTTP OTA live-card findings
|
||||
|
||||
Living debug log for the HTTP OTA (RAM over HTTP) work against the live UICC.
|
||||
Purpose: record **every attempted configuration and its outcome**, so the same
|
||||
variations are not repeated. Add rows as tests are run; keep the confirmed
|
||||
rules section current.
|
||||
|
||||
Setup: `pysim_otaman_server` with a PC/SC reader, the PWA SCP81 tab (or
|
||||
`POST /api/scp81/bip`), the card triggered by its SMS-PP push / the Location
|
||||
status event. Server log at `GET /api/scp81/log`, script state at
|
||||
`GET /api/scp81/script`, proactive history at `GET /api/proactive-log`.
|
||||
|
||||
## RESOLVED 2026-09-16: the card never received the response - BIP TLV bug
|
||||
|
||||
**Root cause:** our RECEIVE DATA TERMINAL RESPONSE encoded the channel-data
|
||||
TLV length as a raw byte (`36 ED ...` for a 237-byte chunk). BER requires the
|
||||
long form for lengths >127: **`36 81 ED ...`** (the reference terminal traces
|
||||
use exactly that, e.g. `push_3311_success_req2.pcapng`). The card's BIP layer
|
||||
silently mis-parsed the malformed TLV, so the TLS record bytes never reached
|
||||
its TLS stack: no alert, no script processing, and the SD kept resuming its
|
||||
dialog ("no complete script received") forever. Every delivery <=127 bytes
|
||||
(handshake records, 204 responses) always worked - which is why the handshake
|
||||
succeeded and only the large script responses "vanished".
|
||||
|
||||
**Fix:** `_handle_bip_command` (cmd 0x42) BER-encodes the channel data length
|
||||
(`36 81 <len>` above 127); regression test
|
||||
`test_receive_data_tlv_long_form_length`.
|
||||
|
||||
**Result with the live card** (one push, `explore` script, 5/5 commands):
|
||||
|
||||
```
|
||||
#1 80CAFF2100 SW 9000 FF210B 81010D 8202C5D6 83020962 (13 applets,
|
||||
free NV 50646 B, free volatile 2402 B)
|
||||
#2 80F28002024F0000 SW 9000 ISD A000000003000000 + D276000005AAFFCAFE00
|
||||
#3 80CA008500 SW 9000 stored HTTP OTA parameters
|
||||
#4 80F24002024F0000 SW CAFE 127-byte ELF registry page (more available)
|
||||
#5 80F21002024F0000 SW CAFE 127-byte applications page (more available)
|
||||
```
|
||||
|
||||
Every command returned `X-Admin-Script-Status: ok` on the card's own POST to
|
||||
the incremented `X-Admin-Next-URI`, on the same keep-alive connection, and the
|
||||
session ended with 204 + mutual close_notify - exactly the reference flow.
|
||||
`SW CAFE` marks a truncated 127-byte page: the remaining entries need a
|
||||
continuation GET STATUS (P2=02 with the last AID as search criterion).
|
||||
|
||||
## Live card facts (verified via the reader, 2026-09-16)
|
||||
|
||||
- `80CAFF2100` (GET DATA extended card resources) **works**:
|
||||
`FF21 0B 81 01 0D 82 02 C5 D6 83 02 09 62` -> 13 applets installed,
|
||||
free NV memory `0xC5D6` = 50646 B, free volatile `0x0962` = 2402 B.
|
||||
- `80CA008500` (GET DATA HTTP administration parameters) **works** and returns
|
||||
the SD's stored OTA configuration: `8A 09 "localhost"`, `8B 14 <agent id>`,
|
||||
`8C 01 "/"` (stored URI), `85 14 <PSK identity>`, `86 07 00 01 25 03 00 10 00`
|
||||
(retry counter 1, timer **10 minutes**), `02 40 01` (KVN/KID), APN-ish
|
||||
`C7 04 03 47 50 42`, destination `BE 05 21 5B D5 05 02` = 91.213.5.2.
|
||||
- `80F28002/80F24002/80F21002 ...4F0000` return `6985` through the reader when
|
||||
the ISD is not the current DF; the reference platform sends
|
||||
`80F28002024F0000` over HTTP, where the SD executes inside the ISD.
|
||||
- `SELECT` of the ISD (`00A4040008A000000003000000`) returns `6112`;
|
||||
a subsequent GET RESPONSE (`00C0000012`) returns `6D00`.
|
||||
- BIP device identities: OPEN CHANNEL uses destination `0x82`; SEND/RECEIVE
|
||||
DATA carry channel `0x21..0x27` (e.g. `82 02 81 22` = channel 2).
|
||||
- Subscribed events (`99 03`): `03` location status, `09` data available,
|
||||
`0A` channel status.
|
||||
- A Location status event re-triggers the OTA session only while the last
|
||||
session is incomplete; after a clean session end the card waits for a push.
|
||||
- The SD stores a 10-minute retry timer (`25 03 00 10 00`).
|
||||
|
||||
## Confirmed rules (with evidence)
|
||||
|
||||
1. **The card needs a clean TLS close, with the close_notify actually
|
||||
fetched.** Keep-alive (no close) -> fatal `unexpected_message` after it
|
||||
fetched the response. `close_notify` sent *after* the buffer drained is
|
||||
never fetched (the card ends the dialog on its own first). Correct order:
|
||||
send it while the response still waits, then wait for the drain, then
|
||||
close.
|
||||
2. **The card's abort alert is `fatal unexpected_message`** - decrypted with
|
||||
the listener's `keylog` option (see `tools/scp81_decrypt.py`).
|
||||
3. **A dropped link must be signalled (TS 102 223 7.5.11), and only after the
|
||||
buffered data was fetched.** Signalling the drop while bytes are still in
|
||||
the BIP buffer makes the card abort the fetch mid-record and end the
|
||||
session. Omitting the signal entirely hangs the SD: after a listener
|
||||
restart dropped the channel silently, the card ignored pushes and location
|
||||
events for minutes; a manual `ENVELOPE (Channel status, B8 02 02 05)`
|
||||
immediately made it start a fresh session.
|
||||
4. **The Next-URI shape matters.** A path-only or absolute Next-URI (`/`,
|
||||
`/1`, `http://127.0.0.1:8443/api/scp81`) draws the fatal
|
||||
`unexpected_message`; the reference-style relative path **with a query**
|
||||
(`/adminserver?PHPSESSID=...&apdu_id=101`) does not.
|
||||
5. **The reference administration server** (`samples/HTTP_OTA/
|
||||
httpota_adminserver_php_v2`) uses: command script
|
||||
`AE 80 22 <len> <apdu> 00 00`; response `200` with
|
||||
`X-Admin-Protocol`, `X-Admin-Next-URI: /adminserver?PHPSESSID=<id>&apdu_id=<n>`,
|
||||
`Content-Type: ...;version=1.0`, **chunked** body (100-byte chunks);
|
||||
the card returns the R-APDU as the body of its next POST with
|
||||
`X-Admin-Script-Status: ok`; the server ends with `204`.
|
||||
Its log proves the card followed the Next-URI three times within 1-2 s per
|
||||
step (`Got next request ... Script status is 'ok' - storing R-APDU data`).
|
||||
6. **`chunked=false` (Content-Length) has never produced an R-APDU.** All
|
||||
sessions that ended silently (clean close, no alert, no POST) used
|
||||
`Content-Length`. Hypothesis: the card only treats a chunked body as a
|
||||
command script; with Content-Length it sees an empty script, executes
|
||||
nothing and ends the session gracefully.
|
||||
|
||||
## The one fully successful session trace (ground truth)
|
||||
|
||||
`traces/HTTPOTA_session_3311_success1.pcap` (2019, **plain HTTP on port 80**,
|
||||
one TCP connection for the whole session, card `3311` - *not* our UICC):
|
||||
|
||||
```
|
||||
POST /server/adminagent?cmd=1 <- card (trigger URI, with query!)
|
||||
200 OK + Date/Server + X-Admin-Protocol
|
||||
+ X-Admin-Next-URI: /Download?req=1 + Content-Length: 11
|
||||
+ Content-Type: .../card-content-mgt;version=1.0
|
||||
body: ae 80 22 05 80 ca 00 85 00 00 00 (script: GET DATA 0085)
|
||||
POST /Download?req=1 <- card, SAME connection
|
||||
X-Admin-Script-Status: ok
|
||||
Content-Type: .../card-content-mgt-response;version=1.0
|
||||
Transfer-Encoding: chunked
|
||||
body: "8
|
||||
" af 80 23 02 6a 88 00 00 "0
|
||||
|
||||
" (R-APDU SW 6A88)
|
||||
200 OK + X-Admin-Next-URI: /Download?req=2 + Content-Length: 14
|
||||
body: ae 80 22 08 80 f2 80 02 02 4f 00 00 00 00 (GET STATUS P1=80)
|
||||
POST /Download?req=2 -> X-Admin-Script-Status: ok, chunked
|
||||
body: "1F
|
||||
" af 80 23 19 <25-byte R-APDU ... 90 00> 00 00 "0
|
||||
|
||||
"
|
||||
200 OK + /Download?req=3 + 11-byte script
|
||||
POST /Download?req=3 -> status ok, R-APDU 23 02 6d 00 (SW 6D00)
|
||||
204 No Content <- session ends
|
||||
```
|
||||
|
||||
Confirmed from it: the card echoes the `X-Admin-Next-URI` (path *and* query)
|
||||
verbatim; its response POST goes on the **same TCP connection**; its response
|
||||
is the `AF 80 23 <len> <R-APDU> 00 00` indefinite Response Scripting template
|
||||
(in a chunked body, with `X-Admin-Script-Status`); the server's script
|
||||
`AE 80 22 <len> <APDU> 00 00` matches ours byte for byte; the server uses
|
||||
`Content-Length` (not chunked), no `Connection` header (implicit keep-alive),
|
||||
and ends with 204.
|
||||
|
||||
## Attempt matrix
|
||||
|
||||
| # | transport | framing | Next-URI | close | link events | outcome |
|
||||
|---|-----------|---------|----------|-------|-------------|---------|
|
||||
| 1 | dump mode only | - | - | - | off | OPEN CHANNEL + ClientHello captured (Phase A) |
|
||||
| 2 | TLS, 204 only | - | - | yes | off | session completes cleanly, no alert (Phase B, live) |
|
||||
| 3 | TLS + script | chunked 100 | `/N` | early (raced fetch) | on | fetch truncated (237/399); card re-opened and repeated its POST with `X-Admin-Resume: true` -> breakdown-resume works |
|
||||
| 4 | TLS + script | chunked 100 / single | `/1`, `/`, absolute | keep-alive | off | full fetch, then fatal `unexpected_message` (Next-URI shape) |
|
||||
| 5 | TLS + script | single | none (`""`) | keep-alive | off | no alert, no POST, session left open (spec: no Next-URI -> no response) |
|
||||
| 6 | TLS + script | chunked 100 | reference | close_notify after drain | off | full fetch, alert (notify never fetched) |
|
||||
| 7 | TLS + script | chunked 100 | reference | close_notify before drain | off | full fetch, alert (head split into its own record) |
|
||||
| 8 | TLS + script | **single record** | reference | drain + close_notify | off | **no alert**, card CLOSE CHANNELs, no R-APDU (`chunked=false` -> suspected empty script) |
|
||||
| 9 | TLS + script | single record | reference | keep-alive (no close) | off | fatal `unexpected_message` (close required) |
|
||||
| 10 | TLS + script | chunked 100 | reference | drain + close_notify | off | full fetch, then alert; later the SD hung until a manual link-dropped event |
|
||||
| 11 | TLS + script | single record | reference | keep-alive | off | fatal `unexpected_message` after the full fetch (no close) |
|
||||
| 12 | TLS + script | single record | reference | drain + close_notify | off | **no alert**, card CLOSE CHANNELs, no R-APDU (`Content-Length`) |
|
||||
| 13 | TLS + script | chunked100 + single | reference | drain + close_notify | off | no alert, no R-APDU |
|
||||
| 14 | TLS + script | single record | reference | keep-alive | off | alert again |
|
||||
| 15 | TLS + script | chunked 100 | reference | keep-alive | on | alert (small records, ruled out record size) |
|
||||
| 16 | TLS + script | single record | reference | keep-alive, no `Connection` header | on | alert |
|
||||
| 17 | TLS + script (RFM! `00D6` write-probe) | chunked, single | reference | drain + close_notify | on | no alert, no R-APDU; EF.SPN unchanged - **RFM result is void**: the ISD only accepts RAM commands |
|
||||
|
||||
All script attempts used the `explore` list, except #8-#17 which used only
|
||||
`80CAFF2100` (or the RFM probe). #3-#17 ran with the card's PSK identity
|
||||
`89390…903` (push trigger) or `89701…` (event trigger).
|
||||
|
||||
**Status after #17 (superseded by the 2026-09-16 resolution above):** the
|
||||
failures were caused by the BIP TLV length bug, not by the HTTP/TLS details;
|
||||
resume mode was a symptom (the working session even started as a resume). The
|
||||
key working recipe (also now the server default): one keep-alive connection,
|
||||
Apache-style headers, `Transfer-Encoding: chunked` body with the script in
|
||||
one TLS record, no Connection header, `X-Admin-Next-URI` with a query whose
|
||||
command id increments.
|
||||
|
||||
**Also confirmed:** a TLS half-close (close_notify then keep reading for the
|
||||
card's POST which RFC 5246 leaves open in practice) cannot be done with
|
||||
CPython's `ssl`: `SSLSocket.unwrap()` with a short timeout raises and poisons
|
||||
the session (tested), so the `half_close` option is a documented no-op.
|
||||
|
||||
## RESOLVED 2026-09-16b: SW CAFE continuation pages
|
||||
|
||||
**Implemented:** the script responder auto-follows a truncated listing page
|
||||
(`SW CAFE`, 127 bytes) by inserting a continuation GET STATUS
|
||||
(`80F2 <P1> 02 <Lc> 4F <len> <last-complete-AID> 00`, next-occurrence mode)
|
||||
as the next command. The last AID comes from the last complete `E3` entry in
|
||||
the page (truncated tails and the live `FC`-prefixed junk are skipped).
|
||||
Logged as `script-page`; a repeated page logs `script-page-stalled` and
|
||||
stops; max 24 pages; inserted continuations are dropped at session start.
|
||||
|
||||
**Live-verified (2026-09-16):** ELF registry: page 1 `SW CAFE` ->
|
||||
continuation with `D276000005AA060200000000B00000` -> page 2 `SW 9000`
|
||||
(complete, 2 entries). Applications: page 1 `SW CAFE` -> continuation with
|
||||
`D276000005AAFFCAFE0010` -> page 2 `SW 9000` (complete, incl.
|
||||
`D276000005AAFFCAFE0001/0010`, `A0000001515350`, `A000000151535041`).
|
||||
Full session: 7/7 commands, all `X-Admin-Script-Status: ok`.
|
||||
|
||||
## RESOLVED 2026-09-16c: RAM install over SCP81 - BER length in the script template
|
||||
|
||||
**Root cause:** `_scp81_command_body` wrote the C-APDU TLV length as a raw
|
||||
byte (`AE 80 22 F5 <245 bytes> 00 00` for a 245-byte LOAD). BER reads a byte
|
||||
above 0x7F as a long-form marker, so the card mis-parsed every LOAD >128
|
||||
bytes; the small INSTALL commands (<128 bytes) executed normally, which made
|
||||
the install look alive. Symptoms: the card accepted the LOAD responses with
|
||||
`X-Admin-Script-Status: ok` but sent a degenerate `AF 80` body, no LOAD
|
||||
R-APDU appeared in the results, and the final INSTALL [for install] answered
|
||||
`6A88` (module not found) because the package was never loaded.
|
||||
|
||||
**Fix:** both the indefinite ("22" TLV) and definite ("AA" outer) template
|
||||
lengths are BER-encoded (`_ber_len_bytes`); the same rule as the BIP channel
|
||||
data TLV fix earlier the same day. Tests cover the 245-byte LOAD body, the
|
||||
short-form case and the definite variant.
|
||||
|
||||
## RESOLVED 2026-09-16d: RAM install - LOAD blocks were overlapping copies
|
||||
|
||||
**Root cause:** the LOAD block slicer indexed the load file TLV with the
|
||||
*block number* (`loadfile_tlv[i * 2:(i + 240) * 2] for i in range(blocks)`)
|
||||
instead of a *character offset*, so every block after the first was a
|
||||
1-byte-shifted copy of its predecessor. On the wire the cap header repeated
|
||||
every 239 bytes. The card accepted the first three blocks and failed block 4
|
||||
with `SW 6400` (execution error), then refused the rest (`6985`) and the
|
||||
final INSTALL answered `6A88`. The same slicing lived in the SCP80
|
||||
/api/ram-install path (the helper was extracted from it), so multi-block caps
|
||||
could never install there either.
|
||||
|
||||
**Fix:** consecutive chunks at char offsets
|
||||
(`range(0, len(tlv), 240 * 2)`), with a reassembly test that pins the joined
|
||||
blocks to the C4 TLV byte for byte.
|
||||
|
||||
## RESOLVED 2026-09-16e: RAM install over SCP81 - complete and verified
|
||||
|
||||
**Live-verified end-to-end**: .cap parse -> INSTALL [for load] (SW 9000) ->
|
||||
LOAD x6 (all SW 9000, after the block-slicing fix) -> INSTALL [for install]
|
||||
with the full parameter set -> SW 9000. The applet's INSTALL [for install]
|
||||
needed the real install parameters (`C900` + the STK parameters
|
||||
`EA 0C 80 0A ...`), which the compact SCP81 form could not express - the
|
||||
Remote APDU -> RAM -> INSTALL [for install] builder has all the fields and
|
||||
its "Queue in SCP81" button feeds the commands straight into the HTTP OTA
|
||||
script (the "To expanded" button shows them in the TS 102 226 command
|
||||
scripting (AA/AE80) form). The card's answer for the parameter-less attempt
|
||||
was SW 6A80 (incorrect parameters in data field).
|
||||
|
||||
Working INSTALL [for install] example (compact):
|
||||
`80E60C002E07AA1902BC22580108AA1902BC2258010108AA1902BC22580101010010C900EA0C800A00000F010000000000000000`
|
||||
|
||||
## RESOLVED 2026-09-16f: SW CAFE pagination used the wrong P2 (02 instead of 03)
|
||||
|
||||
**Root cause:** the continuation GET STATUS used `P2=02`, which Table 11-34
|
||||
(GP Card Spec 2.3.1) defines as "**Get first or all occurrence(s)**" - the
|
||||
card returned the first listing again (with the search criterion's single
|
||||
match), so every listing appeared to end after one extra page and newly
|
||||
installed/registered entries were invisible (the installed package
|
||||
`AA1902BC225801` was missing from the ELF registry). The correct value is
|
||||
`P2=03` = "**Get next occurrence(s)**".
|
||||
|
||||
**Fix:** the continuation repeats the *same* GET STATUS command with P2.b1
|
||||
set (`80F2 <P1> 03 <same data> 00`) - the pagination state lives in the card.
|
||||
A changed `4F` criterion is a match filter, not a position: `P2=03` combined
|
||||
with the last AID as criterion is rejected with SW 6A80, and `P2=02` with it
|
||||
returns that single match (the duplicate seen earlier). The card's
|
||||
truncation warning is its proprietary `CA FE`; GP defines `63 10` (Table
|
||||
11-38) and both trigger the continuation.
|
||||
|
||||
**Also fixed (same week):** the explore script's P1 values - per Table 11-33
|
||||
`P1=40` is *applications and supplementary security domains*, `P1=20` the
|
||||
*ELF registry* and `P1=10` *ELF+modules*; the script never queried the
|
||||
ELF-only registry, which is why the installed package `AA1902BC225801` was
|
||||
invisible. Labels/decoder updated; the remote APDU script builder's P1 map
|
||||
(0x02 load / 0x0C install / 0x08 make-selectable / 0x40 reg-update / 0x10
|
||||
extradition) was already correct.
|
||||
|
||||
## RESOLVED 2026-09-16g: response R-APDU TLV length also needs BER long form
|
||||
|
||||
**Root cause:** `_scp81_parse_response` read the `23` (R-APDU) TLV length as a
|
||||
raw byte. A listing page above 127 bytes arrives as `AF 80 23 81 FC <252
|
||||
bytes> 00 00`; the parser took `0x81` as the length, so every page was
|
||||
silently cut to 127 bytes with a bogus status word (the data's last two
|
||||
bytes, e.g. `CAFE`/`0001`/`9F70` instead of the real `63 10`). The bogus SW
|
||||
also stopped the pagination, so later registry entries - including the
|
||||
installed package `AA1902BC225801` - never appeared.
|
||||
|
||||
**Fix:** the response template TLVs use `httpota.ber_len_read` (BER length,
|
||||
same class of bug as the channel data TLV and the command script template
|
||||
earlier the same day). Regression tests cover a 250-byte page with
|
||||
`23 81 FC` and the short-form case.
|
||||
|
||||
## VERIFIED 2026-09-16h: the installed applet in all registries
|
||||
|
||||
After the response-TLV BER fix, `explore` ran 18 commands / 10 auto
|
||||
continuation pages (all real statuses: `63 10` -> next, `9000` = complete)
|
||||
and the installed applet shows up everywhere:
|
||||
|
||||
```
|
||||
80F240 (applications+SDs): AA1902BC22580101 life=07 (SELECTABLE) priv=00 elf=AA1902BC225801
|
||||
80F220 (ELF registry): AA1902BC225801 life=01 (loaded)
|
||||
80F210 (ELF+modules): AA1902BC225801 life=01 module=AA1902BC22580101
|
||||
```
|
||||
|
||||
Full RAM-over-HTTP install cycle: .cap -> INSTALL [for load] -> LOAD x6 ->
|
||||
INSTALL [for install] -> registries.
|
||||
|
||||
## Next tests / work
|
||||
|
||||
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/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
|
||||
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
|
||||
|
||||
- `tools/scp81_decrypt.py <log.json> <keys.log>` - decrypts the dialog from
|
||||
`GET /api/scp81/log` plus the listener's `keylog` file (SSLKEYLOGFILE
|
||||
format; PSK-AES128-CBC-SHA256, TLS 1.2 PRF + OpenSSL CLI). Shows each
|
||||
record's plaintext and any alert level/description.
|
||||
- Start the listener with `"keylog": "/tmp/.../scp81.keys"` to collect the
|
||||
secrets (contains key material - use a temp path, never commit).
|
||||
+26
-20
@@ -42,9 +42,9 @@
|
||||
|
||||
<h3 id="interface" class="text-lg font-medium mb-2">1.1 Интерфейс</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Шапка</strong> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>.</li>
|
||||
<li><strong>Шапка</strong> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>. Рядом с заголовком небольшой индикатор показывает состояние сервера/карты (серая точка = подключение, красная = нет сервера, значки карты = нет карты / инициализация / карта готова) и компактный значок <strong>ADM ✓</strong> (зелёный, администраторский PIN подтверждён) или <strong>ADM ✗</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>
|
||||
|
||||
@@ -317,7 +319,7 @@
|
||||
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>«Профайлер»</strong> и <strong>«Симулятор телефона»</strong> — отдельные вкладки верхнего уровня.</p>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 Файловый менеджер</h3>
|
||||
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы над деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI.</p>
|
||||
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы и кнопка <strong>«Проверить все файлы»</strong> закреплены над прокручиваемым деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li>
|
||||
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
|
||||
@@ -336,8 +338,8 @@
|
||||
<h4 id="scenario-a" class="font-medium mb-1">Сценарий A — Работа с файлами, не входящими в модель pySim («Пользовательские файлы»)</h4>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Получите FID целевого файла (документация вендора или анализ ATR/файловой системы; такие файлы часто отсутствуют в открытых спецификациях).</li>
|
||||
<li>Откройте вкладку <strong>«Картридер»</strong> → подвкладку <strong>«Пользовательские файлы»</strong>.</li>
|
||||
<li>Введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>).</li>
|
||||
<li>Откройте вкладку <strong>«Профайлер»</strong> → подвкладку <strong>«Пользовательские файлы»</strong>.</li>
|
||||
<li>Выберите корень, введите путь родительского DF (стандартный DF из дерева или пользовательский DF, любой вложенности — при вводе появляются подсказки), 4-hex FID и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>).</li>
|
||||
<li>Нажмите <strong>Добавить</strong> — файл появится в дереве курсивом (непроверенный).</li>
|
||||
<li>Кликните по файлу для проверки существования; при успехе (<code class="font-mono text-sm">9000</code>) он работает как обычный файл.</li>
|
||||
<li>Читайте, редактируйте и сохраняйте hex-данные; переключайте <strong>Данные как на карте</strong> / <strong>Декодированные данные</strong>.</li>
|
||||
@@ -376,8 +378,9 @@
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Новый профиль</strong> — создаёт пустой набор правил, запросив имя.</li>
|
||||
<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>
|
||||
@@ -387,9 +390,9 @@
|
||||
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li>
|
||||
<li><strong>Проверить содержимое</strong> (опционально) — <strong>Точное</strong> (точное совпадение hex) или <strong>Маска</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали. Опция <strong>«Только расхождения»</strong> в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.</p>
|
||||
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения FCI подписаны <em>ожидалось (имя профиля)</em> и <em>фактически (ICCID карты)</em>, а в заголовке отчёта выводится <em>Результаты проверки профиля: <профиль> → <ICCID карты></em>. Опция <strong>«Только расхождения»</strong> в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.</p>
|
||||
<h4 class="font-medium mb-1">Опции сканирования «Профиль с карты»</h4>
|
||||
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>«Проверка FCP/FCI»</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>«Игнорировать содержимое файлов»</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>«Сравнивать первые 4 байта для»</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>«Пользовательские файлы»</strong> включаются с той же проверкой существования.</p>
|
||||
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>«Проверка FCP/FCI»</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>«Игнорировать содержимое файлов»</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>«Сравнивать первые 4 байта для»</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>«Пользовательские файлы»</strong> включаются с той же проверкой существования. Тот же диалог и опции использует <strong>«Профиль из снимка»</strong>: вместо карты обходятся захваченные файлы выбранного снимка; для файлов, содержимое которых не было захвачено, правило создаётся без проверки содержимого (при последующей проверке профиля они помечаются как непроверяемые).</p>
|
||||
|
||||
<h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4>
|
||||
<p class="text-sm mb-2">Представление списка имеет две вкладки — <strong>«Профили»</strong> и <strong>«Снимки карт»</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p>
|
||||
@@ -397,8 +400,8 @@
|
||||
<li><strong>Новый снимок</strong> — запрашивает имя и сканирует карту, затем возвращает к списку.</li>
|
||||
<li><strong>Импорт снимка</strong> — загружает снимок из JSON-файла.</li>
|
||||
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
|
||||
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
|
||||
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт; в этом отчёте поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
|
||||
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты: фактическая сторона подписана именем снимка (<em>фактически (имя снимка)</em>), а в заголовке — <em>Результаты проверки профиля: <профиль> → <имя снимка></em>; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
|
||||
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля, но <strong>всегда точно</strong> (маскирование содержимого не применяется): выберите <em>эталонный</em> снимок и <em>снимок для проверки</em> и получите такой же отчёт; в этом отчёте заголовок — <em>Результаты сравнения снимков: <эталон> → <проверяемый></em>, а поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
|
||||
</ul>
|
||||
|
||||
</section>
|
||||
@@ -406,7 +409,7 @@
|
||||
|
||||
|
||||
<h4 id="custom-files" class="font-medium mb-1">Пользовательские файлы</h4>
|
||||
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim: введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>), затем нажмите <strong>Добавить</strong>; добавленные файлы появляются в дереве «Файловый менеджер». В каждой строке есть кнопки <strong>«Редактировать»</strong> (загружает запись в форму — кнопка становится <strong>«Сохранить»</strong>, появляется <strong>«Отмена»</strong>) и <strong>«Удалить»</strong> (без подтверждения). Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен — <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> и <strong>Импорт из файла</strong> / <strong>Вставить и импортировать</strong> / <strong>Импорт JSON из буфера</strong>.</p>
|
||||
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim. Пользовательский файл всегда имеет полный путь от <strong>MF</strong> или ADF (<strong>ADF.USIM</strong>, <strong>ADF.ISIM</strong>): выберите корень, введите путь <strong>родительского DF</strong> (сам корень, стандартный DF из дерева файлового менеджера или пользовательский DF — любой вложенности, с подсказками при вводе; родитель, ещё не встречавшийся в дереве, остаётся допустимым и помечается <code class="font-mono text-sm">⚠</code>), 4-hex <strong>FID</strong> и псевдоним (<code class="font-mono text-sm">EF.…</code>/<code class="font-mono text-sm">DF.…</code>; префикс определяет, EF это или DF). Канонический путь убирает прежнюю неоднозначность, когда один и тот же файл можно было описать и относительно, и абсолютно. Добавленные файлы появляются в дереве «Файловый менеджер»; в каждой строке есть <strong>«Редактировать»</strong> (путь + псевдоним; изменение пути DF перепривязывает дочерние записи — кнопка становится <strong>«Сохранить»</strong>, <strong>«Отмена»</strong> отменяет) и <strong>«Удалить»</strong> (удаление DF удаляет и его дочерние записи после подтверждения). Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен — <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> / <strong>Импорт</strong>; старые относительные пути (например, <code class="font-mono text-sm">a153/4954</code>) разрешаются при загрузке, неразрешимые отбрасываются и отмечаются в списке.</p>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Симулятор телефона</h2>
|
||||
@@ -426,12 +429,15 @@
|
||||
<p class="text-sm mb-3">Отправка события использует <code class="font-mono text-sm">ENVELOPE(Event Download)</code> по TS 102 223 / TS 131 111.</p>
|
||||
|
||||
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Журнал проактивных команд</h3>
|
||||
<p class="text-sm mb-2">Хронологический список извлечённых проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор (для команд, у которых он есть). Для команд с данными ответа показывается строка <code class="font-mono text-sm">Ответ:</code> с байтами TERMINAL RESPONSE (без служебных TLV); ответы PROVIDE LOCAL INFORMATION декодируются через словарь данных PLI.</p>
|
||||
<p class="text-sm mb-2">Хронологический список извлечённых проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор (для команд, у которых он есть). Раскрытие строки показывает декодированную команду: текст и пункты DISPLAY TEXT / SELECT ITEM / SET UP MENU, параметры BIP-каналов, действия TIMER MANAGEMENT, названия квалификаторов PROVIDE LOCAL INFORMATION (все стандартные, включая ESN 07, MEID 0B и Supported RATs 1A) и SEND SHORT MESSAGE — разбирается SMS TPDU (тип, TP-MR, TP-DA, TP-PID, TP-DCS, срок действия, TP-UDL), а пользовательские данные показываются как текст для текстовых кодировок (GSM 7-бит, UCS2, 8-бит) или как secured-пакет (TS 31.115) при TP-PID = <code class="font-mono text-sm">7F</code> (SIM data download); заголовки конкатенации UDH декодируются. Для команд с данными ответа показывается строка <code class="font-mono text-sm">Ответ:</code> с байтами TERMINAL RESPONSE (без служебных TLV); ответы PROVIDE LOCAL INFORMATION декодируются через словарь данных PLI.</p>
|
||||
|
||||
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 Опрос STATUS</h3>
|
||||
<h3 id="terminal-profile" class="text-lg font-medium mb-2">6.4 TERMINAL PROFILE</h3>
|
||||
<p class="text-sm mb-3">Блок <strong>TERMINAL PROFILE</strong> (рядом с «STATUS и опрос») содержит кнопки <strong>«Отправить»</strong> (повторная отправка, как «Спасение») и <strong>«Настроить»</strong>. В диалоге настройки: селектор пресетов (модели устройств, например профиль BIP-совместимого аппарата из этого проекта), поле hex и форма с флажком на каждый бит профиля по <strong>TS 102 223 §5.2</strong> (байты 1–33; далее — обобщённые подписи); каждый байт — вертикальный список битов; блоки размещены фиксированными группами (байты 1–12 в 2 колонки, 13–16 в 4, 17–18 в 2, 19–21 в 3, 22–25 в 2, 26–28 в 3, 29–30 в 2, далее по одному в строке); переключение бита обновляет hex, а правка hex перерисовывает форму — поле hex основное, неизвестные байты и биты сохраняются. Кнопка <strong>«Применить»</strong> отправляет новое значение на сервер (и далее на карту), сбрасывая STK-сессию как «Спасение»; изменение хранится только в памяти (значение <code class="font-mono text-sm">--terminal-profile</code> — стартовое по умолчанию).</p>
|
||||
|
||||
<h3 id="status-polling" class="text-lg font-medium mb-2">6.5 Опрос STATUS</h3>
|
||||
<p class="text-sm mb-3">Кнопка <strong>Отправить STATUS</strong> отправляет STATUS (F2) вручную. Переключатель <strong>Опрос</strong> включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера <code class="font-mono text-sm">--poll-interval</code>, 1–255 с, по умолчанию 30 с, <code class="font-mono text-sm">0</code> отключает опрос) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.</p>
|
||||
|
||||
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 «Конфигурация TR» — данные ответа PROVIDE LOCAL INFORMATION</h3>
|
||||
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.6 «Конфигурация TR» — данные ответа PROVIDE LOCAL INFORMATION</h3>
|
||||
<p class="text-sm mb-2">Редактируемые hex-значения для всех 22 квалификаторов PLI (TS 102 223 §8.6 + TS 131 111). У десяти квалификаторов есть встроенные формы декодирования/кодирования:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
|
||||
|
||||
+25
-19
@@ -42,9 +42,9 @@
|
||||
|
||||
<h3 id="interface" class="text-lg font-medium mb-2">1.1 Interface</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle.</li>
|
||||
<li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle. Next to the title a small status indicator shows the server/card state (gray dot = connecting, red dot = no server connection, card icons = no card / equipping / equipped) and a compact <strong>ADM ✓</strong> (green, administrator PIN verified) or <strong>ADM ✗</strong> (red, not verified) badge for the current card session.</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>
|
||||
|
||||
@@ -317,7 +319,7 @@
|
||||
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 File manager</h3>
|
||||
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills above the tree; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI above the content pane.</p>
|
||||
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills pinned above the scrolling tree together with <strong>Probe all files</strong>; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI above the content pane.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
|
||||
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
|
||||
@@ -336,8 +338,8 @@
|
||||
<h4 id="scenario-a" class="font-medium mb-1">Scenario A — Working with files not in pySim’s model (Custom files)</h4>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Obtain the FID of the target file (vendor documentation or ATR/file-system analysis; such files are often not in public specs).</li>
|
||||
<li>Open the <strong>Card reader</strong> tab → <strong>Custom files</strong> sub-tab.</li>
|
||||
<li>Enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>).</li>
|
||||
<li>Open the <strong>Profiler</strong> tab → <strong>Custom files</strong> sub-tab.</li>
|
||||
<li>Pick the root, type the parent DF path (a standard DF from the tree or a custom DF, any depth — suggestions appear while you type), the 4-hex FID and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>).</li>
|
||||
<li>Click <strong>Add</strong> — the file appears in the tree in italics (unverified).</li>
|
||||
<li>Click the file to verify existence; on success (<code class="font-mono text-sm">9000</code>) it behaves like a normal file.</li>
|
||||
<li>Read, edit and save hex data; toggle Raw/Decoded views.</li>
|
||||
@@ -376,8 +378,9 @@
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>New profile</strong> — creates an empty ruleset after prompting for a name.</li>
|
||||
<li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li>
|
||||
<li><strong>Profile from snapshot</strong> — picks a saved card snapshot and generates one rule per captured file using the same scan options (see below), without a card reader; the profile name is prefilled with the snapshot name.</li>
|
||||
<li><strong>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>
|
||||
@@ -387,9 +390,9 @@
|
||||
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
|
||||
<li><strong>Check contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p>
|
||||
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled <em>expected (profile name)</em> and <em>actual (card ICCID)</em>, and the results header reads <em>Profile verification results for: <profile> → <card ICCID></em>. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p>
|
||||
<h4 class="font-medium mb-1">“Profile from card” scan options</h4>
|
||||
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>“FCP/FCI check”</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>“Ignore contents of files”</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>“Match first 4 bytes for”</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
|
||||
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>“FCP/FCI check”</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>“Ignore contents of files”</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>“Match first 4 bytes for”</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check. The same dialog and options are used by <strong>Profile from snapshot</strong>, which walks the selected snapshot’s captured files instead of the card; rules for files whose contents were not captured during the scan get no content check (they are reported as unverifiable when the profile is later checked).</p>
|
||||
|
||||
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
|
||||
<p class="text-sm mb-2">The list view has two tabs — <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
|
||||
@@ -397,8 +400,8 @@
|
||||
<li><strong>New snapshot</strong> — asks for a name and scans the card, then returns to the list.</li>
|
||||
<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; 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 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>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 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>
|
||||
@@ -406,7 +409,7 @@
|
||||
|
||||
|
||||
<h4 id="custom-files" class="font-medium mb-1">Custom files</h4>
|
||||
<p class="text-sm mb-3">Add files that pySim’s model does not cover: enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>), then press <strong>Add</strong>; added files appear in the File manager tree. Each row has <strong>Edit</strong> (reloads the entry into the form — the button becomes <strong>Save</strong> and a <strong>Cancel</strong> button appears) and <strong>Delete</strong> (no confirmation) buttons. The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> and restored with <strong>Import from file</strong> / <strong>Paste & import</strong> / <strong>Import JSON from clipboard</strong>.</p>
|
||||
<p class="text-sm mb-3">Add files that pySim’s model does not cover. A custom file always has a full path rooted at <strong>MF</strong> or an ADF (<strong>ADF.USIM</strong>, <strong>ADF.ISIM</strong>): pick the root, type the <strong>parent DF</strong> path (the root itself, a standard DF known from the file-manager tree, or a custom DF — any depth, with suggestions while you type; a parent that has not been seen in the tree yet stays valid and is marked <code class="font-mono text-sm">⚠</code>), the 4-hex <strong>FID</strong> and an alias (<code class="font-mono text-sm">EF.…</code>/<code class="font-mono text-sm">DF.…</code>; the prefix decides whether the entry is an EF or a DF). The canonical path removes the old ambiguity where the same file could be described both relatively and absolutely. Added files appear in the File manager tree; each row has <strong>Edit</strong> (path + alias; changing a DF’s path re-points its child entries — the button becomes <strong>Save</strong>, <strong>Cancel</strong> aborts) and <strong>Delete</strong> (deleting a DF also deletes its child entries after a confirmation). The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> / <strong>Import</strong>; legacy relative paths (e.g. <code class="font-mono text-sm">a153/4954</code>) are resolved on load, unresolvable ones are dropped and reported in the list.</p>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Phone simulator</h2>
|
||||
@@ -426,12 +429,15 @@
|
||||
<p class="text-sm mb-3">Sending an event uses <code class="font-mono text-sm">ENVELOPE(Event Download)</code> per TS 102 223 / TS 131 111.</p>
|
||||
|
||||
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Proactive command log</h3>
|
||||
<p class="text-sm mb-2">Chronological list of fetched proactive commands. Each row shows the elapsed time, type code, name, and a decoded qualifier (for commands that have one). Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
|
||||
<p class="text-sm mb-2">Chronological list of fetched proactive commands. Each row shows the elapsed time, type code, name, and a decoded qualifier (for commands that have one). Expanding a row shows the decoded command: DISPLAY TEXT / SELECT ITEM / SET UP MENU text and items, BIP channel parameters, TIMER MANAGEMENT actions, PROVIDE LOCAL INFORMATION qualifier names (all standard qualifiers, including ESN 07, MEID 0B and Supported RATs 1A), and SEND SHORT MESSAGE — the SMS TPDU is parsed (type, TP-MR, TP-DA, TP-PID, TP-DCS, validity period, TP-UDL) with the user data shown as text for text codings (GSM 7-bit, UCS2, 8-bit) or as a secured packet (TS 31.115) when TP-PID = <code class="font-mono text-sm">7F</code> (SIM data download); UDH concatenation headers are decoded. Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
|
||||
|
||||
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 STATUS polling</h3>
|
||||
<h3 id="terminal-profile" class="text-lg font-medium mb-2">6.4 TERMINAL PROFILE</h3>
|
||||
<p class="text-sm mb-3">The <strong>TERMINAL PROFILE</strong> block (next to STATUS and Polling) offers <strong>Send</strong> (re-sends it, like Rescue) and <strong>Configure</strong>. The Configure dialog has a preset selector (device models, e.g. this project's BIP-capable handset profile), a hex field and a form with one checkbox per profile bit decoded per <strong>TS 102 223 §5.2</strong> (bytes 1–33; later bytes get generic labels), each byte is a vertical list of its bits, placed in fixed column groups (bytes 1-12 in 2 columns, 13-16 in 4, 17-18 in 2, 19-21 in 3, 22-25 in 2, 26-28 in 3, 29-30 in 2, later bytes one per row); toggling a bit updates the hex and editing the hex re-renders the form — the hex field is authoritative and unknown bytes/bits are preserved. <strong>Apply</strong> sends the new value to the server (and on to the card), resetting the STK session like Rescue; the change is in-memory only (the <code class="font-mono text-sm">--terminal-profile</code> CLI value is the startup default).</p>
|
||||
|
||||
<h3 id="status-polling" class="text-lg font-medium mb-2">6.5 STATUS polling</h3>
|
||||
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). A <strong>Polling</strong> toggle enables background polling: after a configurable idle interval (server CLI <code class="font-mono text-sm">--poll-interval</code>, 1–255 s, default 30 s, <code class="font-mono text-sm">0</code> disables polling) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.</p>
|
||||
|
||||
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 TR Config — PROVIDE LOCAL INFORMATION response data</h3>
|
||||
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.6 TR Config — PROVIDE LOCAL INFORMATION response data</h3>
|
||||
<p class="text-sm mb-2">Editable hex values for all 22 PLI qualifiers (TS 102 223 §8.6 + TS 131 111). Ten qualifiers have inline decode/encode forms:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
|
||||
|
||||
+2345
-308
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v125';
|
||||
const CACHE = 'otaman-v176';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -23,29 +23,52 @@ function extractFunc(src, name) {
|
||||
|
||||
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
|
||||
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'
|
||||
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\n';
|
||||
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\nvar _pysimAdmVerified = null;\n';
|
||||
code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
|
||||
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
|
||||
code += extractFunc(html, 'pysimControlDisabled') + '\n';
|
||||
code += extractFunc(html, 'pysimProactiveSeqChanged') + '\n';
|
||||
code += extractFunc(html, 'pysimStkStatusChanged') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdateAdmIndicator') + '\n';
|
||||
code += extractFunc(html, 'pysimSetServerAvailable') + '\n';
|
||||
code += '\nglobalThis.esc = s => s;\n';
|
||||
code += 'globalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
function fakeIndicator() {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
classes, textContent: '', title: null,
|
||||
classList: {
|
||||
add: (...c) => c.forEach(x => classes.add(x)),
|
||||
remove: (...c) => c.forEach(x => classes.delete(x)),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
setAttribute: (k, v) => { if (k === 'title') el.title = v; },
|
||||
removeAttribute: (k) => { if (k === 'title') el.title = null; },
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const el = { textContent: 'status line', innerHTML: '' };
|
||||
const adm = fakeIndicator();
|
||||
const calls = { connected: [], resets: [], refreshStatus: [], proactive: 0 };
|
||||
_pysimCardStateKey = null;
|
||||
_pysimCardSession = null;
|
||||
_pysimProactiveSeq = null;
|
||||
globalThis.document = { getElementById: () => el, querySelectorAll: () => [] };
|
||||
_pysimAdmVerified = null;
|
||||
_pysimServerAvailable = null;
|
||||
globalThis.document = {
|
||||
getElementById: id => id === 'state-indicator-adm' ? adm : el,
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
globalThis.pysimSetConnected = v => calls.connected.push(v);
|
||||
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
|
||||
globalThis.pysimApplyAvailability = () => {};
|
||||
globalThis.isViewVisible = () => true;
|
||||
globalThis.pysimProactiveLogRender = () => { calls.proactive++; };
|
||||
return { el, calls };
|
||||
return { el, adm, calls };
|
||||
}
|
||||
|
||||
function status(extra) {
|
||||
@@ -168,3 +191,37 @@ test('pysimStkStatusChanged detects menu state transitions', () => {
|
||||
assert.ok(pysimStkStatusChanged({ active: true, pending: false }));
|
||||
assert.ok(!pysimStkStatusChanged(null));
|
||||
});
|
||||
|
||||
test('the header ADM badge shows verified / not verified / hidden', () => {
|
||||
const { adm, calls } = setup();
|
||||
pysimCardStateUpdate(status({ connected: true, adm_verified: true }));
|
||||
assert.ok(!adm.classes.has('hidden'));
|
||||
assert.strictEqual(adm.textContent, 'ADM ✓');
|
||||
assert.ok(adm.classes.has('text-emerald-600'));
|
||||
assert.ok(adm.classes.has('dark:text-emerald-400'));
|
||||
assert.strictEqual(adm.title, 'ADM verified');
|
||||
// an unchanged state must not rewrite the badge
|
||||
adm.textContent = '';
|
||||
pysimCardStateUpdate(status({ connected: true, adm_verified: true }));
|
||||
assert.strictEqual(adm.textContent, '', 'unchanged ADM state rewrote the badge');
|
||||
// verification lost (e.g. card reset)
|
||||
pysimCardStateUpdate(status({ connected: true, adm_verified: false }));
|
||||
assert.strictEqual(adm.textContent, 'ADM ✗');
|
||||
assert.ok(adm.classes.has('text-red-500'));
|
||||
assert.ok(!adm.classes.has('text-emerald-600'));
|
||||
assert.strictEqual(adm.title, 'ADM not verified');
|
||||
// no card session hides it
|
||||
pysimCardStateUpdate(status({ connected: false }));
|
||||
assert.ok(adm.classes.has('hidden'));
|
||||
assert.strictEqual(adm.title, null);
|
||||
// the ADM update must not disturb the connect/reset flow
|
||||
assert.deepStrictEqual(calls.connected, [true, false]);
|
||||
});
|
||||
|
||||
test('losing the server hides the ADM badge', () => {
|
||||
const { adm } = setup();
|
||||
pysimCardStateUpdate(status({ connected: true, adm_verified: true }));
|
||||
assert.ok(!adm.classes.has('hidden'));
|
||||
pysimSetServerAvailable(false);
|
||||
assert.ok(adm.classes.has('hidden'));
|
||||
});
|
||||
|
||||
@@ -21,22 +21,24 @@ function extractFunc(src, name) {
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\n';
|
||||
for (const fn of ['pysimCustomSave', 'pysimCustomSubmit', 'pysimCustomEdit', 'pysimCustomEditCancel', 'pysimCustomRemove', 'pysimCustomRender']) {
|
||||
let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\nvar _pysimCustomDropped = 0;\nvar pysimFsTreeRoot = null;\n';
|
||||
for (const fn of ['pysimCustomNormPath', 'pysimCustomKindForName', 'pysimCustomFid',
|
||||
'pysimCustomParent', 'pysimCustomRoot', 'pysimCustomKnownDfPaths', 'pysimCustomParentStatus',
|
||||
'pysimCustomValidate', 'pysimCustomRewriteDescendants', 'pysimCustomNormalizeEntries',
|
||||
'pysimCustomSave', 'pysimCustomRenderRoots', 'pysimCustomRenderParents',
|
||||
'pysimCustomRootChanged', 'pysimCustomParentInput', 'pysimCustomSubmit',
|
||||
'pysimCustomEdit', 'pysimCustomEditCancel', 'pysimCustomRemove', 'pysimCustomRender',
|
||||
'pysimFsNodePath', 'pysimFsFindNodeByPath', 'pysimCustomInject', 'pysimCustomRefreshTree']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||
code += html.match(/const CUSTOM_ROOTS = \[[^\]]*\];/)[0].replace('const ', 'var ') + '\n';
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimFsRenderTree = () => {};\n';
|
||||
eval(code);
|
||||
|
||||
function fakeEl(id) {
|
||||
const classes = new Set();
|
||||
return {
|
||||
id,
|
||||
value: '',
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
attrs: {},
|
||||
focused: 0,
|
||||
id, value: '', innerHTML: '', textContent: '', attrs: {}, focused: 0,
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
@@ -44,12 +46,16 @@ function fakeEl(id) {
|
||||
},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
focus() { this.focused++; },
|
||||
options() { return [...this.innerHTML.matchAll(/value="([^"]+)"/g)].map(m => m[1]); },
|
||||
};
|
||||
}
|
||||
|
||||
function setup(entries) {
|
||||
const els = {
|
||||
'pysim-cf-path': fakeEl('pysim-cf-path'),
|
||||
'pysim-cf-root': fakeEl('pysim-cf-root'),
|
||||
'pysim-cf-parent': fakeEl('pysim-cf-parent'),
|
||||
'pysim-cf-parent-list': fakeEl('pysim-cf-parent-list'),
|
||||
'pysim-cf-fid': fakeEl('pysim-cf-fid'),
|
||||
'pysim-cf-name': fakeEl('pysim-cf-name'),
|
||||
'pysim-cf-list': fakeEl('pysim-cf-list'),
|
||||
'pysim-cf-add-btn': fakeEl('pysim-cf-add-btn'),
|
||||
@@ -64,102 +70,230 @@ function setup(entries) {
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.alertCalls = [];
|
||||
globalThis.alert = m => { globalThis.alertCalls.push(m); };
|
||||
globalThis.confirmResult = true;
|
||||
globalThis.confirm = () => globalThis.confirmResult;
|
||||
pysimCustomFiles = (entries || []).map(e => Object.assign({}, e));
|
||||
pysimCustomEditIndex = null;
|
||||
_pysimCustomDropped = 0;
|
||||
pysimFsTreeRoot = null;
|
||||
pysimCustomRenderRoots();
|
||||
pysimCustomRenderParents();
|
||||
return els;
|
||||
}
|
||||
|
||||
const entry = (fid, name, parentFid) => ({ path: (parentFid || '3F00') + '/' + fid, name, fid, parentFid: parentFid || '3F00' });
|
||||
function fill(els, root, parent, fid, name) {
|
||||
els['pysim-cf-root'].value = root;
|
||||
pysimCustomRenderParents();
|
||||
els['pysim-cf-parent'].value = parent;
|
||||
els['pysim-cf-fid'].value = fid;
|
||||
els['pysim-cf-name'].value = name;
|
||||
}
|
||||
|
||||
test('rows render Edit and Delete buttons instead of the X glyph', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
test('helpers derive canonical roots, parents, FIDs and kinds', () => {
|
||||
assert.strictEqual(pysimCustomNormPath('3f00/7f20/6f46'), 'MF/7F20/6F46');
|
||||
assert.strictEqual(pysimCustomNormPath(' mf / a153 '), 'MF/A153');
|
||||
assert.strictEqual(pysimCustomNormPath(''), '');
|
||||
assert.strictEqual(pysimCustomRoot({ path: 'ADF.USIM/6F07' }), 'ADF.USIM');
|
||||
assert.strictEqual(pysimCustomParent({ path: 'MF/A153/4954' }), 'MF/A153');
|
||||
assert.strictEqual(pysimCustomParent({ path: 'MF/6F46' }), 'MF');
|
||||
assert.strictEqual(pysimCustomFid({ path: 'MF/A153/4954' }), '4954');
|
||||
assert.strictEqual(pysimCustomKindForName('EF.SPN'), 'ef');
|
||||
assert.strictEqual(pysimCustomKindForName('DF.GSM'), 'df');
|
||||
assert.strictEqual(pysimCustomKindForName('XX.SPN'), null);
|
||||
});
|
||||
|
||||
test('migration resolves legacy relative paths and drops the unresolvable', () => {
|
||||
const res = pysimCustomNormalizeEntries([
|
||||
{ path: '3f00/a153', name: 'DF.A1' },
|
||||
{ path: 'a153/4954', name: 'EF.SPNS', fid: '4954', parentFid: 'a153' },
|
||||
{ path: 'a153/4955', name: 'EF.SMSCS' },
|
||||
{ path: 'ffff/1111', name: 'EF.ORPHAN' }, // no such custom DF -> dropped
|
||||
{ path: 'MF/6F46', name: 'BAD.NAME' }, // invalid alias -> dropped
|
||||
], []);
|
||||
assert.deepStrictEqual(res.files, [
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
{ path: 'MF/A153/4955', name: 'EF.SMSCS', kind: 'ef' },
|
||||
]);
|
||||
assert.strictEqual(res.dropped, 2);
|
||||
});
|
||||
|
||||
test('validation accepts standard/unknown parents but rejects known EFs', () => {
|
||||
const files = [{ path: 'MF/A153', name: 'DF.A1', kind: 'df' }];
|
||||
// a parent that is not a custom entry is accepted: it may be a standard DF
|
||||
// from the card model, or simply not seen in the tree yet
|
||||
const unknown = pysimCustomValidate('MF', 'MF/A999', '6F46', 'EF.SPN', files, null);
|
||||
assert.strictEqual(unknown.error, null);
|
||||
assert.strictEqual(unknown.path, 'MF/A999/6F46');
|
||||
// parent defined -> ok
|
||||
assert.strictEqual(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN', files, null).path, 'MF/A153/6F46');
|
||||
// a bare FID chain typed without the root is completed from the selector
|
||||
assert.strictEqual(pysimCustomValidate('MF', 'A153', '6F46', 'EF.SPN', files, null).path, 'MF/A153/6F46');
|
||||
// deep chains are fine
|
||||
assert.strictEqual(pysimCustomValidate('MF', 'MF/7F20/5F01', '6F46', 'EF.DEEP', [], null).path, 'MF/7F20/5F01/6F46');
|
||||
// a parent known here to be an EF is rejected
|
||||
assert.match(pysimCustomValidate('MF', 'MF/A153/6F46', '1234', 'EF.X',
|
||||
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), null).error, /not a DF/);
|
||||
// parent segments must be 4-hex FIDs
|
||||
assert.match(pysimCustomValidate('MF', 'MF/FOO', '6F46', 'EF.SPN', files, null).error, /4-hex/);
|
||||
// bad FID
|
||||
assert.match(pysimCustomValidate('MF', 'MF', '6F4', 'EF.SPN', files, null).error, /4 hex/);
|
||||
// bad alias
|
||||
assert.match(pysimCustomValidate('MF', 'MF', '6F46', 'SPN', files, null).error, /EF\.|DF\./);
|
||||
// duplicate
|
||||
assert.match(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN',
|
||||
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), null).error, /already defined/);
|
||||
// editing the same entry is not a duplicate
|
||||
assert.strictEqual(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN',
|
||||
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), 1).error, null);
|
||||
// root must be known
|
||||
assert.match(pysimCustomValidate('MFX', 'MFX', '6F46', 'EF.SPN', files, null).error, /Root/);
|
||||
});
|
||||
|
||||
test('root and parent suggestions list roots, custom DFs and tree DFs', () => {
|
||||
const els = setup([
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
{ path: 'ADF.USIM/6F07', name: 'EF.IMSI', kind: 'ef' },
|
||||
]);
|
||||
assert.deepStrictEqual(els['pysim-cf-root'].options(), ['MF', 'ADF.USIM', 'ADF.ISIM']);
|
||||
let parents = els['pysim-cf-parent-list'].options();
|
||||
assert.ok(parents.includes('MF'));
|
||||
assert.ok(parents.includes('MF/A153'));
|
||||
assert.ok(!parents.includes('MF/A153/4954'), 'EFs are not parent options');
|
||||
// DFs known from the loaded file tree are suggested, at any depth
|
||||
pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] };
|
||||
const df = { name: 'DF.TELECOM', fid: '7F10', isDir: true, parent: pysimFsTreeRoot, children: [] };
|
||||
const sub = { name: 'DF.SUB', fid: '5F01', isDir: true, parent: df, children: [] };
|
||||
df.children = [sub];
|
||||
pysimFsTreeRoot.children = [df];
|
||||
pysimCustomRenderParents();
|
||||
parents = els['pysim-cf-parent-list'].options();
|
||||
assert.ok(parents.includes('MF/7F10'));
|
||||
assert.ok(parents.includes('MF/7F10/5F01'));
|
||||
// the ADF root is always a valid parent
|
||||
els['pysim-cf-root'].value = 'ADF.USIM';
|
||||
pysimCustomRootChanged();
|
||||
assert.deepStrictEqual(els['pysim-cf-parent-list'].options(), ['ADF.USIM']);
|
||||
});
|
||||
|
||||
test('parent status classifies root, custom, tree and unknown parents', () => {
|
||||
setup([
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
]);
|
||||
assert.strictEqual(pysimCustomParentStatus('MF').status, 'root');
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/A153').status, 'df');
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/A153/4954').status, 'not-df');
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/FFFF').status, 'unknown');
|
||||
pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] };
|
||||
const df = { name: 'DF.GSM', fid: '7F20', isDir: true, parent: pysimFsTreeRoot, children: [] };
|
||||
df.children = [{ name: 'EF.SPN', fid: '6F46', isDir: false, parent: df, children: null }];
|
||||
pysimFsTreeRoot.children = [df];
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/7F20').status, 'df');
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/7F20/6F46').status, 'not-df');
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/7F20/9999').status, 'unknown');
|
||||
});
|
||||
|
||||
test('import keeps canonical entries whose parent is outside the custom list', () => {
|
||||
const res = pysimCustomNormalizeEntries([
|
||||
{ path: '3F00/7f20/5f01/6f46', name: 'EF.DEEP' },
|
||||
{ path: 'mf/ffff/6f46', name: 'EF.ORPHAN' },
|
||||
{ path: 'MF/6F46', name: 'NOPE' },
|
||||
], []);
|
||||
assert.deepStrictEqual(res.files, [
|
||||
{ path: 'MF/7F20/5F01/6F46', name: 'EF.DEEP', kind: 'ef' },
|
||||
{ path: 'MF/FFFF/6F46', name: 'EF.ORPHAN', kind: 'ef' },
|
||||
]);
|
||||
assert.strictEqual(res.dropped, 1);
|
||||
});
|
||||
|
||||
test('submit adds files under the root and under a defined DF', () => {
|
||||
const els = setup([]);
|
||||
fill(els, 'MF', 'MF', '6F46', 'EF.SPN');
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(pysimCustomFiles, [{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' }]);
|
||||
assert.strictEqual(els['pysim-cf-fid'].value, '');
|
||||
// add a DF, then a file under it
|
||||
fill(els, 'MF', 'MF', 'A153', 'DF.A1');
|
||||
pysimCustomSubmit();
|
||||
fill(els, 'MF', 'MF/A153', '4954', 'EF.SPNS');
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
|
||||
['MF/6F46', 'MF/A153', 'MF/A153/4954']);
|
||||
// a parent that is not a custom entry is allowed (it may be a standard DF
|
||||
// the tree has not loaded yet)
|
||||
fill(els, 'MF', 'MF/A153', '2222', 'EF.ORPHAN');
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
|
||||
['MF/6F46', 'MF/A153', 'MF/A153/4954', 'MF/A153/2222']);
|
||||
assert.strictEqual(globalThis.alertCalls.length, 0);
|
||||
// a parent known here to be an EF is rejected
|
||||
fill(els, 'MF', 'MF/A153/4954', '3333', 'EF.NOPE');
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(globalThis.alertCalls, ['Parent is not a DF: MF/A153/4954']);
|
||||
});
|
||||
|
||||
test('editing a DF FID rewrites its descendants', () => {
|
||||
const els = setup([
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
{ path: 'MF/A153/4955', name: 'EF.SMSCS', kind: 'ef' },
|
||||
]);
|
||||
pysimCustomEdit(0);
|
||||
assert.strictEqual(els['pysim-cf-fid'].value, 'A153');
|
||||
els['pysim-cf-fid'].value = 'A154';
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
|
||||
['MF/A154', 'MF/A154/4954', 'MF/A154/4955']);
|
||||
assert.strictEqual(pysimCustomEditIndex, null);
|
||||
});
|
||||
|
||||
test('editing an entry to another defined path is rejected', () => {
|
||||
const els = setup([
|
||||
{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' },
|
||||
{ path: 'MF/6F44', name: 'EF.SPN2', kind: 'ef' },
|
||||
]);
|
||||
pysimCustomEdit(0);
|
||||
els['pysim-cf-fid'].value = '6F44';
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(globalThis.alertCalls, ['File already defined: MF/6F44']);
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46', 'MF/6F44']);
|
||||
assert.strictEqual(pysimCustomEditIndex, 0);
|
||||
});
|
||||
|
||||
test('deleting a DF cascades to its children only after confirmation', () => {
|
||||
setup([
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' },
|
||||
]);
|
||||
globalThis.confirmResult = false;
|
||||
pysimCustomRemove(0);
|
||||
assert.strictEqual(pysimCustomFiles.length, 3, 'cancelled delete must keep everything');
|
||||
globalThis.confirmResult = true;
|
||||
pysimCustomRemove(0);
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46']);
|
||||
});
|
||||
|
||||
test('render shows the canonical path, kind and row actions', () => {
|
||||
const els = setup([
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
]);
|
||||
pysimCustomRender();
|
||||
const out = els['pysim-cf-list'].innerHTML;
|
||||
assert.match(out, /MF\/A153/);
|
||||
assert.match(out, /MF\/A153\/4954/);
|
||||
assert.match(out, /\(DF\)/);
|
||||
assert.match(out, /pysimCustomEdit\(0\)/);
|
||||
assert.match(out, /pysimCustomRemove\(0\)/);
|
||||
assert.match(out, />Edit</);
|
||||
assert.match(out, />Delete</);
|
||||
assert.ok(!out.includes('✕'), 'old X glyph must be gone');
|
||||
assert.match(out, /pysimCustomRemove\(1\)/);
|
||||
});
|
||||
|
||||
test('edit fills the top form and switches it to Save mode', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
assert.strictEqual(pysimCustomEditIndex, 0);
|
||||
assert.strictEqual(els['pysim-cf-path'].value, '3F00/6F46');
|
||||
assert.strictEqual(els['pysim-cf-name'].value, 'EF.SPN');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Save');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Save');
|
||||
assert.ok(!els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||
assert.strictEqual(els['pysim-cf-path'].focused, 1);
|
||||
});
|
||||
|
||||
test('submit in edit mode updates the entry in place', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
els['pysim-cf-path'].value = '3F00/7F20/6F46';
|
||||
els['pysim-cf-name'].value = 'EF.SPNX';
|
||||
pysimCustomSubmit();
|
||||
assert.strictEqual(pysimCustomFiles.length, 1);
|
||||
assert.deepStrictEqual(pysimCustomFiles[0], { path: '3F00/7F20/6F46', name: 'EF.SPNX', fid: '6F46', parentFid: '7F20' });
|
||||
assert.strictEqual(pysimCustomEditIndex, null);
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||
});
|
||||
|
||||
test('saving an edited entry with its own path is not a duplicate', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
pysimCustomSubmit();
|
||||
assert.strictEqual(pysimCustomFiles.length, 1);
|
||||
assert.deepStrictEqual(globalThis.alertCalls, []);
|
||||
});
|
||||
|
||||
test('editing to another entry path is rejected as duplicate', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2')]);
|
||||
pysimCustomEdit(0);
|
||||
els['pysim-cf-path'].value = '3F00/6F44';
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(globalThis.alertCalls, ['Path already exists']);
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['3F00/6F46', '3F00/6F44']);
|
||||
assert.strictEqual(pysimCustomEditIndex, 0);
|
||||
});
|
||||
|
||||
test('cancel restores the Add mode and clears the inputs', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
pysimCustomEditCancel();
|
||||
assert.strictEqual(pysimCustomEditIndex, null);
|
||||
assert.strictEqual(els['pysim-cf-path'].value, '');
|
||||
assert.strictEqual(els['pysim-cf-name'].value, '');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Add');
|
||||
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||
});
|
||||
|
||||
test('deleting the entry being edited cancels the edit', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
pysimCustomRemove(0);
|
||||
assert.deepStrictEqual(pysimCustomFiles, []);
|
||||
assert.strictEqual(pysimCustomEditIndex, null);
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||
});
|
||||
|
||||
test('deleting before the edited row shifts the edit index', () => {
|
||||
setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2'), entry('6F42', 'EF.SPN3')]);
|
||||
pysimCustomEdit(2);
|
||||
pysimCustomRemove(0);
|
||||
assert.strictEqual(pysimCustomEditIndex, 1);
|
||||
assert.strictEqual(pysimCustomFiles[1].name, 'EF.SPN3');
|
||||
});
|
||||
|
||||
test('submit adds a new entry when not editing', () => {
|
||||
test('render flags dropped legacy entries', () => {
|
||||
const els = setup([]);
|
||||
els['pysim-cf-path'].value = '3f00/6f46';
|
||||
els['pysim-cf-name'].value = 'EF.SPN';
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(pysimCustomFiles, [{ path: '3F00/6F46', name: 'EF.SPN', fid: '6F46', parentFid: '3F00' }]);
|
||||
assert.strictEqual(els['pysim-cf-path'].value, '');
|
||||
assert.strictEqual(els['pysim-cf-name'].value, '');
|
||||
_pysimCustomDropped = 3;
|
||||
pysimCustomRender();
|
||||
assert.match(els['pysim-cf-list'].innerHTML, /3/);
|
||||
_pysimCustomDropped = 0;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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);
|
||||
}
|
||||
|
||||
let code = 'var pysimCustomFiles = [];\nvar pysimFsTreeRoot = null;\n';
|
||||
for (const fn of ['pysimFsNodePath', 'pysimCustomInject', 'pysimCustomRefreshTree', 'pysimCustomParent', 'pysimCustomFid']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'globalThis.pysimFsRenderTree = () => {};\n';
|
||||
eval(code);
|
||||
|
||||
function node(name, fid, parent, children) {
|
||||
return { name, fid, parent: parent || null, children: children || null, isDir: true };
|
||||
}
|
||||
|
||||
function tree() {
|
||||
const mf = node('MF', '3F00', null, []);
|
||||
const dfA = node('DF.A', '5F01', mf);
|
||||
const dfB = node('DF.B', '5F02', mf);
|
||||
const dfC = node('DF.C', '5F03', mf, [node('EF.X', '6F46', null)]);
|
||||
dfC.children[0].parent = dfC;
|
||||
const adf = node('ADF.USIM', '7FFF', mf);
|
||||
const efImsi = node('EF.IMSI', '6F07', adf);
|
||||
adf.children = [efImsi];
|
||||
mf.children = [dfA, dfB, dfC, adf];
|
||||
return { mf, dfA, dfB, dfC, adf, efImsi };
|
||||
}
|
||||
|
||||
test('pysimFsNodePath builds canonical paths from MF and ADF roots', () => {
|
||||
const t = tree();
|
||||
assert.strictEqual(pysimFsNodePath(t.mf), 'MF');
|
||||
assert.strictEqual(pysimFsNodePath(t.dfA), 'MF/5F01');
|
||||
assert.strictEqual(pysimFsNodePath(t.dfC.children[0]), 'MF/5F03/6F46');
|
||||
assert.strictEqual(pysimFsNodePath(t.adf), 'ADF.USIM');
|
||||
assert.strictEqual(pysimFsNodePath(t.efImsi), 'ADF.USIM/6F07');
|
||||
});
|
||||
|
||||
test('injection matches full paths, so same-FID DFs stay apart', () => {
|
||||
const t = tree();
|
||||
pysimCustomFiles = [
|
||||
{ path: 'MF/5F01/6F46', name: 'EF.ONLY-A', kind: 'ef' },
|
||||
{ path: 'MF/5F02/6F46', name: 'EF.ONLY-B', kind: 'ef' },
|
||||
{ path: 'ADF.USIM/6F07', name: 'EF.MY-IMSI', kind: 'ef' },
|
||||
];
|
||||
pysimCustomInject(t.mf);
|
||||
assert.strictEqual(t.mf.children.length, 4, 'MF-level injection adds nothing');
|
||||
pysimCustomInject(t.dfA);
|
||||
assert.strictEqual(t.dfA.children.length, 1);
|
||||
assert.strictEqual(t.dfA.children[0].name, 'EF.ONLY-A');
|
||||
assert.strictEqual(t.dfA.children[0].customPath, 'MF/5F01/6F46');
|
||||
pysimCustomInject(t.dfB);
|
||||
assert.strictEqual(t.dfB.children[0].name, 'EF.ONLY-B');
|
||||
pysimCustomInject(t.dfC);
|
||||
assert.strictEqual(t.dfC.children.length, 1, 'unrelated DF is untouched');
|
||||
pysimCustomInject(t.adf);
|
||||
assert.strictEqual(t.adf.children.length, 1);
|
||||
assert.strictEqual(t.adf.children[0].name, 'EF.MY-IMSI');
|
||||
assert.strictEqual(t.adf.children[0].customPath, 'ADF.USIM/6F07');
|
||||
});
|
||||
|
||||
test('injection renames and marks an existing model node', () => {
|
||||
const t = tree();
|
||||
pysimCustomFiles = [{ path: 'MF/5F03/6F46', name: 'EF.RENAMED', kind: 'ef' }];
|
||||
pysimCustomInject(t.dfC);
|
||||
assert.strictEqual(t.dfC.children.length, 1);
|
||||
assert.strictEqual(t.dfC.children[0].name, 'EF.RENAMED');
|
||||
assert.strictEqual(t.dfC.children[0].custom, true);
|
||||
});
|
||||
|
||||
test('injected DFs are directories and can host their own children', () => {
|
||||
const t = tree();
|
||||
pysimCustomFiles = [
|
||||
{ path: 'MF/5F10', name: 'DF.NEW', kind: 'df' },
|
||||
{ path: 'MF/5F10/6F46', name: 'EF.UNDER-NEW', kind: 'ef' },
|
||||
];
|
||||
pysimCustomInject(t.mf);
|
||||
const df = t.mf.children.find(c => c.fid === '5F10');
|
||||
assert.ok(df, 'custom DF injected into MF');
|
||||
assert.strictEqual(df.isDir, true);
|
||||
assert.strictEqual(pysimFsNodePath(df), 'MF/5F10');
|
||||
pysimCustomInject(df);
|
||||
assert.strictEqual(df.children.length, 1);
|
||||
assert.strictEqual(df.children[0].name, 'EF.UNDER-NEW');
|
||||
});
|
||||
|
||||
test('refresh restores renamed model nodes and drops injected ones', () => {
|
||||
const t = tree();
|
||||
pysimFsTreeRoot = t.mf;
|
||||
pysimCustomFiles = [{ path: 'MF/5F03/6F46', name: 'EF.RENAMED', kind: 'ef' }];
|
||||
pysimCustomRefreshTree();
|
||||
assert.strictEqual(t.dfC.children[0].name, 'EF.RENAMED');
|
||||
assert.strictEqual(t.dfC.children[0].custom, true);
|
||||
// deleting the entry brings the model name back
|
||||
pysimCustomFiles = [];
|
||||
pysimCustomRefreshTree();
|
||||
assert.strictEqual(t.dfC.children[0].name, 'EF.X');
|
||||
assert.ok(!t.dfC.children[0].custom);
|
||||
assert.strictEqual(t.dfC.children[0].modelName, undefined);
|
||||
// injected nodes disappear together with their entry
|
||||
pysimCustomFiles = [{ path: 'MF/5F10', name: 'DF.NEW', kind: 'df' }];
|
||||
pysimCustomRefreshTree();
|
||||
assert.ok(t.mf.children.some(c => c.fid === '5F10'));
|
||||
pysimCustomFiles = [];
|
||||
pysimCustomRefreshTree();
|
||||
assert.ok(!t.mf.children.some(c => c.fid === '5F10'));
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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 extractBlock(startMarker, endMarker) {
|
||||
const start = html.indexOf(startMarker);
|
||||
const end = html.indexOf(endMarker, start);
|
||||
if (start < 0 || end < 0) throw new Error('block not found');
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Rewrite top-level const -> var so the maps leak out of sloppy-mode eval.
|
||||
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'cmdQualifierShort'));
|
||||
eval(extractBlock('const REJECTION_CAUSES = [', 'const EVENT_FORMS = {').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const EVENT_FORMS = {', 'const PLI_QUALIFIERS = [').replace(/^const /gm, 'var '));
|
||||
|
||||
test('CMD_NAMES decodes timer management and the BIP commands', () => {
|
||||
assert.strictEqual(CMD_NAMES['27'], 'TIMER MANAGEMENT');
|
||||
assert.strictEqual(CMD_NAMES['40'], 'OPEN CHANNEL');
|
||||
assert.strictEqual(CMD_NAMES['41'], 'CLOSE CHANNEL');
|
||||
assert.strictEqual(CMD_NAMES['42'], 'RECEIVE DATA');
|
||||
assert.strictEqual(CMD_NAMES['43'], 'SEND DATA');
|
||||
assert.strictEqual(CMD_NAMES['44'], 'GET CHANNEL STATUS');
|
||||
});
|
||||
|
||||
test('cmdQualifierShort decodes TIMER MANAGEMENT actions', () => {
|
||||
assert.strictEqual(cmdQualifierShort('27', 0x00), 'Start');
|
||||
assert.strictEqual(cmdQualifierShort('27', 0x01), 'Deactivate');
|
||||
assert.strictEqual(cmdQualifierShort('27', 0x02), 'Get');
|
||||
});
|
||||
|
||||
test('cmdQualifierShort decodes OPEN CHANNEL qualifier flags', () => {
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x00), 'OnDemand');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x01), 'Immediate');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x03), 'Immediate+AutoReconn');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x05), 'Background');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x0C), 'Background+DNS');
|
||||
});
|
||||
|
||||
test('cmdQualifierShort returns empty for unknown types', () => {
|
||||
assert.strictEqual(cmdQualifierShort('99', 0x01), '');
|
||||
});
|
||||
|
||||
test('channel status event builds the B8 channel status TLV', () => {
|
||||
const build = EVENT_FORMS[0x0A].build;
|
||||
assert.strictEqual(EVENT_FORMS[0x0A].note, undefined);
|
||||
assert.strictEqual(build({ channel: '2', state: '128', info: '5' }), 'B8028205');
|
||||
assert.strictEqual(build({ channel: '1', state: '0', info: '0' }), 'B8020100');
|
||||
assert.strictEqual(build({ channel: '0', state: '64', info: '0' }), 'B8024000');
|
||||
});
|
||||
@@ -22,7 +22,7 @@ function extractFunc(src, name) {
|
||||
}
|
||||
|
||||
let code = 'var pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n';
|
||||
for (const fn of ['getParentSel', 'getParentPath', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsProbeUi', 'pysimFsProbeAll']) {
|
||||
for (const fn of ['getParentSel', 'getParentPath', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsFitTree', 'pysimFsProbeUi', 'pysimFsProbeAll']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimCustomInject = () => {};\n';
|
||||
|
||||
@@ -13,10 +13,40 @@ 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', '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('PLI qualifier tables cover all standard qualifiers', () => {
|
||||
// ESN (07), MEID (0B) and Supported RATs (1A) must at least be named, in
|
||||
// both the TR Config dictionary and the proactive-log short labels.
|
||||
const pli = /const PLI_QUALIFIERS = \[([\s\S]*?)\];/.exec(html);
|
||||
assert.ok(pli, 'PLI_QUALIFIERS not found');
|
||||
for (const code of ['07', '0B', '1A']) {
|
||||
assert.ok(pli[1].includes("{code:'" + code + "'"), 'PLI_QUALIFIERS missing ' + code);
|
||||
}
|
||||
const block = /const CMD_QUALIFIER_SHORT = \{([\s\S]*?)\n\};/.exec(html);
|
||||
assert.ok(block, 'CMD_QUALIFIER_SHORT not found');
|
||||
const short = /'26': \{([^}]*)\}/.exec(block[1]);
|
||||
assert.ok(short, "CMD_QUALIFIER_SHORT['26'] not found");
|
||||
for (const key of ['0x07', '0x0B', '0x1A']) {
|
||||
assert.ok(short[1].includes(key + ':'), 'CMD_QUALIFIER_SHORT 26 missing ' + key);
|
||||
}
|
||||
});
|
||||
|
||||
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"'));
|
||||
@@ -49,15 +79,43 @@ test('header state indicator and profiler custom-files tab', () => {
|
||||
assert.ok(!html.includes('data-pysim-sub="custom"'));
|
||||
});
|
||||
|
||||
test('header status indicator has a compact ADM badge', () => {
|
||||
assert.ok(html.includes('id="state-indicator-adm"'));
|
||||
});
|
||||
|
||||
test('file manager has FID / Name sort pills', () => {
|
||||
assert.match(html, /data-fs-sort="fid" onclick="pysimFsSetSort\('fid'\)"/);
|
||||
assert.match(html, /data-fs-sort="name" onclick="pysimFsSetSort\('name'\)"/);
|
||||
assert.ok(html.includes('pysim-fs-sort-pill'));
|
||||
});
|
||||
|
||||
test('file manager keeps sort/probe controls above the scrolling tree', () => {
|
||||
// The sort pills and the Probe all files button/status must sit outside
|
||||
// the scrolling tree container so they stay visible while it scrolls.
|
||||
assert.ok(html.indexOf('id="pysim-fs-probe-btn"') < html.indexOf('id="pysim-fs-tree"'));
|
||||
assert.ok(html.indexOf('pysim-fs-sort-pill') < html.indexOf('id="pysim-fs-tree"'));
|
||||
assert.match(html, /style="max-height:65vh"[^>]*>\s*<div id="pysim-fs-tree">/);
|
||||
// the runtime fit caps it to the free viewport space; 65vh stays only as
|
||||
// the no-JS fallback
|
||||
assert.match(html, /function pysimFsFitTree\(/);
|
||||
assert.match(html, /addEventListener\('resize', pysimFsFitTree\)/);
|
||||
});
|
||||
|
||||
test('custom-files init runs after the language init', () => {
|
||||
// pysimCustomLoad/RenderParents call t(): running them before
|
||||
// currentLang is initialized throws a TDZ error and aborts the rest
|
||||
// of the script (all later handlers fail with 'before initialization').
|
||||
assert.ok(html.indexOf('// Init custom files') > html.indexOf("let currentLang = 'en';"));
|
||||
});
|
||||
|
||||
test('custom files form has add/save and cancel controls', () => {
|
||||
assert.match(html, /id="pysim-cf-add-btn"[^>]*data-l10n="Add"/);
|
||||
assert.match(html, /id="pysim-cf-cancel-btn"[^>]*class="hidden[^"]*"[^>]*data-l10n="Cancel"/);
|
||||
// canonical path form: root + parent DF + 4-hex FID, no free-form path
|
||||
assert.ok(html.includes('id="pysim-cf-root"'));
|
||||
assert.ok(html.includes('id="pysim-cf-parent"'));
|
||||
assert.ok(html.includes('id="pysim-cf-fid"'));
|
||||
assert.ok(!html.includes('id="pysim-cf-path"'));
|
||||
assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()"));
|
||||
assert.ok(!html.includes('pysimCustomAdd'));
|
||||
});
|
||||
@@ -81,3 +139,10 @@ test('file manager shows FCI info and keeps the selection in state, not the DOM'
|
||||
assert.ok(html.includes('pysimFsSelected = name;'));
|
||||
assert.ok(!html.includes('pysimFsSelect()'));
|
||||
});
|
||||
|
||||
test('profile list has a Profile from snapshot button', () => {
|
||||
assert.match(html, /data-l10n="Profile from snapshot">Profile from snapshot</);
|
||||
assert.ok(html.includes('onclick="profilerFromSnapshot()"'));
|
||||
assert.ok(html.includes('function profilerScanFromSnapshot(si)'));
|
||||
assert.ok(html.includes('function profilerBuildFileRuleFromSnapshot('));
|
||||
});
|
||||
|
||||
@@ -27,7 +27,8 @@ const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
|
||||
'globalThis.pysimEventsRender = () => { globalThis._events = (globalThis._events || 0) + 1; };\n' +
|
||||
'globalThis.pysimProactiveLogRender = () => { globalThis._log = (globalThis._log || 0) + 1; };\n' +
|
||||
'globalThis.pysimPollStatusInit = () => { globalThis._poll = (globalThis._poll || 0) + 1; };\n' +
|
||||
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n';
|
||||
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n' +
|
||||
'globalThis.tpRefresh = () => { globalThis._tp = (globalThis._tp || 0) + 1; };\n';
|
||||
eval(code);
|
||||
|
||||
function makeClassList() {
|
||||
@@ -52,7 +53,7 @@ function setup() {
|
||||
getElementById: id => panels[id] || null,
|
||||
};
|
||||
globalThis._anchor = null;
|
||||
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = 0;
|
||||
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = globalThis._tp = 0;
|
||||
return { buttons, panels };
|
||||
}
|
||||
|
||||
@@ -80,4 +81,5 @@ test('Phone pill shows the phone panel and renders CAT views', () => {
|
||||
assert.strictEqual(globalThis._log, 1);
|
||||
assert.strictEqual(globalThis._poll, 1);
|
||||
assert.strictEqual(globalThis._pli, 0);
|
||||
assert.strictEqual(globalThis._tp, 1);
|
||||
});
|
||||
|
||||
+281
-33
@@ -21,14 +21,18 @@ 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'];
|
||||
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'pysimCustomNormPath', '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';
|
||||
code += extractFunc(html, 'profilerRunRule', true) + '\n';
|
||||
code += extractFunc(html, 'profilerScanCard', true) + '\n';
|
||||
code += extractFunc(html, 'profilerBuildSnapshotFile', true) + '\n';
|
||||
code += "var _scanTarget = 'profile';\n";
|
||||
code += extractFunc(html, 'profilerCardIccid', true) + '\n';
|
||||
code += extractFunc(html, 'profilerCheck', true) + '\n';
|
||||
code += extractFunc(html, 'profilerCheckSnapshot', true) + '\n';
|
||||
code += extractFunc(html, 'profilerScanSnapshot', true) + '\n';
|
||||
code += "var _scanTarget = 'profile';\nvar profilerResults = null;\nvar profilerResultsHeader = null;\nvar profilerMismatchOnly = false;\n";
|
||||
code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
|
||||
eval(code);
|
||||
|
||||
@@ -431,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: [
|
||||
@@ -507,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;
|
||||
});
|
||||
|
||||
@@ -549,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;
|
||||
});
|
||||
|
||||
@@ -943,6 +1004,32 @@ test('profilerScanCard snapshot mode builds snapshot entries with ICCID', async
|
||||
delete global.pysimCustomFiles;
|
||||
});
|
||||
|
||||
test('profilerScanCard walks nested dirs with their parent path, not their own segment', async () => {
|
||||
global.pysimCustomFiles = [];
|
||||
const treeCalls = [];
|
||||
global.pysimFetch = async (path, body) => {
|
||||
if (path === '/api/tree') {
|
||||
treeCalls.push(body);
|
||||
if (body.name === 'MF') return { exists: true, name: 'MF', children: [
|
||||
{ name: 'EF.DIR', fid: '2f00', isDir: false },
|
||||
{ name: 'DF.GSM', fid: '7f20', isDir: true },
|
||||
] };
|
||||
if (body.name === 'DF.GSM') return { exists: true, name: 'DF.GSM', children: [
|
||||
{ name: 'EF.ADN', fid: '6f3a', isDir: false },
|
||||
] };
|
||||
throw new Error('unexpected tree ' + body.name);
|
||||
}
|
||||
if (path === '/api/select') return { name: 'X', fid: '0000', file_type: 'transparent', file_size: 1, record_len: null, num_of_rec: null, exists: true };
|
||||
if (path === '/api/read') return { success: true, data: 'AA' };
|
||||
throw new Error('unexpected ' + path);
|
||||
};
|
||||
const files = await profilerScanCard(new Set(), new Set(), 'type', undefined, new Set(), 'snapshot');
|
||||
// MF root has no parent; DF.GSM must be looked up under MF, not under itself
|
||||
assert.deepStrictEqual(treeCalls.map(b => b.parent_path), [undefined, ['MF']]);
|
||||
assert.deepStrictEqual(files.map(f => f.path).sort(), ['MF/2F00', 'MF/7F20/6F3A']);
|
||||
delete global.pysimCustomFiles;
|
||||
});
|
||||
|
||||
test('profilerListSwitch toggles the profiles/snapshots tabs', () => {
|
||||
const mkBtn = tab => ({
|
||||
dataset: { listTab: tab },
|
||||
@@ -1086,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');
|
||||
@@ -1095,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');
|
||||
@@ -1125,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')]);
|
||||
@@ -1303,3 +1378,176 @@ test('fcpDiffHtml headers use custom labels', () => {
|
||||
assert.ok(diff.includes('>Candidate<'), diff);
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerLabelText builds default, verbatim and prefixed labels', () => {
|
||||
global.t = s => 't:' + s;
|
||||
assert.strictEqual(profilerLabelText(null, 'expected'), 't:expected');
|
||||
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B' }, 'expected'), 'A');
|
||||
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B' }, 'actual'), 'B');
|
||||
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B', prefix: true }, 'actual'), 't:actual (B)');
|
||||
assert.strictEqual(profilerLabelText({ expected: '', prefix: true }, 'expected'), 't:expected');
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerRenderReport prefixes names in expected/actual labels', () => {
|
||||
global.t = s => s;
|
||||
global.pysimCustomFiles = [];
|
||||
const html = profilerRenderReport([{
|
||||
path: 'MF/6F3A', status: 'fail',
|
||||
checks: [{ label: 'fileSize', expected: 4, actual: 9, ok: false }],
|
||||
}], { expected: 'My profile', actual: 'Snap X', prefix: true });
|
||||
assert.ok(html.includes('expected (My profile)'), html);
|
||||
assert.ok(html.includes('actual (Snap X)'), html);
|
||||
delete global.t;
|
||||
delete global.pysimCustomFiles;
|
||||
});
|
||||
|
||||
test('fcpDiffHtml uses prefixed labels in headers and decode notes', () => {
|
||||
global.t = s => s;
|
||||
const diff = fcpDiffHtml(FCP_TRANSPARENT, '62128002000A8202412183026F078A0105880110', { expected: 'Prof', actual: 'Snap', prefix: true });
|
||||
assert.ok(diff.includes('expected (Prof)'), diff);
|
||||
assert.ok(diff.includes('actual (Snap)'), diff);
|
||||
const bad = fcpDiffHtml('not hex', FCP_TRANSPARENT, { expected: 'Prof', actual: 'Snap', prefix: true });
|
||||
assert.ok(bad.includes('expected (Prof)'), bad);
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerCardIccid reads and decodes MF/2FE2, null otherwise', async () => {
|
||||
let body = null;
|
||||
global.pysimFetch = async (path, b) => { body = [path, b]; return { success: true, data: '98103254769810325476' }; };
|
||||
assert.strictEqual(await profilerCardIccid(), '89012345678901234567');
|
||||
assert.deepStrictEqual(body, ['/api/read', { path: 'MF/2FE2', mode: 'raw' }]);
|
||||
global.pysimFetch = async () => ({ success: false });
|
||||
assert.strictEqual(await profilerCardIccid(), null);
|
||||
global.pysimFetch = async () => { throw new Error('offline'); };
|
||||
assert.strictEqual(await profilerCardIccid(), null);
|
||||
delete global.pysimFetch;
|
||||
});
|
||||
|
||||
test('profilerCheck titles with the card ICCID and passes prefixed labels', async () => {
|
||||
global.t = s => s;
|
||||
global.profiles = [{ name: 'MyProfile', rules: [] }];
|
||||
global.pysimFetch = async () => ({ success: true, data: '98103254769810325476' });
|
||||
let captured = null;
|
||||
global.profilerRunProfile = async (...args) => { captured = args; };
|
||||
await profilerCheck(0);
|
||||
assert.deepStrictEqual(captured[2], { kind: 'profile', from: 'MyProfile', to: '89012345678901234567' });
|
||||
assert.deepStrictEqual(captured[4], { expected: 'MyProfile', actual: '89012345678901234567', prefix: true });
|
||||
delete global.profiles; delete global.pysimFetch; delete global.profilerRunProfile; delete global.t;
|
||||
});
|
||||
|
||||
test('profilerCheckSnapshot passes the snapshot name as the actual label', async () => {
|
||||
global.t = s => s;
|
||||
global.profiles = [{ name: 'Prof', rules: [] }];
|
||||
global.snapshots = [{ name: 'SnapX', files: [] }];
|
||||
global.document = { getElementById: () => ({ classList: { add() {} } }) };
|
||||
let captured = null;
|
||||
global.profilerRunProfile = async (...args) => { captured = args; };
|
||||
await profilerCheckSnapshot(0, 0);
|
||||
assert.deepStrictEqual(captured[2], { kind: 'profile', from: 'Prof', to: 'SnapX' });
|
||||
assert.deepStrictEqual(captured[4], { expected: 'Prof', actual: 'SnapX', prefix: true });
|
||||
delete global.profiles; delete global.snapshots; delete global.profilerRunProfile; delete global.t;
|
||||
});
|
||||
|
||||
test('profilerResultsHeaderText builds profile and snapshot headers', () => {
|
||||
global.t = s => 't:' + s;
|
||||
assert.strictEqual(profilerResultsHeaderText({ kind: 'profile', from: 'Prof', to: '8901' }), 't:Profile verification results for: Prof \u2192 8901');
|
||||
assert.strictEqual(profilerResultsHeaderText({ kind: 'snapshot', from: 'Snap1', to: 'Snap2' }), 't:Snapshot comparison results: Snap1 \u2192 Snap2');
|
||||
assert.strictEqual(profilerResultsHeaderText({ kind: 'profile', from: 'Prof', to: null }), 't:Profile verification results for: Prof');
|
||||
assert.strictEqual(profilerResultsHeaderText(null), '');
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerRenderResultsView writes the built header into the title', () => {
|
||||
global.t = s => s;
|
||||
profilerResults = [];
|
||||
profilerResultsHeader = { kind: 'profile', from: 'Prof', to: 'Snap' };
|
||||
const els = {};
|
||||
for (const id of ['profiler-summary', 'profiler-report', 'profiler-results-title']) els[id] = { innerHTML: '', textContent: '' };
|
||||
global.document = { getElementById: id => els[id] || null };
|
||||
profilerRenderResultsView();
|
||||
assert.strictEqual(els['profiler-results-title'].textContent, 'Profile verification results for: Prof \u2192 Snap');
|
||||
profilerResultsHeader = { kind: 'snapshot', from: 'A', to: 'B' };
|
||||
profilerRenderResultsView();
|
||||
assert.strictEqual(els['profiler-results-title'].textContent, 'Snapshot comparison results: A \u2192 B');
|
||||
profilerResults = null;
|
||||
profilerResultsHeader = null;
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerBuildFileRuleFromSnapshot builds a rule with exact contents', () => {
|
||||
const f = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'transparent', fileSize: 4, recordLen: null, numRecords: null, fciHex: '620B', content: { kind: 'transparent', data: 'AABBCCDD' } };
|
||||
const rule = profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type_size', new Set());
|
||||
assert.strictEqual(rule.path, 'MF/7F10/6F3A');
|
||||
assert.strictEqual(rule.name, 'EF.ADN');
|
||||
assert.strictEqual(rule.fileType, 'transparent');
|
||||
assert.strictEqual(rule.fileSize, 4);
|
||||
assert.strictEqual(rule.fciMode, 'type_size');
|
||||
assert.strictEqual(rule.fciHex, '620B');
|
||||
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'transparent', expected: 'AABBCCDD' });
|
||||
});
|
||||
|
||||
test('profilerBuildFileRuleFromSnapshot masks the first 4 bytes on request', () => {
|
||||
const f = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: '0891101234567890' } };
|
||||
assert.deepStrictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set(['6F07'])).content,
|
||||
{ mode: 'mask', kind: 'transparent', expected: '08911012????????' });
|
||||
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set()).content.mode, 'exact');
|
||||
});
|
||||
|
||||
test('profilerBuildFileRuleFromSnapshot honors the ignore list by FID and name', () => {
|
||||
const f = { path: 'MF/7F20/6F52', name: 'EF.KcGPRS', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: 'AABB' } };
|
||||
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(['6F52']), new Set(), 'type_size', new Set()).content, null);
|
||||
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(['EF.KCGPRS']), 'type_size', new Set()).content, null);
|
||||
});
|
||||
|
||||
test('profilerBuildFileRuleFromSnapshot keeps uncaptured and record contents', () => {
|
||||
const noContent = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: null };
|
||||
assert.strictEqual(profilerBuildFileRuleFromSnapshot(noContent, new Set(), new Set(), 'type_size', new Set()).content, null);
|
||||
const records = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'linear_fixed', fileSize: 60, recordLen: 30, numRecords: 2, content: { kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] } };
|
||||
const rule = profilerBuildFileRuleFromSnapshot(records, new Set(), new Set(), 'exact', new Set());
|
||||
assert.strictEqual(rule.fileSize, null);
|
||||
assert.strictEqual(rule.recordLen, 30);
|
||||
assert.strictEqual(rule.numRecords, 2);
|
||||
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] });
|
||||
});
|
||||
|
||||
test('profilerScanSnapshot walks snapshot files with progress', async () => {
|
||||
const snapshot = { files: [
|
||||
{ path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', content: null },
|
||||
{ path: 'MF/2FE2', name: 'EF.ICCID', fileType: 'transparent', content: { kind: 'transparent', data: '9807' } },
|
||||
] };
|
||||
const progress = [];
|
||||
const rules = await profilerScanSnapshot(snapshot, new Set(), new Set(), 'type_size', new Set(), (d, tt, p) => progress.push([d, tt, p]));
|
||||
assert.strictEqual(rules.length, 2);
|
||||
assert.deepStrictEqual(rules.map(r => r.path), ['MF/6F07', 'MF/2FE2']);
|
||||
assert.deepStrictEqual(progress, [[0, 2, ''], [1, 2, 'MF/6F07'], [2, 2, 'MF/2FE2']]);
|
||||
});
|
||||
|
||||
test('profilerSnapshotPickListHtml wires the chosen action per snapshot', () => {
|
||||
global.t = s => s;
|
||||
global.snapshots = [
|
||||
{ name: 'SnapA', iccid: '8901', created: '2026-01-01T00:00:00Z', files: [{}, {}] },
|
||||
{ name: 'SnapB', iccid: '', created: '2026-01-02T00:00:00Z', files: [] },
|
||||
];
|
||||
const html = profilerSnapshotPickListHtml(si => 'profilerScanFromSnapshot(' + si + ')');
|
||||
assert.ok(html.includes('onclick="profilerScanFromSnapshot(0)"'), html);
|
||||
assert.ok(html.includes('onclick="profilerScanFromSnapshot(1)"'), html);
|
||||
assert.ok(html.includes('SnapA'), html);
|
||||
assert.ok(html.includes('2 files'), html);
|
||||
delete global.snapshots;
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
test('profilerScanSetTarget labels the snapshot-sourced profile form', () => {
|
||||
global.t = s => s;
|
||||
const els = {};
|
||||
for (const id of ['profiler-scan-title', 'profiler-scan-name-label', 'profiler-scan-options']) {
|
||||
els[id] = { textContent: '', attrs: {}, classList: { set: new Set(), toggle(c, on) { if (on) this.set.add(c); else this.set.delete(c); }, contains(c) { return this.set.has(c); } }, setAttribute(k, v) { this.attrs[k] = v; } };
|
||||
}
|
||||
global.document = { getElementById: id => els[id] || null };
|
||||
profilerScanSetTarget('profile-snapshot');
|
||||
assert.strictEqual(els['profiler-scan-title'].textContent, 'Profile from snapshot');
|
||||
assert.strictEqual(els['profiler-scan-name-label'].textContent, 'Profile name');
|
||||
assert.ok(!els['profiler-scan-options'].classList.contains('hidden'));
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
+161
-2
@@ -6,7 +6,7 @@ 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 re = new RegExp('(?:async\\s+)?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;
|
||||
@@ -22,13 +22,18 @@ function extractFunc(src, name) {
|
||||
}
|
||||
|
||||
// Extract chain builder functions and dependencies
|
||||
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'chainInit', 'chainRamBuildRowHex'];
|
||||
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml',
|
||||
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute'];
|
||||
let code = '';
|
||||
for (const f of FNS) {
|
||||
code += extractFunc(html, f) + '\n';
|
||||
}
|
||||
const m = html.match(/const _chains = \{\};/);
|
||||
if (m) code += m[0].replace(/^const /, 'var ') + '\n';
|
||||
const lc = html.match(/const RAM_LIFECYCLE = \{[\s\S]*?\n\};/);
|
||||
if (lc) code += lc[0].replace(/^const /, 'var ') + '\n';
|
||||
eval(code);
|
||||
code += 'var _ramCardIdx = null;\nvar _ramOpLast = null;\nvar _ramExplorerData = null;\n';
|
||||
eval(code);
|
||||
|
||||
const els = {};
|
||||
@@ -161,3 +166,157 @@ test('STORE DATA ram-enc P1 values 00/40/80/C0/E0', () => {
|
||||
assert.ok(apdu.startsWith('80E2' + p1 + '00'), enc + ' -> P1 ' + p1);
|
||||
}
|
||||
});
|
||||
|
||||
test('ramRenderExploreHtml localizes every label and button', () => {
|
||||
const seen = [];
|
||||
global.t = s => { seen.push(s); return 'XX' + s; };
|
||||
const out = ramRenderExploreHtml(
|
||||
{ appCount: 5, freeNV: 100, freeV: 50 },
|
||||
[{ aid: 'A000000151000000', lifecycle: '07', privileges: '', sdAid: 'A000000151000000' }],
|
||||
[{ aid: 'A1130001180001', lifecycle: '07', privileges: '80', implicitSel: '00', elfAid: 'ELF1' }],
|
||||
[{ aid: 'ELF1', lifecycle: '01', version: '1.0', moduleAids: ['M1'], sdAid: null }]
|
||||
);
|
||||
delete global.t;
|
||||
assert.ok(out.includes('XXDelete'), out);
|
||||
assert.ok(out.includes('XXDelete All'), out);
|
||||
assert.ok(out.includes('XXApplications:'), out);
|
||||
assert.ok(out.includes('XXFree NV:'), out);
|
||||
assert.ok(out.includes('XXFree Volatile:'), out);
|
||||
assert.ok(out.includes('XXAID:'), out);
|
||||
assert.ok(out.includes('XXLifecycle:'), out);
|
||||
assert.ok(out.includes('XXPrivileges:'), out);
|
||||
assert.ok(out.includes('XXSD AID:'), out);
|
||||
assert.ok(out.includes('XXImplicit sel:'), out);
|
||||
assert.ok(out.includes('XXVersion:'), out);
|
||||
assert.ok(seen.includes('Application / Instance AID:'));
|
||||
assert.ok(seen.includes('Load File AID / Package AID:'));
|
||||
assert.ok(seen.includes('Executable Module AIDs / Applet Class AIDs:'));
|
||||
assert.ok(!out.includes('data-l10n'), out);
|
||||
});
|
||||
|
||||
test('ramFmtPrivileges uses the translated (none) placeholder', () => {
|
||||
global.t = s => 'XX' + s;
|
||||
assert.strictEqual(ramFmtPrivileges(''), 'XX(none)');
|
||||
assert.strictEqual(ramFmtPrivileges('00'), 'XX(none)');
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
function fakeClassList() {
|
||||
const set = new Set();
|
||||
return {
|
||||
add: (...cs) => cs.forEach(c => set.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => set.delete(c)),
|
||||
contains: c => set.has(c),
|
||||
toggle: (c, on) => { if (on === undefined ? !set.has(c) : on) set.add(c); else set.delete(c); },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeEl(id) {
|
||||
return {
|
||||
id,
|
||||
value: '',
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
classList: fakeClassList(),
|
||||
options: [],
|
||||
appendChild(opt) { this.options.push(opt); },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeRamDocument(ids) {
|
||||
const els = {};
|
||||
for (const id of ids) els[id] = fakeEl(id);
|
||||
const sel = els['ram-card-sel'];
|
||||
if (sel) {
|
||||
Object.defineProperty(sel, 'innerHTML', {
|
||||
get() { return this._html || ''; },
|
||||
set(v) { this._html = v; this.value = ''; },
|
||||
});
|
||||
}
|
||||
globalThis.document = {
|
||||
getElementById: id => els[id] || null,
|
||||
createElement: () => fakeEl('option'),
|
||||
};
|
||||
return els;
|
||||
}
|
||||
|
||||
test('ramOpChanged clears the executed status only on a real op change', () => {
|
||||
const els = fakeRamDocument(['ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
||||
_ramOpLast = null;
|
||||
els['ram-op'].value = 'explore';
|
||||
ramOpChanged();
|
||||
assert.ok(!els['ram-result'].classList.contains('hidden'));
|
||||
els['ram-result'].classList.remove('hidden');
|
||||
els['ram-steps'].classList.remove('hidden');
|
||||
ramOpChanged();
|
||||
assert.ok(!els['ram-result'].classList.contains('hidden'), 'same op must keep the result');
|
||||
els['ram-op'].value = 'install-cap';
|
||||
ramOpChanged();
|
||||
assert.ok(els['ram-result'].classList.contains('hidden'));
|
||||
assert.ok(els['ram-steps'].classList.contains('hidden'));
|
||||
assert.ok(els['ram-explorer'].classList.contains('hidden'));
|
||||
assert.ok(els['ram-progress'].classList.contains('hidden'));
|
||||
assert.ok(!els['ram-install-params'].classList.contains('hidden'));
|
||||
});
|
||||
|
||||
test('ramRender keeps the selected card preset across rebuilds', () => {
|
||||
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
||||
globalThis.cards = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
|
||||
_ramCardIdx = null;
|
||||
_ramOpLast = 'explore';
|
||||
els['ram-op'].value = 'explore';
|
||||
ramRender();
|
||||
assert.strictEqual(els['ram-card-sel'].value, '');
|
||||
els['ram-card-sel'].value = '1';
|
||||
ramRender();
|
||||
assert.strictEqual(els['ram-card-sel'].value, '1');
|
||||
els['ram-card-sel'].value = '';
|
||||
_ramCardIdx = 2;
|
||||
ramRender();
|
||||
assert.strictEqual(els['ram-card-sel'].value, '2');
|
||||
globalThis.cards = [{ name: 'A' }];
|
||||
_ramCardIdx = 2;
|
||||
ramRender();
|
||||
assert.strictEqual(els['ram-card-sel'].value, '');
|
||||
delete globalThis.cards;
|
||||
});
|
||||
|
||||
test('ramApplyCard remembers a valid picked preset', () => {
|
||||
globalThis.cards = [{ name: 'A' }, { name: 'B' }];
|
||||
let applied = null;
|
||||
globalThis.cardsApply = i => { applied = i; };
|
||||
_ramCardIdx = null;
|
||||
ramApplyCard('1');
|
||||
assert.strictEqual(_ramCardIdx, 1);
|
||||
assert.strictEqual(applied, '1');
|
||||
ramApplyCard('');
|
||||
assert.strictEqual(_ramCardIdx, 1, 'invalid pick must not forget the preset');
|
||||
delete globalThis.cards;
|
||||
delete globalThis.cardsApply;
|
||||
});
|
||||
|
||||
test('ramExecute commits the dropdown selection before running', async () => {
|
||||
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
||||
globalThis.cards = [{ name: 'A' }];
|
||||
globalThis.getRamSpParams = () => ({ kicKey: '11', kidKey: '22' });
|
||||
let explored = false;
|
||||
globalThis.ramExplore = async () => { explored = true; };
|
||||
globalThis.alert = () => {};
|
||||
_ramCardIdx = null;
|
||||
els['ram-card-sel'].value = '0';
|
||||
els['ram-op'].value = 'explore';
|
||||
await ramExecute();
|
||||
assert.strictEqual(_ramCardIdx, 0);
|
||||
assert.ok(explored);
|
||||
delete globalThis.cards;
|
||||
delete globalThis.getRamSpParams;
|
||||
delete globalThis.ramExplore;
|
||||
delete globalThis.alert;
|
||||
});
|
||||
|
||||
test('ramCardIdxAfterRemove keeps the remembered index aligned', () => {
|
||||
assert.strictEqual(ramCardIdxAfterRemove(2, 0), 1);
|
||||
assert.strictEqual(ramCardIdxAfterRemove(0, 0), null);
|
||||
assert.strictEqual(ramCardIdxAfterRemove(0, 2), 0);
|
||||
assert.strictEqual(ramCardIdxAfterRemove(null, 1), null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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);
|
||||
}
|
||||
|
||||
eval(extractFunc(html, 'scp81LogLine'));
|
||||
|
||||
test('scp81LogLine renders a BIP open entry', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 4, kind: 'open', channel: 1, requested: '77.221.153.19:10174', target: '127.0.0.1:8443' }),
|
||||
'4 open ch1 77.221.153.19:10174 -> 127.0.0.1:8443');
|
||||
});
|
||||
|
||||
test('scp81LogLine renders a TLS request with the GP headers', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 5, kind: 'tls-request', method: 'POST', uri: '/server/adminagent?cmd=1', agent: '0123456789', bytes: 0 }),
|
||||
'5 tls-request from=0123456789 POST /server/adminagent?cmd=1');
|
||||
});
|
||||
|
||||
test('scp81LogLine renders handshake and errors', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 6, kind: 'tls-handshake', cipher: 'PSK-AES128-CBC-SHA256', identity: 'id-1' }),
|
||||
'6 tls-handshake id=id-1 PSK-AES128-CBC-SHA256');
|
||||
assert.strictEqual(scp81LogLine({ seq: 7, kind: 'tls-error', error: 'boom' }), '7 tls-error boom');
|
||||
});
|
||||
|
||||
test('scp81 log covers the dump mode kinds', () => {
|
||||
assert.strictEqual(scp81LogLine({ seq: 1, kind: 'dump-rx', bytes: 71 }), '1 dump-rx 71B');
|
||||
});
|
||||
|
||||
test('scp81LogLine renders script entries', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 9, kind: 'script-send', index: 1, apdu: '80CAFF2100' }),
|
||||
'9 script-send #1 80CAFF2100');
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 12, kind: 'script-rapdu', index: 1, sw: '9000', bytes: 14 }),
|
||||
'12 script-rapdu #1 SW 9000 14B');
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 13, kind: 'script-memory', applets: 4, free_nv: 61600, free_volatile: 2048 }),
|
||||
'13 script-memory applets=4 free NV=61600 free vol=2048');
|
||||
});
|
||||
|
||||
eval(extractFunc(html, 'scp81DecodeGetStatus'));
|
||||
eval(extractFunc(html, 'scp81GroupResults'));
|
||||
eval(extractFunc(html, 'scp81ResultLines'));
|
||||
|
||||
test('scp81DecodeGetStatus decodes complete entries', () => {
|
||||
const entries = scp81DecodeGetStatus('E32A4F08A0000000030000009F70010FC50380DE00C40BD276000005AAFFCAFE0010CC08A000000003000000');
|
||||
assert.strictEqual(entries.length, 1);
|
||||
assert.strictEqual(entries[0].aid, 'A000000003000000');
|
||||
assert.strictEqual(entries[0].lifecycle, '0F');
|
||||
assert.strictEqual(entries[0].privileges, '80DE00');
|
||||
});
|
||||
|
||||
test('scp81DecodeGetStatus reads module AIDs and skips truncated tails', () => {
|
||||
const entries = scp81DecodeGetStatus('E31B4F07A00000015153509F700101CE0201008408A000000151535041' + 'E3204F08D27600');
|
||||
assert.strictEqual(entries.length, 1);
|
||||
assert.strictEqual(entries[0].aid, 'A0000001515350');
|
||||
assert.strictEqual(entries[0].modules[0], 'A000000151535041');
|
||||
});
|
||||
|
||||
test('scp81GroupResults merges pages under one command', () => {
|
||||
const groups = scp81GroupResults({ results: [
|
||||
{ index: 4, apdu: '80F24002024F0000', sw: 'CAFE', rapdu: 'E3114F08A0000000030000009F70010FC50100' },
|
||||
{ index: 5, apdu: '80F24002114F0F', sw: '9000', rapdu: 'E3114F08A0000000030000009F70010FC50100' },
|
||||
{ index: 1, apdu: '80CAFF2100', sw: '9000', rapdu: 'FF210B81010D8202C5D683020962' },
|
||||
] });
|
||||
assert.strictEqual(groups.length, 2);
|
||||
assert.strictEqual(groups[0].results.length, 2);
|
||||
assert.strictEqual(groups[1].key, '80CAFF');
|
||||
});
|
||||
|
||||
test('scp81ResultLines decodes the memory page', () => {
|
||||
const lines = scp81ResultLines({ apdu: '80CAFF2100', results: [
|
||||
{ rapdu: 'FF210B81010D8202C5D683020962', sw: '9000' } ] });
|
||||
assert.strictEqual(lines[0], 'applets=13 free NV=50646 B free vol=2402 B');
|
||||
});
|
||||
|
||||
test('scp81ResultLines decodes GET STATUS entries', () => {
|
||||
global.decodePrivileges = () => 'Security Domain';
|
||||
try {
|
||||
const lines = scp81ResultLines({ apdu: '80F24002024F0000', results: [
|
||||
{ rapdu: 'E3114F08A0000000030000009F70010FC50100', sw: '9000' } ] });
|
||||
assert.strictEqual(lines[0], 'A000000003000000 life=0F [Security Domain]');
|
||||
} finally {
|
||||
delete global.decodePrivileges;
|
||||
}
|
||||
});
|
||||
|
||||
eval(extractFunc(html, 'scp81Ascii'));
|
||||
eval(extractFunc(html, 'scp81Bcd'));
|
||||
eval(extractFunc(html, 'scp81DecodeAdminParams'));
|
||||
eval(extractFunc(html, 'scp81CmdLabel'));
|
||||
|
||||
test('scp81DecodeAdminParams decodes the stored 0085 answer', () => {
|
||||
const hex = '856F84248103014003820281828500B50103B902058EC70403475042BC03020582BE05215BD50502851814383937303178787878787878787878787878787802400186070001250300100089248A096C6F63616C686F73748B1438393730317878787878787878787878787878788C012F';
|
||||
const lines = scp81DecodeAdminParams(hex);
|
||||
assert.ok(lines.includes('PSK id=89701xxxxxxxxxxxxxxx KVN/KID=40/01'));
|
||||
assert.ok(lines.includes('retry counter=1 timer=00:10:00'));
|
||||
assert.ok(lines.includes('host=localhost'));
|
||||
assert.ok(lines.includes('agent=89701xxxxxxxxxxxxxxx'));
|
||||
assert.ok(lines.includes('uri=/'));
|
||||
assert.ok(lines.includes(' apn=GPB'));
|
||||
assert.ok(lines.includes(' dest=91.213.5.2'));
|
||||
});
|
||||
|
||||
test('scp81CmdLabel names the explore commands', () => {
|
||||
assert.strictEqual(scp81CmdLabel('80CAFF2100'), 'GET DATA FF21 (extended card resources)');
|
||||
assert.strictEqual(scp81CmdLabel('80F24002024F0000'), 'GET STATUS P1=40 (applications and security domains)');
|
||||
assert.strictEqual(scp81CmdLabel('80F22002024F0000'), 'GET STATUS P1=20 (executable load files)');
|
||||
assert.strictEqual(scp81CmdLabel('80F21002024F0000'), 'GET STATUS P1=10 (executable load files and modules)');
|
||||
assert.strictEqual(scp81CmdLabel('80E8800000'), 'LOAD');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -28,7 +28,7 @@ function extractFunc(src, name) {
|
||||
|
||||
const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt',
|
||||
'xorBytes', 'zeroPad', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
|
||||
'aesCmac', 'genSp'];
|
||||
'aesCmac', 'genSp', 'spNextCntr'];
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
|
||||
@@ -217,3 +217,15 @@ test('AES rejects 8-byte key', () => {
|
||||
});
|
||||
assert.strictEqual(err, 'Error: AES KIc key must be 16, 24, or 32 bytes');
|
||||
});
|
||||
|
||||
test('spNextCntr increments with carry', () => {
|
||||
assert.strictEqual(spNextCntr('0000000001'), '0000000002');
|
||||
assert.strictEqual(spNextCntr('00000000FF'), '0000000100');
|
||||
assert.strictEqual(spNextCntr('000000FFFF'), '0000010000');
|
||||
assert.strictEqual(spNextCntr('0000ABCDEF'), '0000ABCDF0');
|
||||
});
|
||||
|
||||
test('spNextCntr tolerates lower case and separators', () => {
|
||||
assert.strictEqual(spNextCntr('00000000 0a'), '000000000B');
|
||||
assert.strictEqual(spNextCntr(''), '0000000001');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const f of ['tpNorm', 'tpValid', 'tpGetBit', 'tpSetBit', 'tpBitLabel', 'tpLayoutGroups']) {
|
||||
code += extractFunc(html, f) + '\n';
|
||||
}
|
||||
code += html.match(/const TP_BITS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||
code += html.match(/const TP_LAYOUT = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||
code += html.match(/const TP_PRESETS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||
eval(code);
|
||||
|
||||
test('tpNorm / tpValid normalize and validate profile hex', () => {
|
||||
assert.strictEqual(tpNorm(' ff ee 00 '), 'FFEE00');
|
||||
assert.strictEqual(tpNorm('zz'), '');
|
||||
assert.ok(tpValid('FF'));
|
||||
assert.ok(tpValid('00FF'));
|
||||
assert.ok(!tpValid(''));
|
||||
assert.ok(!tpValid('F'));
|
||||
});
|
||||
|
||||
test('tpGetBit / tpSetBit address bits MSB-first per byte', () => {
|
||||
const hex = '80' + '01'; // byte 1 b8 set, byte 2 b1 set
|
||||
assert.strictEqual(tpGetBit(hex, 0), true); // byte 1 b8
|
||||
assert.strictEqual(tpGetBit(hex, 7), false); // byte 1 b1
|
||||
assert.strictEqual(tpGetBit(hex, 8), false); // byte 2 b8
|
||||
assert.strictEqual(tpGetBit(hex, 15), true); // byte 2 b1
|
||||
assert.strictEqual(tpGetBit(hex, 16), null); // beyond the profile
|
||||
// toggling keeps the other bits untouched
|
||||
assert.strictEqual(tpSetBit('00', 0, true), '80');
|
||||
assert.strictEqual(tpSetBit('80', 0, false), '00');
|
||||
assert.strictEqual(tpSetBit('FF', 7, false), 'FE');
|
||||
// a bit beyond the current length grows the profile with zero bytes
|
||||
assert.strictEqual(tpSetBit('FF', 24, true), 'FF000080');
|
||||
});
|
||||
|
||||
test('TERMINAL PROFILE bit table matches the spec spot checks', () => {
|
||||
assert.ok(TP_BITS.length >= 312, 'expected the full byte 1..39 table');
|
||||
assert.strictEqual(TP_BITS.length % 8, 0);
|
||||
assert.strictEqual(TP_BITS[0], 'Profile download'); // byte 1 b8
|
||||
assert.strictEqual(TP_BITS[16], 'Proactive UICC: DISPLAY TEXT'); // byte 3 b8
|
||||
assert.strictEqual(TP_BITS[32], 'Proactive UICC: SET UP EVENT LIST'); // byte 5 b8
|
||||
assert.strictEqual(TP_BITS[42], 'Event: Data available'); // byte 6 b3
|
||||
assert.strictEqual(TP_BITS[50], 'Proactive UICC: PERFORM CARD APDU'); // byte 7 b6 (pySim said RESET)
|
||||
assert.strictEqual(TP_BITS[88], 'Proactive UICC: OPEN CHANNEL'); // byte 12 b8
|
||||
assert.strictEqual(TP_BITS[140], 'Proactive UICC: PROVIDE LOCAL INFORMATION (ESN)'); // byte 18 b4
|
||||
assert.strictEqual(TP_BITS[181], 'Proactive UICC: PROVIDE LOCAL INFORMATION (MEID)'); // byte 23 b3
|
||||
assert.strictEqual(TP_BITS[260], 'Proactive UICC: PROVIDE LOCAL INFORMATION (Supported Radio Access Technologies)');
|
||||
assert.strictEqual(TP_BITS[280], 'Data Connection Status Change Event support – PDU Connection'); // byte 36 b8
|
||||
assert.strictEqual(tpBitLabel(0), 'Profile download');
|
||||
assert.match(tpBitLabel(400), /^RFU \(byte 51 b/); // beyond the table
|
||||
});
|
||||
|
||||
test('3GPP-defined bits use the TS 31.111 names, not placeholders', () => {
|
||||
assert.ok(!TP_BITS.some(l => /reserved by 3gpp/i.test(l)), 'no "reserved by 3GPP" labels');
|
||||
assert.ok(!TP_BITS.some(l => /reserved by etsi/i.test(l)));
|
||||
// a few audited 3GPP bits (TS 31.111 5.2)
|
||||
const byteLabels = b => TP_BITS.slice((b - 1) * 8, b * 8);
|
||||
assert.strictEqual(byteLabels(17)[6], 'E-UTRAN'); // byte 17 b2
|
||||
assert.strictEqual(byteLabels(17)[7], 'HSDPA'); // byte 17 b1
|
||||
assert.strictEqual(byteLabels(18)[5], 'CALL CONTROL on GPRS'); // byte 18 b3
|
||||
assert.strictEqual(byteLabels(25)[4], 'Event: Network Rejection for GERAN/UTRAN');
|
||||
assert.strictEqual(byteLabels(32)[0], 'IMS support'); // byte 32 b8
|
||||
assert.strictEqual(byteLabels(34)[0], 'URI support for SEND SHORT MESSAGE');
|
||||
assert.match(byteLabels(39)[0], /NG-RAN\/Satellite NG-RAN Timing Advance/);
|
||||
});
|
||||
|
||||
test('TERMINAL PROFILE presets are valid even-length hex', () => {
|
||||
assert.ok(TP_PRESETS.length >= 9);
|
||||
// the project default stays first (it matches the CLI default profile)
|
||||
assert.match(TP_PRESETS[0].name, /Xiaomi Mi A1/);
|
||||
const names = TP_PRESETS.map(p => p.name);
|
||||
for (const model of ['Quectel GSM module', 'Samsung S21+ 5G', 'Samsung A55 5G',
|
||||
'Xiaomi Redmi Note 10 LTE', 'Sony Xperia Z5c LTE', 'Huawei E5573c / M150 (LTE)',
|
||||
'Huawei E173 3G modem', 'Nokia 7210 2G']) {
|
||||
assert.ok(names.some(n => n.includes(model)), model);
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const p of TP_PRESETS) {
|
||||
assert.ok(p.name && p.profile, p.name);
|
||||
assert.ok(/^[0-9A-F]+$/.test(p.profile) && p.profile.length % 2 === 0, p.name);
|
||||
assert.ok(p.profile.length >= 8 && p.profile.length <= 510, p.name);
|
||||
assert.ok(!seen.has(p.profile), 'duplicate profile: ' + p.name);
|
||||
seen.add(p.profile);
|
||||
}
|
||||
});
|
||||
|
||||
test('Phone tab exposes the TERMINAL PROFILE block and Configure dialog', () => {
|
||||
// the compact block has no room for the hex value: Send + Configure only
|
||||
assert.ok(!html.includes('id="tp-current"'));
|
||||
assert.match(html, /id="tp-send-btn"[^>]*data-needs="card"/);
|
||||
assert.match(html, /id="tp-configure-btn"[^>]*data-needs="server"/);
|
||||
assert.ok(html.includes('id="tp-modal"'));
|
||||
assert.ok(html.includes('id="tp-preset"'));
|
||||
assert.ok(html.includes('id="tp-hex"'));
|
||||
assert.ok(html.includes('id="tp-form"'));
|
||||
assert.ok(html.includes('id="tp-apply-btn"'));
|
||||
// the preset select sits above the hex field, not next to it
|
||||
assert.ok(html.indexOf('id="tp-preset"') < html.indexOf('id="tp-hex"'));
|
||||
// Apply/Cancel sit at the top (right of the preset/hex fields), above the
|
||||
// long bits grid, so they are reachable without scrolling
|
||||
assert.ok(html.indexOf('id="tp-apply-btn"') < html.indexOf('id="tp-form"'));
|
||||
});
|
||||
|
||||
test('tpLayoutGroups lays the byte blocks out in the configured columns', () => {
|
||||
assert.deepStrictEqual(tpLayoutGroups(33), [
|
||||
{ from: 1, to: 12, cols: 2 },
|
||||
{ from: 13, to: 16, cols: 4 },
|
||||
{ from: 17, to: 18, cols: 2 },
|
||||
{ from: 19, to: 21, cols: 3 },
|
||||
{ from: 22, to: 25, cols: 2 },
|
||||
{ from: 26, to: 28, cols: 3 },
|
||||
{ from: 29, to: 30, cols: 2 },
|
||||
{ from: 31, to: 33, cols: 1 },
|
||||
]);
|
||||
// short profiles clamp: only existing bytes get a group
|
||||
assert.deepStrictEqual(tpLayoutGroups(8), [{ from: 1, to: 8, cols: 2 }]);
|
||||
assert.deepStrictEqual(tpLayoutGroups(18), [
|
||||
{ from: 1, to: 12, cols: 2 },
|
||||
{ from: 13, to: 16, cols: 4 },
|
||||
{ from: 17, to: 18, cols: 2 },
|
||||
]);
|
||||
// long profiles: everything past byte 30 is one per row
|
||||
assert.deepStrictEqual(tpLayoutGroups(40).slice(-1), [{ from: 31, to: 40, cols: 1 }]);
|
||||
});
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-otaman-server"
|
||||
version = "2.1.1"
|
||||
version = "2.2.11"
|
||||
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.
|
||||
|
||||
@@ -42,8 +42,10 @@ def main():
|
||||
help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)')
|
||||
parser.add_argument('--sms-sm-sc', default='12345678912', metavar='DIGITS',
|
||||
help='SM-SC address for SMS-SUBMIT routing in PoR-in-submit mode (default: 12345678912)')
|
||||
parser.add_argument('--terminal-profile', default='7FFFFFFFFF0000CF02', metavar='HEX',
|
||||
help='TERMINAL PROFILE payload (default: 10-byte profile with SMS-PP download and event list)')
|
||||
parser.add_argument('--terminal-profile',
|
||||
default='FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003',
|
||||
metavar='HEX',
|
||||
help='TERMINAL PROFILE payload (default: the 33-byte profile of a real BIP-capable handset - the live card only starts HTTP OTA when BIP events/commands are advertised)')
|
||||
parser.add_argument('--poll-interval', type=int, default=30, metavar='SECS',
|
||||
help='Idle interval before automatic STATUS polling (1-255 seconds, default: 30). Disable with --poll-interval 0')
|
||||
parser.add_argument('--no-card-init', action='store_true', default=False,
|
||||
@@ -177,6 +179,7 @@ def main():
|
||||
server.sms_sc = opts.sms_sm_sc
|
||||
server.log_requests = opts.log_requests
|
||||
server.terminal_profile = opts.terminal_profile
|
||||
server.cli_terminal_profile = opts.terminal_profile
|
||||
server.web_dir = opts.web_dir
|
||||
server.sim_menu = sim_menu
|
||||
server.event_list = event_list
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
"""HTTP OTA (SCP81 / GP RAM over HTTP) emulation.
|
||||
|
||||
Phase A: terminal-side BIP emulation (OPEN/SEND/RECEIVE/CLOSE CHANNEL) plus a
|
||||
raw TCP capture listener. The card's BIP channel is always redirected to the
|
||||
locally configured target (the future PSK TLS platform); the address the card
|
||||
requested is only logged.
|
||||
|
||||
Reference behavior (TS 102 223 8.52-8.56, GP v2.2 Amendment B) is taken from
|
||||
the captured real-terminal traces in samples/HTTP_OTA/traces:
|
||||
OPEN CHANNEL TR: result, Channel status (38), Bearer description (35), Buffer size (39)
|
||||
SEND DATA TR: result, Channel data length (37)
|
||||
RECEIVE DATA TR: result, Channel data (36), Channel data length (37)
|
||||
CLOSE CHANNEL TR: result
|
||||
"""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
MAX_LOG = 1000
|
||||
|
||||
|
||||
def ber_len_read(data, off):
|
||||
"""Read a BER-TLV length at data[off]; returns (length, next_offset)."""
|
||||
if off >= len(data):
|
||||
return 0, off
|
||||
b = data[off]
|
||||
if b < 0x80:
|
||||
return b, off + 1
|
||||
n = b & 0x7F
|
||||
if n == 0 or off + 1 + n > len(data):
|
||||
return 0, len(data)
|
||||
return int.from_bytes(data[off + 1:off + 1 + n], 'big'), off + 1 + n
|
||||
|
||||
|
||||
def proactive_tlvs(raw):
|
||||
"""Top-level TLV map {tag: value} of a D0 proactive command."""
|
||||
out = {}
|
||||
if not raw or raw[0] != 0xD0:
|
||||
return out
|
||||
ln, off = ber_len_read(raw, 1)
|
||||
end = min(len(raw), off + ln)
|
||||
while off + 1 < end:
|
||||
tag = raw[off]
|
||||
tlen, off2 = ber_len_read(raw, off + 1)
|
||||
val = raw[off2:off2 + tlen]
|
||||
off = off2 + tlen
|
||||
out.setdefault(tag, val)
|
||||
return out
|
||||
|
||||
|
||||
def parse_other_address(value):
|
||||
"""Decode an 'Other address' TLV (21=IPv4, 57=IPv6, F0=FQDN)."""
|
||||
if not value:
|
||||
return None
|
||||
t = value[0]
|
||||
if t == 0x21 and len(value) >= 5:
|
||||
return '.'.join(str(b) for b in value[1:5])
|
||||
if t == 0x57 and len(value) >= 17:
|
||||
return ':'.join('%x' % int.from_bytes(value[i:i + 2], 'big') for i in range(1, 17, 2))
|
||||
if t == 0xF0:
|
||||
return value[1:].decode('ascii', 'replace')
|
||||
return None
|
||||
|
||||
|
||||
def parse_transport_level(value):
|
||||
"""Decode an UICC/terminal interface transport level TLV -> (proto, port)."""
|
||||
if not value or len(value) < 3:
|
||||
return None, None
|
||||
return value[0], int.from_bytes(value[1:3], 'big')
|
||||
|
||||
|
||||
TAG_BEARER = 0x35
|
||||
TAG_CHANNEL_DATA = 0x36
|
||||
TAG_CHANNEL_DATA_LENGTH = 0x37
|
||||
TAG_CHANNEL_STATUS = 0x38
|
||||
TAG_BUFFER_SIZE = 0x39
|
||||
TAG_TRANSPORT_LEVEL = 0x3C
|
||||
TAG_OTHER_ADDRESS = 0x3E
|
||||
TAG_NAA = 0x47
|
||||
|
||||
|
||||
class BipChannel:
|
||||
def __init__(self, channel_id, sock, requested, target, buffer_size):
|
||||
self.id = channel_id
|
||||
self.sock = sock
|
||||
self.requested = requested
|
||||
self.target = target
|
||||
self.buffer_size = buffer_size or 512
|
||||
self.rx = bytearray()
|
||||
self.bytes_in = 0
|
||||
self.bytes_out = 0
|
||||
self.opened_at = time.time()
|
||||
self.peer_closed = False
|
||||
self.closed_reported = False
|
||||
self.notified_len = 0
|
||||
self.last_notify = 0.0
|
||||
|
||||
def pump(self, timeout=0.05):
|
||||
"""Move whatever the network has into the local buffer. Returns bytes moved."""
|
||||
if self.peer_closed:
|
||||
return 0
|
||||
moved = 0
|
||||
self.sock.settimeout(timeout)
|
||||
try:
|
||||
while True:
|
||||
chunk = self.sock.recv(self.buffer_size)
|
||||
if not chunk:
|
||||
self.peer_closed = True
|
||||
break
|
||||
self.rx.extend(chunk)
|
||||
self.bytes_in += len(chunk)
|
||||
moved += len(chunk)
|
||||
if len(chunk) < self.buffer_size:
|
||||
break
|
||||
except (socket.timeout, BlockingIOError):
|
||||
pass
|
||||
except OSError:
|
||||
self.peer_closed = True
|
||||
return moved
|
||||
|
||||
def send(self, data):
|
||||
self.sock.sendall(data)
|
||||
self.bytes_out += len(data)
|
||||
|
||||
def take(self, maxlen):
|
||||
self.pump()
|
||||
n = min(maxlen, len(self.rx), self.buffer_size)
|
||||
out = bytes(self.rx[:n])
|
||||
del self.rx[:n]
|
||||
return out
|
||||
|
||||
def available(self):
|
||||
self.pump()
|
||||
return len(self.rx)
|
||||
|
||||
def send_capacity(self):
|
||||
free = self.buffer_size - len(self.rx)
|
||||
return 0xFF if free > 0xFF else max(0, free)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class BipTerminal:
|
||||
"""Terminal (device) side of BIP: channels to the configured target."""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = False
|
||||
self.target = None
|
||||
self.channels = {}
|
||||
self.next_id = 1
|
||||
self.entries = []
|
||||
self.seq = 0
|
||||
self.lock = threading.Lock()
|
||||
self.pending_events = []
|
||||
self.on_data = None
|
||||
self._monitor = None
|
||||
|
||||
def log(self, kind, **fields):
|
||||
with self.lock:
|
||||
self.seq += 1
|
||||
entry = {'seq': self.seq, 't': time.time(), 'kind': kind}
|
||||
entry.update(fields)
|
||||
self.entries.append(entry)
|
||||
if len(self.entries) > MAX_LOG:
|
||||
del self.entries[:len(self.entries) - MAX_LOG]
|
||||
return entry
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""Watch channels for incoming bytes and ask the card to fetch them.
|
||||
|
||||
The card only learns about server data through the Data available
|
||||
event (TS 102 223 7.5.10), so the socket must be pumped even while
|
||||
the card is idle."""
|
||||
while True:
|
||||
time.sleep(0.25)
|
||||
with self.lock:
|
||||
channels = list(self.channels.values())
|
||||
for ch in channels:
|
||||
try:
|
||||
ch.pump()
|
||||
except OSError:
|
||||
ch.peer_closed = True
|
||||
if ch.peer_closed and not ch.closed_reported and not ch.rx:
|
||||
# Report a dropped link (TS 102 223 7.5.11) only once the
|
||||
# buffered server data has been fetched: signalling the
|
||||
# drop while bytes are still waiting makes the card abort
|
||||
# the fetch and end the session prematurely.
|
||||
ch.closed_reported = True
|
||||
self.log('peer-close', channel=ch.id)
|
||||
self._queue_link_status(ch.id)
|
||||
if (self.on_data and ch.rx and not ch.peer_closed
|
||||
and (len(ch.rx) > ch.notified_len
|
||||
or time.time() - ch.last_notify > 2.0)):
|
||||
# Re-notify while data stays unfetched: the live card
|
||||
# sometimes needs the Data available event again to drain
|
||||
# a partially received TLS record.
|
||||
if self.on_data(ch):
|
||||
ch.notified_len = len(ch.rx)
|
||||
ch.last_notify = time.time()
|
||||
|
||||
def _start_monitor(self):
|
||||
if self._monitor is None or not self._monitor.is_alive():
|
||||
self._monitor = threading.Thread(target=self._monitor_loop,
|
||||
name='bip-monitor', daemon=True)
|
||||
self._monitor.start()
|
||||
|
||||
def enable(self, host, port):
|
||||
self.target = (host, int(port))
|
||||
self.enabled = True
|
||||
self.log('enabled', target='%s:%d' % self.target)
|
||||
self._start_monitor()
|
||||
|
||||
def disable(self):
|
||||
self.enabled = False
|
||||
self.log('disabled')
|
||||
self.close_all(link_lost=True)
|
||||
self.target = None
|
||||
|
||||
def close_all(self, link_lost=False):
|
||||
for ch in list(self.channels.values()):
|
||||
self._close_channel(ch, link_lost=link_lost)
|
||||
|
||||
def _close_channel(self, ch, link_lost=False):
|
||||
ch.close()
|
||||
if self.channels.get(ch.id) is ch:
|
||||
del self.channels[ch.id]
|
||||
if link_lost:
|
||||
self._queue_link_status(ch.id)
|
||||
|
||||
def _queue_link_status(self, channel_id, status=None, info=0x05):
|
||||
"""Record a BIP link change that did not result from a proactive
|
||||
command (TS 102 223 7.5.11). The default is link not established +
|
||||
info 05 = link dropped; a successful background-mode OPEN CHANNEL
|
||||
reports link established instead. The server turns these into
|
||||
ENVELOPE (Channel status)."""
|
||||
with self.lock:
|
||||
if any(e['channel'] == channel_id for e in self.pending_events):
|
||||
return
|
||||
self.pending_events.append({
|
||||
'channel': channel_id,
|
||||
'status': channel_id & 0x07 if status is None else status,
|
||||
'info': info})
|
||||
|
||||
def take_pending_events(self):
|
||||
with self.lock:
|
||||
events, self.pending_events = self.pending_events, []
|
||||
return events
|
||||
|
||||
def _check_peer(self, ch):
|
||||
"""Notify once per channel when the peer closed the connection, after
|
||||
any buffered data has been fetched (see _monitor_loop)."""
|
||||
if ch.peer_closed and not ch.closed_reported and not ch.rx:
|
||||
ch.closed_reported = True
|
||||
self.log('peer-close', channel=ch.id)
|
||||
self._queue_link_status(ch.id)
|
||||
|
||||
def _alloc_id(self):
|
||||
for _ in range(7):
|
||||
cid = self.next_id
|
||||
self.next_id = 1 if cid >= 7 else cid + 1
|
||||
if cid not in self.channels:
|
||||
return cid
|
||||
return None
|
||||
|
||||
def open(self, requested_host, requested_port, buffer_size):
|
||||
"""Open a channel to the redirect target. Returns (channel_id, error)."""
|
||||
if not self.enabled or not self.target:
|
||||
return None, 'bip disabled'
|
||||
target = self.target
|
||||
requested = '%s:%s' % (requested_host, requested_port)
|
||||
cid = self._alloc_id()
|
||||
if cid is None:
|
||||
self.log('open-fail', requested=requested, reason='no free channel')
|
||||
return None, 'no free channel'
|
||||
try:
|
||||
sock = socket.create_connection(target, timeout=2.0)
|
||||
except OSError as e:
|
||||
self.log('open-fail', requested=requested, target='%s:%d' % target, reason=str(e))
|
||||
return None, str(e)
|
||||
ch = BipChannel(cid, sock, requested, target, buffer_size)
|
||||
self.channels[cid] = ch
|
||||
self.log('open', channel=cid, requested=requested, target='%s:%d' % target,
|
||||
buffer_size=ch.buffer_size)
|
||||
return cid, None
|
||||
|
||||
def send(self, channel_id, data):
|
||||
ch = self.channels.get(channel_id)
|
||||
if not ch:
|
||||
return False
|
||||
try:
|
||||
ch.send(data)
|
||||
except OSError as e:
|
||||
self.log('send-fail', channel=channel_id, error=str(e))
|
||||
self._close_channel(ch, link_lost=True)
|
||||
return False
|
||||
self.log('send', channel=channel_id, bytes=len(data), hex=data.hex().upper()[:2000])
|
||||
return True
|
||||
|
||||
def receive(self, channel_id, maxlen):
|
||||
ch = self.channels.get(channel_id)
|
||||
if not ch:
|
||||
return None
|
||||
data = ch.take(maxlen)
|
||||
if data:
|
||||
self.log('receive', channel=channel_id, bytes=len(data), remaining=len(ch.rx),
|
||||
hex=data.hex().upper()[:2000])
|
||||
# The TR announced the remainder via the channel-data-length TLV,
|
||||
# but the live card still waits for a fresh Data available event
|
||||
# before fetching it - re-arm the notification for what is left.
|
||||
ch.notified_len = 0
|
||||
self._check_peer(ch)
|
||||
return data
|
||||
|
||||
def available(self, channel_id):
|
||||
ch = self.channels.get(channel_id)
|
||||
if not ch:
|
||||
return 0
|
||||
n = ch.available()
|
||||
self._check_peer(ch)
|
||||
return n
|
||||
|
||||
def send_capacity(self, channel_id):
|
||||
ch = self.channels.get(channel_id)
|
||||
if not ch:
|
||||
return 0
|
||||
n = ch.send_capacity()
|
||||
self._check_peer(ch)
|
||||
return n
|
||||
|
||||
def clear_log(self):
|
||||
with self.lock:
|
||||
self.entries = []
|
||||
|
||||
def close(self, channel_id):
|
||||
ch = self.channels.get(channel_id)
|
||||
if not ch:
|
||||
return False
|
||||
self.log('close', channel=channel_id, bytes_in=ch.bytes_in, bytes_out=ch.bytes_out)
|
||||
self._close_channel(ch)
|
||||
return True
|
||||
|
||||
def status(self):
|
||||
channels = []
|
||||
for ch in self.channels.values():
|
||||
channels.append({
|
||||
'id': ch.id,
|
||||
'requested': ch.requested,
|
||||
'target': '%s:%d' % ch.target,
|
||||
'buffer_size': ch.buffer_size,
|
||||
'bytes_in': ch.bytes_in,
|
||||
'bytes_out': ch.bytes_out,
|
||||
'pending': len(ch.rx),
|
||||
'peer_closed': ch.peer_closed,
|
||||
})
|
||||
return {
|
||||
'enabled': self.enabled,
|
||||
'target': '%s:%d' % self.target if self.target else None,
|
||||
'channels': channels,
|
||||
'seq': self.seq,
|
||||
}
|
||||
|
||||
def entries_after(self, after=0):
|
||||
with self.lock:
|
||||
return [e for e in self.entries if e['seq'] > after]
|
||||
|
||||
|
||||
class TcpDumpServer:
|
||||
"""Plain TCP listener that logs whatever it receives (ClientHello capture).
|
||||
|
||||
Used as the BIP redirect target until the PSK TLS platform is brought up.
|
||||
"""
|
||||
|
||||
def __init__(self, host, port, on_rx=None, on_log=None):
|
||||
self.on_rx = on_rx
|
||||
self.on_log = on_log
|
||||
self.stopped = False
|
||||
self.conns = []
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.sock.bind((host, int(port)))
|
||||
self.sock.listen(4)
|
||||
self.host, self.port = self.sock.getsockname()[:2]
|
||||
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
self.thread.start()
|
||||
if self.on_log:
|
||||
self.on_log('listener-start', host=self.host, port=self.port)
|
||||
|
||||
def _accept_loop(self):
|
||||
while not self.stopped:
|
||||
try:
|
||||
self.sock.settimeout(0.2)
|
||||
conn, addr = self.sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
self.conns.append(conn)
|
||||
if self.on_log:
|
||||
self.on_log('conn', peer='%s:%d' % addr[:2])
|
||||
threading.Thread(target=self._conn_loop, args=(conn, addr), daemon=True).start()
|
||||
|
||||
def _conn_loop(self, conn, addr):
|
||||
try:
|
||||
while not self.stopped:
|
||||
conn.settimeout(0.2)
|
||||
try:
|
||||
data = conn.recv(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
if self.on_rx:
|
||||
self.on_rx('%s:%d' % addr[:2], data)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
if self.on_log:
|
||||
self.on_log('listener-stop', host=self.host, port=self.port)
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
for conn in self.conns:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.conns = []
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Phase B: PSK TLS server and HTTP administration session for SCP81.
|
||||
|
||||
Implements the Remote Administration Server side of GP RAM over HTTP
|
||||
(GPC v2.2 Amendment B):
|
||||
|
||||
- TLS 1.2 with the PSK cipher suites of clause 4.3.2. The handshake and
|
||||
record layer are handled by the stdlib ``ssl`` module through OpenSSL's
|
||||
PSK callbacks (identity -> PSK), so no TLS code lives here.
|
||||
- The HTTP dialog of clause 4.4: parse the Security Domain's POST
|
||||
(``X-Admin-*`` headers, optional body with the previous response string)
|
||||
and answer with 200 + a command string, or 204 No Content to close the
|
||||
administration session.
|
||||
|
||||
The card talks TLS *through* the BIP channel: this server listens on the
|
||||
local redirect target and the BIP terminal proxies the card's SEND/RECEIVE
|
||||
DATA records to it.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
|
||||
MAX_HEAD = 32 * 1024
|
||||
MAX_BODY = 1 * 1024 * 1024
|
||||
|
||||
# TLS_PSK_* suites from GPC v2.2 Amendment B Table 4-2 / RFC 4279/4785/5487.
|
||||
PSK_CIPHERS = ':'.join([
|
||||
'PSK-AES128-CBC-SHA256', # TLS_PSK_WITH_AES_128_CBC_SHA256 (0x00AE)
|
||||
'PSK-AES128-CBC-SHA', # TLS_PSK_WITH_AES_128_CBC_SHA (0x008C)
|
||||
'PSK-AES256-CBC-SHA', # TLS_PSK_WITH_AES_256_CBC_SHA (0x008D)
|
||||
'PSK-3DES-EDE-CBC-SHA', # TLS_PSK_WITH_3DES_EDE_CBC_SHA (0x008B)
|
||||
'PSK-NULL-SHA256', # TLS_PSK_WITH_NULL_SHA256 (0x00B0)
|
||||
'PSK-NULL-SHA', # TLS_PSK_WITH_NULL_SHA (0x002C)
|
||||
])
|
||||
|
||||
GP_PROTOCOL = 'globalplatform-remote-admin/1.0'
|
||||
GP_CT_COMMAND = 'application/vnd.globalplatform.card-content-mgt;version=1.0'
|
||||
GP_CT_RESPONSE = 'application/vnd.globalplatform.card-content-mgt-response;version=1.0'
|
||||
|
||||
# OpenSSL SSL_OP_NO_ENCRYPT_THEN_MAC (not exposed by the ssl module). The live
|
||||
# card offers the encrypt_then_mac extension but aborts the session with
|
||||
# SSLV3_ALERT_UNEXPECTED_MESSAGE as soon as the server echoes it, so keep the
|
||||
# extension out of the ServerHello (verified live 2026-09-15).
|
||||
OP_NO_ENCRYPT_THEN_MAC = 0x00080000
|
||||
|
||||
TLS_VERSIONS = {
|
||||
'1.0': ssl.TLSVersion.TLSv1,
|
||||
'1.1': ssl.TLSVersion.TLSv1_1,
|
||||
'1.2': ssl.TLSVersion.TLSv1_2,
|
||||
}
|
||||
|
||||
|
||||
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)."""
|
||||
head = data.split(b'\r\n\r\n', 1)[0]
|
||||
lines = head.split(b'\r\n')
|
||||
parts = lines[0].split(b' ')
|
||||
if len(parts) < 3:
|
||||
raise ValueError('malformed request line')
|
||||
method, target = parts[0].decode('latin-1'), parts[1].decode('latin-1')
|
||||
headers = {}
|
||||
for line in lines[1:]:
|
||||
name, _, value = line.partition(b':')
|
||||
headers[name.strip().decode('latin-1').lower()] = value.strip().decode('latin-1')
|
||||
return method, target, headers
|
||||
|
||||
|
||||
def decode_chunked(body):
|
||||
"""Decode a chunked transfer body (RFC 2616 3.6.1)."""
|
||||
out = bytearray()
|
||||
while body:
|
||||
line, _, rest = body.partition(b'\r\n')
|
||||
try:
|
||||
size = int(line.split(b';')[0], 16)
|
||||
except ValueError:
|
||||
raise ValueError('bad chunk size %r' % line[:16])
|
||||
if size == 0:
|
||||
break
|
||||
out.extend(rest[:size])
|
||||
body = rest[size + 2:]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_http_response(status, reason, headers, body=b'', chunked=False,
|
||||
compact=False, connection=None):
|
||||
"""Build an HTTP response. With chunked=True the body is framed as 100-byte
|
||||
chunks (like the reference admin server); with compact=True header names
|
||||
and values are separated by ':' without whitespace, which keeps the whole
|
||||
response inside one card-sized TLS record (<= 256 bytes ciphertext).
|
||||
connection ('close'/'keep-alive') declares the connection fate: without
|
||||
it an HTTP/1.1 client assumes the connection persists and tries to reuse
|
||||
it for the next POST instead of dialing a new one (live card 2026-09-15)."""
|
||||
lines = ['HTTP/1.1 %d %s' % (status, reason)]
|
||||
sep = ':' if compact else ': '
|
||||
for name, value in headers.items():
|
||||
lines.append('%s%s%s' % (name, sep, value))
|
||||
if connection:
|
||||
lines.append('Connection%s%s' % (sep, connection))
|
||||
has_te = 'transfer-encoding' in [k.lower() for k in headers]
|
||||
if body and (chunked or has_te):
|
||||
if not has_te:
|
||||
lines.append('Transfer-Encoding: chunked')
|
||||
elif body and 'content-length' not in [k.lower() for k in headers]:
|
||||
lines.append('Content-Length%s%d' % (sep, len(body)))
|
||||
head = ('\r\n'.join(lines) + '\r\n\r\n').encode('latin-1')
|
||||
if not body:
|
||||
return head
|
||||
if not chunked:
|
||||
return head + body
|
||||
out = bytearray(head)
|
||||
for i in range(0, len(body), 100):
|
||||
piece = body[i:i + 100]
|
||||
out += ('%X\r\n' % len(piece)).encode('latin-1') + piece + b'\r\n'
|
||||
out += b'0\r\n\r\n'
|
||||
return bytes(out)
|
||||
|
||||
|
||||
class PskTlsServer:
|
||||
"""PSK TLS listener speaking the GP remote administration HTTP dialog."""
|
||||
|
||||
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,
|
||||
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 = _norm_identity(identity)
|
||||
self.on_log = on_log
|
||||
self.responder = responder or self._default_responder
|
||||
self.timeout = timeout
|
||||
self.chunked = chunked
|
||||
# chunk_size 0 = one record for the whole response
|
||||
self.chunk_size = int(chunk_size)
|
||||
self.keep_alive = keep_alive
|
||||
self.compact_headers = compact_headers
|
||||
# The reference traces negotiated TLS 1.0 with PSK-AES128-CBC-SHA;
|
||||
# some cards only speak the older record layer correctly.
|
||||
self.tls_version = tls_version if tls_version in TLS_VERSIONS else '1.2'
|
||||
# Pin one cipher suite (e.g. PSK-AES128-CBC-SHA) if the card's SD only
|
||||
# maps a specific suite to a usable SCP81 security level.
|
||||
self.cipher = cipher or None
|
||||
# Called with the peer address just before closing a non-keep-alive
|
||||
# connection: the server waits until the card has drained the BIP
|
||||
# buffer, otherwise the EOF truncates the response fetch.
|
||||
self.on_before_close = on_before_close
|
||||
# Debug aid: write the TLS traffic secrets to this file
|
||||
# (SSLKEYLOGFILE format), so captures of the PSK dialog can be
|
||||
# decrypted (tshark etc). Contains key material - use a temp path.
|
||||
self.keylog = keylog or None
|
||||
# Connection header value: None = auto ('keep-alive'/'close' per the
|
||||
# keep_alive flag), 'none' = omit the header (Apache-style implicit
|
||||
# HTTP/1.1 keep-alive, as in the working reference trace).
|
||||
self.conn_header = conn_header or None
|
||||
# TLS half-close after a script body. NOTE (live 2026-09-16):
|
||||
# CPython's SSLSocket.unwrap() poisons the session when the peer does
|
||||
# not answer with its own close_notify in time, so this cannot be
|
||||
# implemented with the stdlib ssl module; the flag is kept for the
|
||||
# option surface and for cards that answer promptly (the exception
|
||||
# path leaves the session unusable, so it is off by default).
|
||||
self.half_close = half_close
|
||||
# Wait before answering a request (the reference Apache/PHP servers
|
||||
# answer ~1 s after the card's POST; the card may need its BIP
|
||||
# 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)
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
# A quick Stop -> Start can race the previous listener's close (the
|
||||
# port stays busy for a moment); retry before giving up.
|
||||
last_error = None
|
||||
for _ in range(10):
|
||||
try:
|
||||
self.sock.bind((host, int(port)))
|
||||
last_error = None
|
||||
break
|
||||
except OSError as e:
|
||||
last_error = e
|
||||
time.sleep(0.3)
|
||||
if last_error is not None:
|
||||
self.sock.close()
|
||||
raise last_error
|
||||
self.sock.listen(4)
|
||||
self.host, self.port = self.sock.getsockname()[:2]
|
||||
self.ctx = self._make_context()
|
||||
if self.keylog:
|
||||
try:
|
||||
self.ctx.keylog_filename = self.keylog
|
||||
except (AttributeError, OSError):
|
||||
self.keylog = None
|
||||
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
self.thread.start()
|
||||
self.log('tls-listener-start', host=self.host, port=self.port)
|
||||
|
||||
def log(self, kind, **fields):
|
||||
if self.on_log:
|
||||
try:
|
||||
self.on_log(kind, **fields)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _make_context(self):
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ver = TLS_VERSIONS[self.tls_version]
|
||||
ctx.minimum_version = ver
|
||||
ctx.maximum_version = ver
|
||||
ciphers = self.cipher or PSK_CIPHERS
|
||||
if self.tls_version in ('1.0', '1.1'):
|
||||
# OpenSSL 3.x disables the legacy protocol versions by default.
|
||||
ciphers += ':@SECLEVEL=0'
|
||||
ctx.set_ciphers(ciphers)
|
||||
# Prefer our (AES-first) order over the card's NULL-suite-first list.
|
||||
ctx.options |= ssl.OP_CIPHER_SERVER_PREFERENCE
|
||||
ctx.options |= OP_NO_ENCRYPT_THEN_MAC
|
||||
# No TLS session resumption: the live card aborts with
|
||||
# SSLV3_ALERT_UNEXPECTED_MESSAGE on the post-handshake
|
||||
# NewSessionTicket record (verified live 2026-09-15).
|
||||
ctx.options |= ssl.OP_NO_TICKET
|
||||
ctx.set_psk_server_callback(self._psk_cb)
|
||||
return ctx
|
||||
|
||||
def _psk_cb(self, identity):
|
||||
"""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 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):
|
||||
"""No script configured: close the administration session (4.4.2)."""
|
||||
return 204, {'X-Admin-Protocol': GP_PROTOCOL}, b''
|
||||
|
||||
def _accept_loop(self):
|
||||
while not self.stopped:
|
||||
try:
|
||||
self.sock.settimeout(0.2)
|
||||
conn, addr = self.sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
self.conns.append(conn)
|
||||
peer = '%s:%d' % addr[:2]
|
||||
threading.Thread(target=self._conn_loop, args=(conn, peer),
|
||||
daemon=True).start()
|
||||
|
||||
def _read_request(self, tls):
|
||||
buf = b''
|
||||
while b'\r\n\r\n' not in buf:
|
||||
chunk = tls.recv(4096)
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
if len(buf) > MAX_HEAD:
|
||||
raise ValueError('request head too large')
|
||||
head, _, rest = buf.partition(b'\r\n\r\n')
|
||||
method, target, headers = parse_http_request(head + b'\r\n\r\n')
|
||||
body = rest
|
||||
if 'content-length' in headers:
|
||||
want = int(headers['content-length'])
|
||||
while len(body) < want:
|
||||
chunk = tls.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
body += chunk
|
||||
body = body[:want]
|
||||
elif headers.get('transfer-encoding', '').lower() == 'chunked':
|
||||
while not body.endswith(b'0\r\n\r\n'):
|
||||
chunk = tls.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
body += chunk
|
||||
body = decode_chunked(body)
|
||||
return method, target, headers, body
|
||||
|
||||
def _conn_loop(self, conn, peer):
|
||||
tls = None
|
||||
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,
|
||||
psk_match=self.identity_matched)
|
||||
while not self.stopped:
|
||||
req = self._read_request(tls)
|
||||
if req is None:
|
||||
break
|
||||
method, target, headers, body = req
|
||||
if self.answer_delay > 0:
|
||||
time.sleep(self.answer_delay)
|
||||
self.log('tls-request', peer=peer, method=method, uri=target,
|
||||
headers=headers,
|
||||
agent=headers.get('x-admin-from'),
|
||||
protocol=headers.get('x-admin-protocol'),
|
||||
script_status=headers.get('x-admin-script-status'),
|
||||
resume=headers.get('x-admin-resume'),
|
||||
content_type=headers.get('content-type'),
|
||||
bytes=len(body), body_hex=body.hex().upper()[:2000] or None)
|
||||
status, resp_headers, resp_body = self.responder(
|
||||
method, target, headers, body)
|
||||
reason = {200: 'OK', 204: 'No Content'}.get(status, 'Status')
|
||||
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_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
|
||||
# followed by the body draws an unexpected_message alert. When
|
||||
# a chunk_size is given, the head goes in one record and the
|
||||
# body in pieces of that size.
|
||||
if self.chunk_size <= 0:
|
||||
tls.sendall(response)
|
||||
else:
|
||||
head, sep, rest = response.partition(b'\r\n\r\n')
|
||||
tls.sendall(head + sep if sep else head)
|
||||
for off in range(0, len(rest), self.chunk_size):
|
||||
tls.sendall(rest[off:off + self.chunk_size])
|
||||
self.log('tls-response', peer=peer, status=status,
|
||||
bytes=len(resp_body), chunked=self.chunked,
|
||||
response_hex=response.hex().upper()[:600],
|
||||
body_hex=resp_body.hex().upper()[:2000] or None)
|
||||
# 204 always ends the dialog. Without keep-alive every response
|
||||
# ends it: the card's HTTP client appears to delimit the
|
||||
# response at connection close (live 2026-09-15) and then
|
||||
# starts a fresh session for its next POST.
|
||||
if status == 204 or not resp_body or not self.keep_alive:
|
||||
peer_name = None
|
||||
if resp_body and self.on_before_close:
|
||||
try:
|
||||
peer_name = tls.getpeername()
|
||||
except Exception:
|
||||
peer_name = None
|
||||
plain = None
|
||||
if not self.keep_alive:
|
||||
# Clean TLS shutdown BEFORE the card drains the
|
||||
# buffer: a bare TCP close leaves the card's TLS stack
|
||||
# with a truncated session (it then neither processes
|
||||
# the script nor posts the response), and a
|
||||
# close_notify sent only after the drain is never
|
||||
# fetched. Send it while the response still waits, so
|
||||
# the card reads both, then wait for the buffer to
|
||||
# drain and only then send the FIN.
|
||||
try:
|
||||
tls.settimeout(2.0)
|
||||
plain = tls.unwrap()
|
||||
tls = None
|
||||
except Exception:
|
||||
plain = None
|
||||
if peer_name and self.on_before_close:
|
||||
try:
|
||||
self.on_before_close(peer_name)
|
||||
except Exception:
|
||||
pass
|
||||
if plain is not None:
|
||||
try:
|
||||
plain.close()
|
||||
except OSError:
|
||||
pass
|
||||
break
|
||||
except ssl.SSLError as e:
|
||||
self.log('tls-error', peer=peer, error=str(e))
|
||||
except (OSError, ValueError) as e:
|
||||
self.log('tls-error', peer=peer, error=str(e))
|
||||
finally:
|
||||
if tls is not None:
|
||||
try:
|
||||
tls.close()
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.log('tls-close', peer=peer)
|
||||
if conn in self.conns:
|
||||
self.conns.remove(conn)
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
self.log('tls-listener-stop', host=self.host, port=self.port)
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
for conn in list(self.conns):
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.conns = []
|
||||
+1476
-71
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,454 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for the HTTP OTA (SCP81) BIP terminal emulation (Phase A).
|
||||
|
||||
TR byte vectors come from the captured real-terminal traces in
|
||||
samples/HTTP_OTA/traces (OPEN CHANNEL success/failure, SEND/RECEIVE/CLOSE).
|
||||
No live card or live card data is used here.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PROJECTS = Path(__file__).resolve().parents[2]
|
||||
PY_SIM = PROJECTS / 'pysim'
|
||||
if str(PY_SIM) not in sys.path:
|
||||
sys.path.insert(0, str(PY_SIM))
|
||||
|
||||
from pysim_otaman_server import httpota
|
||||
import pysim_otaman_server.server as server
|
||||
|
||||
|
||||
OPEN_LOCALHOST = bytes.fromhex(
|
||||
'd02b010301400102028182050035010339020200470b076d656761666f6e2e7275'
|
||||
'3c03021f903e05217f000001')
|
||||
|
||||
TR_OPEN_OK = '0103014001020282810301003802810035010339020200'
|
||||
TR_OPEN_FAIL = '01030140010202828103023a0035010339020200'
|
||||
|
||||
|
||||
class PeerServer(threading.Thread):
|
||||
"""Tiny TCP peer: accepts one connection, greets, records what it receives."""
|
||||
|
||||
def __init__(self, greeting=b''):
|
||||
super().__init__(daemon=True)
|
||||
self.sock = socket.socket()
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.sock.bind(('127.0.0.1', 0))
|
||||
self.sock.listen(1)
|
||||
self.port = self.sock.getsockname()[1]
|
||||
self.greeting = greeting
|
||||
self.received = b''
|
||||
self.conn = None
|
||||
self.ready = threading.Event()
|
||||
self.done = threading.Event()
|
||||
|
||||
def run(self):
|
||||
self.sock.settimeout(3)
|
||||
try:
|
||||
self.conn, _ = self.sock.accept()
|
||||
except OSError:
|
||||
return
|
||||
self.ready.set()
|
||||
if self.greeting:
|
||||
self.conn.sendall(self.greeting)
|
||||
self.conn.settimeout(2)
|
||||
deadline = time.time() + 3
|
||||
try:
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
data = self.conn.recv(4096)
|
||||
except socket.timeout:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
self.received += data
|
||||
except OSError:
|
||||
pass
|
||||
self.done.set()
|
||||
|
||||
def stop(self):
|
||||
self.sock.close()
|
||||
|
||||
|
||||
def open_cmd(host, port, buffer_size=512):
|
||||
ip = bytes(int(x) for x in host.split('.'))
|
||||
tlvs = (b'\x81\x03\x01\x40\x01'
|
||||
b'\x82\x02\x81\x82'
|
||||
b'\x35\x01\x03'
|
||||
b'\x39\x02' + buffer_size.to_bytes(2, 'big') +
|
||||
b'\x3c\x03\x02' + port.to_bytes(2, 'big') +
|
||||
b'\x3e\x05\x21' + ip)
|
||||
return b'\xd0' + bytes([len(tlvs)]) + tlvs
|
||||
|
||||
|
||||
def channel_cmd(cmd_type, qualifier, data_tlvs=b''):
|
||||
tlvs = (bytes([0x81, 0x03, 0x01, cmd_type, qualifier]) +
|
||||
b'\x82\x02\x81\x21' + data_tlvs)
|
||||
return b'\xd0' + bytes([len(tlvs)]) + tlvs
|
||||
|
||||
|
||||
class TlvTest(unittest.TestCase):
|
||||
def test_proactive_tlvs_open_channel(self):
|
||||
tlvs = httpota.proactive_tlvs(OPEN_LOCALHOST)
|
||||
self.assertEqual(tlvs[httpota.TAG_BEARER], b'\x03')
|
||||
self.assertEqual(tlvs[httpota.TAG_BUFFER_SIZE], b'\x02\x00')
|
||||
self.assertEqual(tlvs[httpota.TAG_NAA], b'\x07megafon.ru')
|
||||
self.assertEqual(httpota.parse_transport_level(tlvs[httpota.TAG_TRANSPORT_LEVEL]), (0x02, 8080))
|
||||
self.assertEqual(httpota.parse_other_address(tlvs[httpota.TAG_OTHER_ADDRESS]), '127.0.0.1')
|
||||
|
||||
|
||||
class TrVectorTest(unittest.TestCase):
|
||||
def test_open_channel_success_vector(self):
|
||||
extra = bytes([0x38, 0x02, 0x81, 0x00]) + bytes([0x35, 0x01, 0x03]) + bytes([0x39, 0x02, 0x02, 0x00])
|
||||
tr = server._bip_tr(1, 0x40, 0x01, 0x81, 0x82, 0x00, None, extra)
|
||||
self.assertEqual(tr.hex(), TR_OPEN_OK)
|
||||
|
||||
def test_open_channel_failure_vector(self):
|
||||
extra = bytes([0x35, 0x01, 0x03]) + bytes([0x39, 0x02, 0x02, 0x00])
|
||||
tr = server._bip_tr(1, 0x40, 0x01, 0x81, 0x82, 0x3A, 0x00, extra)
|
||||
self.assertEqual(tr.hex(), TR_OPEN_FAIL)
|
||||
|
||||
def test_disabled_bip_fails_open_channel(self):
|
||||
old = server._BIP
|
||||
try:
|
||||
server._BIP = httpota.BipTerminal()
|
||||
tr = server._handle_bip_command(None, 1, 0x40, 0x01, OPEN_LOCALHOST, 0x81, 0x82)
|
||||
self.assertEqual(tr.hex(), TR_OPEN_FAIL)
|
||||
finally:
|
||||
server._BIP = old
|
||||
|
||||
|
||||
class BipTerminalTest(unittest.TestCase):
|
||||
def test_redirect_and_roundtrip(self):
|
||||
peer = PeerServer(greeting=b'SERVERHELLO')
|
||||
peer.start()
|
||||
bip = httpota.BipTerminal()
|
||||
bip.enable('127.0.0.1', peer.port)
|
||||
cid, err = bip.open('10.9.9.9', 1234, 512)
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(bip.channels[cid].requested, '10.9.9.9:1234')
|
||||
self.assertEqual(bip.channels[cid].target, ('127.0.0.1', peer.port))
|
||||
self.assertTrue(bip.send(cid, b'CLIENTHELLO'))
|
||||
data = b''
|
||||
for _ in range(20):
|
||||
data = bip.receive(cid, 100)
|
||||
if data:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(data, b'SERVERHELLO')
|
||||
self.assertTrue(bip.close(cid))
|
||||
peer.done.wait(3)
|
||||
self.assertEqual(peer.received, b'CLIENTHELLO')
|
||||
kinds = [e['kind'] for e in bip.entries_after(0)]
|
||||
self.assertIn('open', kinds)
|
||||
self.assertIn('send', kinds)
|
||||
self.assertIn('receive', kinds)
|
||||
self.assertIn('close', kinds)
|
||||
peer.stop()
|
||||
|
||||
def test_passthru_mode_roundtrip_via_bip_control(self):
|
||||
# SCP81 passthru: the control API enables BIP with the external
|
||||
# platform as the target and starts no local listener; the card's
|
||||
# channel talks straight to that platform.
|
||||
peer = PeerServer(greeting=b'PLATFORM')
|
||||
peer.start()
|
||||
try:
|
||||
resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru',
|
||||
'host': '127.0.0.1', 'port': peer.port})
|
||||
self.assertTrue(resp['ok'], resp)
|
||||
self.assertEqual(resp['listener']['mode'], 'passthru')
|
||||
self.assertEqual(server._BIP.target, ('127.0.0.1', peer.port))
|
||||
cid, err = server._BIP.open('10.9.9.9', 10174, 512)
|
||||
self.assertIsNone(err)
|
||||
self.assertTrue(server._BIP.send(cid, b'CARDHELLO'))
|
||||
data = b''
|
||||
for _ in range(20):
|
||||
data = server._BIP.receive(cid, 100)
|
||||
if data:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(data, b'PLATFORM')
|
||||
finally:
|
||||
server._scp81_bip_control({'action': 'stop'})
|
||||
peer.stop()
|
||||
|
||||
def test_disabled_terminal_refuses_open(self):
|
||||
bip = httpota.BipTerminal()
|
||||
cid, err = bip.open('127.0.0.1', 1, 512)
|
||||
self.assertIsNone(cid)
|
||||
self.assertIn('disabled', err)
|
||||
|
||||
def test_peer_close_queues_channel_status_event(self):
|
||||
# TS 102 223 7.5.11: a link lost outside a proactive command must be
|
||||
# reported to the UICC (channel id, link not established, info 05).
|
||||
srv = socket.socket()
|
||||
srv.bind(('127.0.0.1', 0))
|
||||
srv.listen(1)
|
||||
try:
|
||||
bip = httpota.BipTerminal()
|
||||
bip.enable('127.0.0.1', srv.getsockname()[1])
|
||||
cid, err = bip.open('10.9.9.9', 1234, 512)
|
||||
self.assertIsNone(err)
|
||||
conn, _ = srv.accept()
|
||||
conn.close()
|
||||
events = []
|
||||
for _ in range(40):
|
||||
bip.receive(cid, 16)
|
||||
events = bip.take_pending_events()
|
||||
if events:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(events, [{'channel': cid, 'status': cid, 'info': 0x05}])
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
def test_channel_status_queued_once_per_channel(self):
|
||||
bip = httpota.BipTerminal()
|
||||
bip._queue_link_status(3)
|
||||
bip._queue_link_status(3)
|
||||
self.assertEqual(bip.take_pending_events(),
|
||||
[{'channel': 3, 'status': 3, 'info': 0x05}])
|
||||
self.assertEqual(bip.take_pending_events(), [])
|
||||
|
||||
def test_proactive_close_does_not_queue_status(self):
|
||||
# A CLOSE CHANNEL proactive command is not an autonomous link change.
|
||||
peer = PeerServer()
|
||||
peer.start()
|
||||
try:
|
||||
bip = httpota.BipTerminal()
|
||||
bip.enable('127.0.0.1', peer.port)
|
||||
cid, err = bip.open('10.9.9.9', 1234, 512)
|
||||
self.assertIsNone(err)
|
||||
self.assertTrue(bip.close(cid))
|
||||
self.assertEqual(bip.take_pending_events(), [])
|
||||
finally:
|
||||
peer.stop()
|
||||
|
||||
def test_dump_server_logs_received_bytes(self):
|
||||
received = []
|
||||
dump = httpota.TcpDumpServer('127.0.0.1', 0, on_rx=lambda peer, data: received.append(data))
|
||||
c = socket.create_connection(('127.0.0.1', dump.port), timeout=2)
|
||||
c.sendall(b'HELLOCARD')
|
||||
deadline = time.time() + 2
|
||||
while time.time() < deadline and not received:
|
||||
time.sleep(0.02)
|
||||
c.close()
|
||||
dump.stop()
|
||||
self.assertEqual(b''.join(received), b'HELLOCARD')
|
||||
|
||||
|
||||
def parse_tr(tr):
|
||||
"""Parse a BIP TERMINAL RESPONSE payload into {tag: value}."""
|
||||
out = {}
|
||||
off = 0
|
||||
while off + 1 < len(tr):
|
||||
tag, ln = tr[off], tr[off + 1]
|
||||
out[tag] = tr[off + 2:off + 2 + ln]
|
||||
off += 2 + ln
|
||||
return out
|
||||
|
||||
|
||||
class BipCommandFlowTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.peer = PeerServer(greeting=b'SERVERHELLO')
|
||||
self.peer.start()
|
||||
self.old = server._BIP
|
||||
self.bip = httpota.BipTerminal()
|
||||
self.bip.enable('127.0.0.1', self.peer.port)
|
||||
server._BIP = self.bip
|
||||
|
||||
def tearDown(self):
|
||||
server._BIP = self.old
|
||||
self.peer.stop()
|
||||
|
||||
def test_open_send_receive_close_flow(self):
|
||||
tr = server._handle_bip_command(None, 1, 0x40, 0x01, open_cmd('127.0.0.1', self.peer.port), 0x81, 0x82)
|
||||
self.assertEqual(tr.hex(), TR_OPEN_OK)
|
||||
|
||||
tr = server._handle_bip_command(None, 1, 0x43, 0x01,
|
||||
channel_cmd(0x43, 0x01, bytes([0x36, 0x08]) + b'CLIENTHE'),
|
||||
0x81, 0x21)
|
||||
tlvs = parse_tr(tr)
|
||||
self.assertEqual(tlvs[0x01].hex(), '014301')
|
||||
self.assertEqual(tlvs[0x02].hex(), '8281')
|
||||
self.assertEqual(tlvs[0x03], b'\x00')
|
||||
self.assertEqual(tlvs[0x37], b'\xff')
|
||||
|
||||
data = b''
|
||||
for _ in range(20):
|
||||
tr = server._handle_bip_command(None, 1, 0x42, 0x00, channel_cmd(0x42, 0x00, bytes([0x37, 0x01, 0x64])), 0x81, 0x21)
|
||||
tlvs = parse_tr(tr)
|
||||
if 0x36 in tlvs and tlvs[0x36]:
|
||||
data += tlvs[0x36]
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(data, b'SERVERHELLO')
|
||||
self.assertEqual(tlvs[0x37], b'\x00')
|
||||
|
||||
tr = server._handle_bip_command(None, 1, 0x41, 0x00, channel_cmd(0x41, 0x00), 0x81, 0x21)
|
||||
self.assertEqual(tr.hex(), '010301410002028281030100')
|
||||
self.peer.done.wait(3)
|
||||
self.assertEqual(self.peer.received, b'CLIENTHE')
|
||||
|
||||
def test_send_without_channel_fails(self):
|
||||
tr = server._handle_bip_command(None, 1, 0x43, 0x01,
|
||||
channel_cmd(0x43, 0x01, bytes([0x36, 0x01]) + b'X'),
|
||||
0x81, 0x21)
|
||||
tlvs = parse_tr(tr)
|
||||
self.assertEqual(tlvs[0x03].hex(), '3a00')
|
||||
|
||||
def test_open_channel_cr_set_tlvs(self):
|
||||
# Live card 2026-09-15: the fallback OPEN CHANNEL uses the CR-set tag
|
||||
# variants (B5/B9/C7/BC/BE) - the handler must find them too.
|
||||
raw = bytes.fromhex('d0248103014003820281828500b50103b902058e'
|
||||
'c70403475042bc03020582be05215bd50502')
|
||||
tr = server._handle_bip_command(None, 1, 0x40, 0x03, raw, 0x81, 0x82)
|
||||
tlvs = parse_tr(tr)
|
||||
self.assertEqual(tlvs[0x03], b'\x00')
|
||||
self.assertIn(0x38, tlvs) # Channel status
|
||||
self.assertIn(0x39, tlvs) # Buffer size echo
|
||||
|
||||
def test_open_channel_plain_tlvs(self):
|
||||
# Same command with the plain tag variants (reference phone traces).
|
||||
raw = bytes.fromhex('d02401030140030202818205003501033902058e'
|
||||
'4704034750423c030205823e05215bd50502')
|
||||
tr = server._handle_bip_command(None, 1, 0x40, 0x03, raw, 0x81, 0x82)
|
||||
tlvs = parse_tr(tr)
|
||||
self.assertEqual(tlvs[0x03], b'\x00')
|
||||
self.assertIn(0x38, tlvs)
|
||||
|
||||
def test_open_channel_truncated_destination_accepted(self):
|
||||
# Live card 2026-09-15: '3e 05' with no value (empty buffer quirk,
|
||||
# same family as the reference openchannel_not_understood_no_apn
|
||||
# trace). The emulation is permissive and opens the configured target.
|
||||
raw = bytes.fromhex('d01c810301400c82028182850035010339020200'
|
||||
'4701003c030227be3e05')
|
||||
tr = server._handle_bip_command(None, 1, 0x40, 0x0C, raw, 0x81, 0x82)
|
||||
tlvs = parse_tr(tr)
|
||||
self.assertEqual(tlvs[0x03], b'\x00')
|
||||
self.assertIn(0x38, tlvs)
|
||||
kinds = [(e['kind'], e.get('note')) for e in self.bip.entries_after(0)]
|
||||
self.assertIn(('open-relaxed', 'destination/transport not fully specified'), kinds)
|
||||
|
||||
def test_open_channel_without_transport_accepted(self):
|
||||
# No transport level at all (bearer-level channel): still accepted.
|
||||
raw = bytes.fromhex('d00d81030140018202818239020200')
|
||||
tr = server._handle_bip_command(None, 1, 0x40, 0x01, raw, 0x81, 0x82)
|
||||
tlvs = parse_tr(tr)
|
||||
self.assertEqual(tlvs[0x03], b'\x00')
|
||||
self.assertIn(0x38, tlvs)
|
||||
kinds = [e['kind'] for e in self.bip.entries_after(0)]
|
||||
self.assertIn('open-relaxed', kinds)
|
||||
|
||||
def test_background_open_queues_link_established(self):
|
||||
# Qualifier 0x04 (background mode): the terminal must report the
|
||||
# established link via ENVELOPE (Channel status) - 7.5.11.
|
||||
raw = bytes.fromhex('d01c810301400c82028182850035010339020200'
|
||||
'4701003c030227be3e05')
|
||||
server._handle_bip_command(None, 1, 0x40, 0x0C, raw, 0x81, 0x82)
|
||||
events = self.bip.take_pending_events()
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(events[0]['info'], 0x00)
|
||||
self.assertTrue(events[0]['status'] & 0x80)
|
||||
|
||||
def test_flush_channel_events_when_subscribed(self):
|
||||
sent = []
|
||||
|
||||
class Tp:
|
||||
def send_apdu(self, apdu):
|
||||
sent.append(apdu)
|
||||
return '', '9000'
|
||||
|
||||
scc = types.SimpleNamespace(cat_cla='80', _tp=Tp())
|
||||
self.bip._queue_link_status(2)
|
||||
ref = types.SimpleNamespace(event_list=[0x09, 0x0A])
|
||||
with mock.patch.object(server, '_server_ref', ref):
|
||||
server._bip_flush_channel_events(scc)
|
||||
# D6: event list (ch status), device ids, Channel status B8 02 02 05
|
||||
# (channel 2, link not established, info 05 = link dropped)
|
||||
self.assertEqual(sent, ['80c200000dd60b99010a82028281b8020205'])
|
||||
self.assertEqual(self.bip.take_pending_events(), [])
|
||||
|
||||
def test_flush_skipped_without_subscription(self):
|
||||
sent = []
|
||||
|
||||
class Tp:
|
||||
def send_apdu(self, apdu):
|
||||
sent.append(apdu)
|
||||
return '', '9000'
|
||||
|
||||
scc = types.SimpleNamespace(cat_cla='80', _tp=Tp())
|
||||
self.bip._queue_link_status(1)
|
||||
ref = types.SimpleNamespace(event_list=[0x09])
|
||||
with mock.patch.object(server, '_server_ref', ref):
|
||||
server._bip_flush_channel_events(scc)
|
||||
self.assertEqual(sent, [])
|
||||
# Not subscribed: the event stays queued for a later card session.
|
||||
self.assertEqual(self.bip.take_pending_events(),
|
||||
[{'channel': 1, 'status': 1, 'info': 0x05}])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
def test_peer_close_reported_after_buffer_drained(self):
|
||||
# A dropped link must not be signalled while server data still waits
|
||||
# to be fetched: the card would abort the fetch mid-record. Drain
|
||||
# first, then report.
|
||||
srv = socket.socket()
|
||||
srv.bind(('127.0.0.1', 0))
|
||||
srv.listen(1)
|
||||
try:
|
||||
bip = httpota.BipTerminal()
|
||||
bip.enable('127.0.0.1', srv.getsockname()[1])
|
||||
cid, err = bip.open('10.9.9.9', 1234, 512)
|
||||
self.assertIsNone(err)
|
||||
conn, _ = srv.accept()
|
||||
conn.sendall(b'response-bytes')
|
||||
conn.close()
|
||||
ch = bip.channels[cid]
|
||||
for _ in range(40):
|
||||
ch.pump()
|
||||
if ch.rx and ch.peer_closed:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertTrue(ch.rx)
|
||||
self.assertTrue(ch.peer_closed)
|
||||
# Partial fetch: the link-dropped event must still be withheld.
|
||||
bip.receive(cid, 5)
|
||||
self.assertEqual(bip.take_pending_events(), [])
|
||||
# Remaining bytes fetched: the event is reported now.
|
||||
bip.receive(cid, 64)
|
||||
self.assertEqual(bip.take_pending_events(),
|
||||
[{'channel': cid, 'status': cid, 'info': 0x05}])
|
||||
bip.close(cid)
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
def test_receive_data_tlv_long_form_length(self):
|
||||
# A >127-byte channel data TLV must use the BER long form (0x81 len),
|
||||
# as the reference terminal traces do (`36 81 ed` for 237 bytes).
|
||||
import types
|
||||
server = __import__('pysim_otaman_server.server', fromlist=['x'])
|
||||
big = bytes(range(256)) * 1 # 256 bytes; take a slice below
|
||||
ch = types.SimpleNamespace(rx=bytearray(b'\xAA' * 237))
|
||||
class FakeBip:
|
||||
def __init__(self): self.channels = {1: ch}
|
||||
def receive(self, cid, n):
|
||||
data = bytes(ch.rx[:min(n, len(ch.rx))]); del ch.rx[:len(data)]; return data
|
||||
def available(self, cid): return len(ch.rx)
|
||||
def log(self, *a, **k): pass
|
||||
old = server._BIP
|
||||
server._BIP = FakeBip()
|
||||
try:
|
||||
raw = bytes.fromhex('d00c8103014200820281213701ed')
|
||||
tr = server._handle_bip_command(None, 1, 0x42, 0, raw, None, 0x21)
|
||||
self.assertIn(b'\x36\x81\xed' + b'\xAA' * 237, tr)
|
||||
finally:
|
||||
server._BIP = old
|
||||
+425
-2
@@ -7,6 +7,7 @@ No live/sample card keys and no ICCIDs appear here.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
@@ -25,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,
|
||||
@@ -175,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):
|
||||
@@ -252,6 +277,17 @@ class TestProactiveDecode(unittest.TestCase):
|
||||
srv._PROACTIVE_SESSION_START = 1234.0
|
||||
srv._PLI_DATA[0x00] = '93055210011000'
|
||||
|
||||
@staticmethod
|
||||
def _cmd_raw(cmd_type, qualifier, extras=b''):
|
||||
"""A D0-wrapped proactive command (header TLVs + extras)."""
|
||||
body = (bytes([0x81, 0x03, 0x01, cmd_type, qualifier])
|
||||
+ bytes([0x82, 0x02, 0x83, 0x81]) + extras)
|
||||
return bytes([0xD0, len(body)]) + body
|
||||
|
||||
@staticmethod
|
||||
def _decoded(cmd_type, raw, qualifier=None):
|
||||
return {d['label']: d['value'] for d in _decode_cmd(cmd_type, raw, qualifier)}
|
||||
|
||||
def test_decode_cmd_poll_interval(self):
|
||||
r = _decode_cmd(0x03, bytes.fromhex('d00d8103010300820283818402011e'), None)
|
||||
self.assertEqual(r, [{'label': 'Interval', 'value': '30 s'}])
|
||||
@@ -261,13 +297,142 @@ class TestProactiveDecode(unittest.TestCase):
|
||||
self.assertEqual(r, [{'label': 'Events', 'value': 'Call connected'}])
|
||||
|
||||
def test_decode_cmd_send_short_message(self):
|
||||
r = _decode_cmd(0x13, bytes.fromhex('d0158103011300820283818b0b916106152670f900a35f020101'), None)
|
||||
self.assertEqual(r, [{'label': 'SMS TPDU', 'value': '916106152670f900a35f02'}])
|
||||
# SEND SHORT MESSAGE with an SMS-SUBMIT TPDU carrying GSM-7 text.
|
||||
tpdu = bytes.fromhex('010006912143F5000005E8329BFD06')
|
||||
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
|
||||
r = self._decoded(0x13, raw)
|
||||
self.assertEqual(r['Type'], 'SMS-SUBMIT')
|
||||
self.assertEqual(r['TP-MR'], '0')
|
||||
self.assertEqual(r['TP-DA'], '12345')
|
||||
self.assertEqual(r['TP-PID'], '0x00')
|
||||
self.assertEqual(r['TP-DCS'], '0x00')
|
||||
self.assertEqual(r['TP-UDL'], '5')
|
||||
self.assertEqual(r['Text'], 'hello')
|
||||
self.assertEqual(r['SMS TPDU'], tpdu.hex().upper())
|
||||
|
||||
def test_decode_cmd_send_short_message_udh_8bit(self):
|
||||
# UDHI + concatenation IE (16-bit ref) + 8-bit text data.
|
||||
udh = bytes.fromhex('0608040001020341 42'.replace(' ', ''))
|
||||
tpdu = (bytes.fromhex('4100' '06912143F5' '00' '04' '09') + udh)
|
||||
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
|
||||
r = self._decoded(0x13, raw)
|
||||
self.assertEqual(r['Concat (16-bit ref)'], '1, part 2/3')
|
||||
self.assertEqual(r['Text'], 'AB')
|
||||
|
||||
def test_decode_cmd_send_short_message_ucs2(self):
|
||||
text = 'Тест'.encode('utf-16-be')
|
||||
tpdu = (bytes.fromhex('0100' '06912143F5' '00' '08' '%02X' % len(text))
|
||||
+ text)
|
||||
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
|
||||
r = self._decoded(0x13, raw)
|
||||
self.assertEqual(r['TP-DCS'], '0x08')
|
||||
self.assertEqual(r['Text'], 'Тест')
|
||||
|
||||
def test_decode_cmd_send_short_message_secured_packet(self):
|
||||
# PID 0x7F = SIM data download: the UD is a secured packet (TS 31.115).
|
||||
tpdu = bytes.fromhex('0100' '06912143F5' '7F' 'F6' '03' 'AABBCC')
|
||||
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
|
||||
r = self._decoded(0x13, raw)
|
||||
self.assertEqual(r['TP-PID'], '0x7F (SIM data download)')
|
||||
self.assertEqual(r['Secured packet (TS 31.115)'], '3 bytes: AABBCC')
|
||||
|
||||
def test_decode_cmd_send_short_message_malformed_falls_back(self):
|
||||
# A malformed/garbage TPDU must not raise: the raw hex line remains.
|
||||
raw = bytes.fromhex('d0158103011300820283818b0b916106152670f900a35f020101')
|
||||
r = self._decoded(0x13, raw)
|
||||
self.assertEqual(r['SMS TPDU'], '916106152670F900A35F02')
|
||||
|
||||
def test_decode_cmd_pli_qualifier_name(self):
|
||||
r = _decode_cmd(0x26, b'\xd0', 0x00)
|
||||
self.assertTrue(r[0]['value'].startswith('Location Information (MCC, MNC, LAC/TAC, Cell ID)'))
|
||||
|
||||
def test_decode_cmd_pli_all_standard_qualifiers_named(self):
|
||||
# TS 102 223 V18.3.0 (PLI qualifier coding): names must exist even
|
||||
# without a special data decoder, e.g. ESN (07) and MEID (0B).
|
||||
cases = {
|
||||
0x07: 'ESN',
|
||||
0x0B: 'MEID',
|
||||
0x1A: 'Supported Radio Access Technologies',
|
||||
0x05: 'Reserved for GSM',
|
||||
}
|
||||
for qualifier, name in cases.items():
|
||||
r = _decode_cmd(0x26, b'\xd0', qualifier)
|
||||
self.assertIn(name, r[0]['value'], 'qualifier 0x%02X' % qualifier)
|
||||
|
||||
def test_decode_cmd_timer_management_start(self):
|
||||
# TS 102 223 6.6.21/8.37/8.38: start timer 3 for 14:07:32
|
||||
raw = bytes.fromhex('d011810301270082028182a40103a503417023')
|
||||
self.assertEqual(_decode_cmd(0x27, raw, 0x00), [
|
||||
{'label': 'Action', 'value': 'Start'},
|
||||
{'label': 'Timer', 'value': '3'},
|
||||
{'label': 'Value', 'value': '14:07:32'},
|
||||
])
|
||||
|
||||
def test_decode_cmd_timer_management_plain_tags(self):
|
||||
# Cards may use the plain (non comprehension-required) tag variant.
|
||||
raw = bytes.fromhex('d00c010301270102028182240103')
|
||||
self.assertEqual(_decode_cmd(0x27, raw, 0x01), [
|
||||
{'label': 'Action', 'value': 'Deactivate'},
|
||||
{'label': 'Timer', 'value': '3'},
|
||||
])
|
||||
|
||||
def test_decode_cmd_open_channel_cr_tags(self):
|
||||
# Same OPEN CHANNEL as the reference traces, but with CR-set TLVs.
|
||||
raw = bytes.fromhex(
|
||||
'd02b8103014001820281828500b50103b9020200c70b076d656761666f6e2e7275'
|
||||
'bc03021f90be05217f000001')
|
||||
r = _decode_cmd(0x40, raw, 0x01)
|
||||
self.assertIn({'label': 'Bearer', 'value': '0x03'}, r)
|
||||
self.assertIn({'label': 'Buffer size', 'value': '512'}, r)
|
||||
self.assertIn({'label': 'APN', 'value': 'megafon.ru'}, r)
|
||||
self.assertIn({'label': 'Destination', 'value': '127.0.0.1'}, r)
|
||||
self.assertIn({'label': 'Transport', 'value': 'TCP client port 8080'}, r)
|
||||
|
||||
def test_decode_cmd_bip_channel_from_device_ids(self):
|
||||
# Real trace: SEND DATA carries the channel in the device identities
|
||||
# (source UICC 0x81, destination Channel 1 0x21).
|
||||
raw = bytes.fromhex('d00e8103014301820281213701013603aabbcc')
|
||||
r = _decode_cmd(0x43, raw, 0x01)
|
||||
self.assertEqual(r[0], {'label': 'Channel', 'value': '1'})
|
||||
self.assertEqual(r[1], {'label': 'Data bytes', 'value': '3'})
|
||||
|
||||
def test_parse_proactive_header_plain_tags(self):
|
||||
import pysim_otaman_server.server as srv
|
||||
raw = bytes.fromhex('d00c010301270102028182240103')
|
||||
self.assertEqual(srv._parse_proactive_header(raw), (1, 0x27, 0x81, 0x82, 0x01))
|
||||
|
||||
def test_default_handler_logs_timer_management(self):
|
||||
# pySim's auto-handler path: the parsed command object (not the empty
|
||||
# collection) is re-encoded for the log and used for the response.
|
||||
import pysim_otaman_server.server as srv
|
||||
from pySim.cat import ProactiveCommand
|
||||
from pySim.utils import h2b
|
||||
srv._PROACTIVE_LOG.clear()
|
||||
handler = srv._DefaultProactiveHandler()
|
||||
pcmd = ProactiveCommand()
|
||||
parsed = pcmd.from_tlv(h2b('d011810301270082028182a40103a503417023'))
|
||||
ti = handler.receive_fetch_raw(pcmd, parsed)
|
||||
tr = b''.join(x.to_tlv() for x in ti).hex()
|
||||
self.assertTrue(tr.startswith('810301270082028281830100'), tr)
|
||||
entry = srv._PROACTIVE_LOG[-1]
|
||||
self.assertEqual(entry['type_hex'], '27')
|
||||
self.assertEqual(entry['type_name'], 'TIMER MANAGEMENT')
|
||||
self.assertEqual(entry['tr_result'], '00')
|
||||
|
||||
def test_default_handler_pli_includes_dict_data(self):
|
||||
import pysim_otaman_server.server as srv
|
||||
from pySim.cat import ProactiveCommand
|
||||
from pySim.utils import h2b
|
||||
srv._PROACTIVE_LOG.clear()
|
||||
srv._PLI_DATA[0x00] = '93055210011000'
|
||||
handler = srv._DefaultProactiveHandler()
|
||||
pcmd = ProactiveCommand()
|
||||
parsed = pcmd.from_tlv(h2b('d00d810301260082028182'))
|
||||
ti = handler.receive_fetch_raw(pcmd, parsed)
|
||||
tr = b''.join(x.to_tlv() for x in ti).hex()
|
||||
self.assertIn('93055210011000', tr)
|
||||
self.assertEqual(srv._PROACTIVE_LOG[-1]['tr_hex'], '93055210011000')
|
||||
|
||||
def test_decode_cmd_empty_raw(self):
|
||||
self.assertEqual(_decode_cmd(0x26, b'', None), [])
|
||||
self.assertEqual(_decode_cmd(0x03, None, None), [])
|
||||
@@ -360,6 +525,122 @@ class TestProactiveDecode(unittest.TestCase):
|
||||
self.assertNotIn('tr_result', entry)
|
||||
|
||||
|
||||
class TestEventDownload(unittest.TestCase):
|
||||
"""ENVELOPE (EVENT DOWNLOAD) assembly, TS 102 223 7.5.11."""
|
||||
|
||||
def _send(self, event_type, event_data):
|
||||
import pysim_otaman_server.server as srv
|
||||
calls = []
|
||||
|
||||
class Tp:
|
||||
def send_apdu(self, apdu):
|
||||
calls.append(apdu)
|
||||
return '', '9000'
|
||||
|
||||
class Scc:
|
||||
cat_cla = '80'
|
||||
_tp = Tp()
|
||||
|
||||
data, sw = srv._send_event_download(Scc(), event_type, event_data)
|
||||
return calls[0], sw
|
||||
|
||||
def test_channel_status_event(self):
|
||||
# Event list + device identities + Channel status (8.56): channel 2,
|
||||
# link established, info 05 = link dropped.
|
||||
apdu, sw = self._send(0x0A, bytes.fromhex('b8028205'))
|
||||
self.assertEqual(sw, '9000')
|
||||
self.assertEqual(apdu, '80c200000dd60b99010a82028281b8028205')
|
||||
|
||||
def test_event_without_data(self):
|
||||
apdu, sw = self._send(0x05, None)
|
||||
self.assertEqual(sw, '9000')
|
||||
self.assertEqual(apdu, '80c2000009d60799010582028281')
|
||||
|
||||
|
||||
class TestTimerManagement(unittest.TestCase):
|
||||
"""Terminal side of TIMER MANAGEMENT (TS 102 223 6.6.21, 6.8.13/14, 7.4).
|
||||
|
||||
The start vector is the live card's: timer 1, 60 s."""
|
||||
|
||||
START = bytes.fromhex('d011810301270082028182a40101a503001000')
|
||||
|
||||
def tearDown(self):
|
||||
import pysim_otaman_server.server as srv
|
||||
srv._timer_cancel()
|
||||
|
||||
def test_hms_bcd_roundtrip(self):
|
||||
import pysim_otaman_server.server as srv
|
||||
self.assertEqual(srv._hms_bcd(60).hex(), '001000')
|
||||
self.assertEqual(srv._hms_bcd(3723).hex(), '102030')
|
||||
self.assertEqual([srv._bcd_swap(b) for b in srv._hms_bcd(3723)], [1, 2, 3])
|
||||
|
||||
def test_start_returns_result_only_and_arms_timer(self):
|
||||
import pysim_otaman_server.server as srv
|
||||
tr = srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
|
||||
self.assertEqual(tr.hex(), '810301270082028281030100')
|
||||
remaining = srv._timer_remaining(1)
|
||||
self.assertTrue(55 <= remaining <= 60, remaining)
|
||||
|
||||
def test_get_returns_remaining_value(self):
|
||||
import pysim_otaman_server.server as srv
|
||||
srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
|
||||
tr = srv._handle_timer_command(1, 0x27, 0x02, self.START, 0x81, 0x82)
|
||||
self.assertEqual(tr.hex(), '810301270282028281a40101a503001000030100')
|
||||
|
||||
def test_deactivate_stops_and_reports_value(self):
|
||||
import pysim_otaman_server.server as srv
|
||||
srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
|
||||
tr = srv._handle_timer_command(1, 0x27, 0x01, self.START, 0x81, 0x82)
|
||||
self.assertTrue(tr.hex().startswith('8103012701'), tr.hex())
|
||||
self.assertIn('a40101a503001000', tr.hex())
|
||||
self.assertIsNone(srv._timer_remaining(1))
|
||||
|
||||
def test_get_on_stopped_timer_is_contradiction(self):
|
||||
import pysim_otaman_server.server as srv
|
||||
tr = srv._handle_timer_command(1, 0x27, 0x02, self.START, 0x81, 0x82)
|
||||
self.assertEqual(tr.hex(), '810301270282028281030124')
|
||||
|
||||
def test_timer_expiration_envelope(self):
|
||||
import pysim_otaman_server.server as srv
|
||||
calls = []
|
||||
|
||||
class Tp:
|
||||
def send_apdu(self, apdu):
|
||||
calls.append(apdu)
|
||||
return '', '9000'
|
||||
|
||||
ref = types.SimpleNamespace(
|
||||
scc=types.SimpleNamespace(cat_cla='80', _tp=Tp()), stk_pending=None)
|
||||
with mock.patch.object(srv, '_server_ref', ref):
|
||||
with mock.patch.object(srv, '_CARD_CONNECTED', True):
|
||||
srv._timer_expired(1, 60)
|
||||
# D7 0C: device identities (terminal -> UICC), Timer id A4, value A5
|
||||
self.assertEqual(calls, ['80c200000ed70c82028281a40101a503001000'])
|
||||
|
||||
def test_cancelled_timer_does_not_report(self):
|
||||
import pysim_otaman_server.server as srv
|
||||
calls = []
|
||||
|
||||
class Tp:
|
||||
def send_apdu(self, apdu):
|
||||
calls.append(apdu)
|
||||
return '', '9000'
|
||||
|
||||
ref = types.SimpleNamespace(
|
||||
scc=types.SimpleNamespace(cat_cla='80', _tp=Tp()), stk_pending=None)
|
||||
with mock.patch.object(srv, '_server_ref', ref):
|
||||
with mock.patch.object(srv, '_CARD_CONNECTED', True):
|
||||
srv._timer_fire(1, 60) # never started/cancelled
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_decode_tr_timer(self):
|
||||
tr = bytes.fromhex('810301270082028281a40101a503001000030100')
|
||||
data = _tr_data_only(tr).hex()
|
||||
r = _decode_tr('27', '00', data)
|
||||
self.assertEqual(r, [{'label': 'Timer', 'value': '1'},
|
||||
{'label': 'Remaining', 'value': '00:01:00'}])
|
||||
|
||||
|
||||
class TestExpandedRemoteResponse(unittest.TestCase):
|
||||
"""Expanded Remote Response parsing (TS 102 226 §5.2.2)."""
|
||||
|
||||
@@ -645,3 +926,145 @@ class TestSmsReassembly(unittest.TestCase):
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class CapApduSequenceTest(unittest.TestCase):
|
||||
"""RAM APDU sequence shared by the SCP80 and SCP81 install paths."""
|
||||
|
||||
def _mini_cap(self):
|
||||
import io, zipfile
|
||||
# Header: tag(1) size(2) magic(4) minor(1) major(1) flags(1)
|
||||
# pkg minor(1) pkg major(1) aid_len(1) aid(N)
|
||||
header = (b'\x01\x00\x11' + b'\xde\xca\xff\xed' + b'\x00\x01\x00' +
|
||||
b'\x00\x01' + b'\x06' + b'\xa0\x00\x00\x01\x00\x01')
|
||||
# Applet: tag(1) size(2) count(1) aid_len(1) module_aid(N) offset(2)
|
||||
applet = (b'\x03\x00\x0a\x01\x05' + b'\xa0\x00\x00\x01\x00' + b'\x00\x08')
|
||||
buf = io.BytesIO()
|
||||
zf = zipfile.ZipFile(buf, 'w')
|
||||
zf.writestr('pkg/Header.cap', header)
|
||||
zf.writestr('pkg/Applet.cap', applet)
|
||||
zf.close()
|
||||
return buf.getvalue().hex().upper()
|
||||
|
||||
def test_cap_parse(self):
|
||||
from pysim_otaman_server.server import _cap_parse
|
||||
loadfile_aid, module_aid, data = _cap_parse(self._mini_cap())
|
||||
self.assertEqual(loadfile_aid, 'A00000010001')
|
||||
self.assertEqual(module_aid, 'A000000100')
|
||||
# Header then Applet, per the CAP component order.
|
||||
self.assertTrue(data.startswith('010011DECAFFED'))
|
||||
self.assertIn('03000A01', data)
|
||||
|
||||
def test_sequence_install_load_install(self):
|
||||
from pysim_otaman_server.server import _cap_apdu_sequence
|
||||
seq = _cap_apdu_sequence('A00000010001', 'A000000100', 'AABBCCDD')
|
||||
# INSTALL [for load]: lv(pkg aid) + lv(ISD) + 000000
|
||||
self.assertEqual(seq[0],
|
||||
'80E6020013' + '06A00000010001' + '08A000000003000000' + '000000' + '00')
|
||||
# One LOAD block (small payload, last -> P1=0x80, P2=0)
|
||||
self.assertEqual(seq[1][:8], '80E88000')
|
||||
self.assertTrue(seq[1].endswith('00'))
|
||||
# INSTALL [for install]: C9 00 install params appended to the lv chain
|
||||
self.assertTrue(seq[2].startswith('80E60C00'))
|
||||
self.assertIn('06A00000010001' + '05A000000100' + '05A000000100' + '0100', seq[2])
|
||||
|
||||
def test_load_blocks_split_and_counter(self):
|
||||
from pysim_otaman_server.server import _cap_apdu_sequence, _ber_len as _ber_len_lower
|
||||
data = ''.join('%02X' % (i % 256) for i in range(700))
|
||||
seq = _cap_apdu_sequence('A00000010001', 'A000000100', data)
|
||||
self.assertEqual(len(seq), 5) # INSTALL + 3 LOAD + INSTALL
|
||||
self.assertEqual(seq[1][:8], '80E80000')
|
||||
self.assertEqual(seq[2][:8], '80E80001')
|
||||
self.assertEqual(seq[3][:8], '80E88002') # last block: P1=0x80
|
||||
# The blocks are consecutive chunks and reassemble the load file TLV
|
||||
# byte-for-byte (a shifted/overlapping split fails the card mid-load).
|
||||
def payload(apdu):
|
||||
lc = int(apdu[8:10], 16)
|
||||
return apdu[10:10 + lc * 2]
|
||||
joined = payload(seq[1]) + payload(seq[2]) + payload(seq[3])
|
||||
self.assertTrue(joined.startswith('C482'))
|
||||
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'])
|
||||
|
||||
|
||||
class TerminalProfileTest(unittest.TestCase):
|
||||
"""Runtime TERMINAL PROFILE: hex validation and re-send."""
|
||||
|
||||
def test_validate_tp_hex(self):
|
||||
from pysim_otaman_server.server import _validate_tp_hex
|
||||
self.assertEqual(_validate_tp_hex('ff 00 80'), ('FF0080', None))
|
||||
self.assertEqual(_validate_tp_hex('80FF'), ('80FF', None))
|
||||
for bad in ('', ' ', 'F', 'XYZ', 'FF0', 'FF' * 256):
|
||||
h, err = _validate_tp_hex(bad)
|
||||
self.assertIsNone(h, bad)
|
||||
self.assertTrue(err, bad)
|
||||
|
||||
def test_resend_terminal_profile_resets_state_and_sends(self):
|
||||
import types
|
||||
from pysim_otaman_server import server as srv
|
||||
server_obj = types.SimpleNamespace(
|
||||
terminal_profile='FF00', stk_pending={'type': 'display_text'},
|
||||
menu_active=True, event_list=[0x03], sim_menu='old')
|
||||
seen = []
|
||||
old_send = srv._send_terminal_profile
|
||||
|
||||
def fake_send(scc, tp):
|
||||
seen.append(tp)
|
||||
return 'menu', [0x09]
|
||||
|
||||
srv._send_terminal_profile = fake_send
|
||||
try:
|
||||
resp = srv._resend_terminal_profile(server_obj, object())
|
||||
finally:
|
||||
srv._send_terminal_profile = old_send
|
||||
self.assertTrue(resp['ok'])
|
||||
self.assertEqual(resp['profile'], 'FF00')
|
||||
self.assertEqual(seen, ['FF00'])
|
||||
self.assertIsNone(server_obj.stk_pending)
|
||||
self.assertFalse(server_obj.menu_active)
|
||||
self.assertEqual(server_obj.event_list, [0x09])
|
||||
self.assertEqual(server_obj.sim_menu, 'menu')
|
||||
self.assertTrue(resp['menu'])
|
||||
|
||||
+1189
File diff suppressed because it is too large
Load Diff
Executable
+135
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decrypt the SCP81 PSK-TLS dialog from a server log + keylog.
|
||||
|
||||
The server's /api/scp81/log has every TLS record of the dialog (send = card,
|
||||
receive = card fetch, i.e. the server stream), and the listener can write the
|
||||
TLS secrets (SSLKEYLOGFILE) when started with a "keylog" path. With the
|
||||
PSK-AES128-CBC-SHA256 dialog we can derive the record keys (TLS 1.2
|
||||
PRF/master secret) and decrypt the card's alerts, which are otherwise opaque.
|
||||
|
||||
Usage: scp81_decrypt.py <log.json> <keys.log>
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def p_sha256(secret, seed, length):
|
||||
out = b''
|
||||
a = seed
|
||||
while len(out) < length:
|
||||
a = hmac.new(secret, a, hashlib.sha256).digest()
|
||||
out += hmac.new(secret, a + seed, hashlib.sha256).digest()
|
||||
return out[:length]
|
||||
|
||||
|
||||
def aes_cbc_decrypt(key, iv, data):
|
||||
p = subprocess.run(['openssl', 'enc', '-d', '-aes-128-cbc', '-nopad',
|
||||
'-K', key.hex(), '-iv', iv.hex()],
|
||||
input=data, capture_output=True)
|
||||
if p.returncode != 0:
|
||||
raise RuntimeError(p.stderr.decode())
|
||||
return p.stdout
|
||||
|
||||
|
||||
def record_payloads(stream):
|
||||
"""Walk TLS records in a byte stream, yield (type, version, payload)."""
|
||||
i = 0
|
||||
while i + 5 <= len(stream):
|
||||
rtype, ver, ln = stream[i], stream[i + 1:i + 3], int.from_bytes(stream[i + 3:i + 5], 'big')
|
||||
body = stream[i + 5:i + 5 + ln]
|
||||
if len(body) < ln:
|
||||
break
|
||||
yield rtype, ver, body
|
||||
i += 5 + ln
|
||||
|
||||
|
||||
def find_random(stream, hs_type):
|
||||
"""Return the 32-byte random of a ClientHello/ServerHello in the stream."""
|
||||
for rtype, ver, body in record_payloads(stream):
|
||||
if rtype != 0x16 or not body or body[0] != hs_type:
|
||||
continue
|
||||
hslen = int.from_bytes(body[1:4], 'big')
|
||||
hs = body[:4 + hslen]
|
||||
return hs[6:38]
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
log_path, keys_path = sys.argv[1], sys.argv[2]
|
||||
entries = sorted(json.load(open(log_path))['entries'], key=lambda x: x['seq'])
|
||||
# Only the last TLS session: start at the final OPEN CHANNEL.
|
||||
start = 0
|
||||
for i, e in enumerate(entries):
|
||||
if e.get('kind') == 'open':
|
||||
start = i
|
||||
entries = entries[start:]
|
||||
client = b''
|
||||
server = b''
|
||||
for e in entries:
|
||||
if e.get('kind') == 'send' and e.get('hex'):
|
||||
client += bytes.fromhex(e['hex'])
|
||||
elif e.get('kind') == 'receive' and e.get('hex'):
|
||||
server += bytes.fromhex(e['hex'])
|
||||
|
||||
crandom = find_random(client, 0x01)
|
||||
srandom = find_random(server, 0x02)
|
||||
print('client_random:', crandom.hex() if crandom else None)
|
||||
print('server_random:', srandom.hex() if srandom else None)
|
||||
if not crandom or not srandom:
|
||||
sys.exit('handshake randoms not found in log')
|
||||
|
||||
master = None
|
||||
for line in open(keys_path):
|
||||
parts = line.split()
|
||||
if parts and parts[0] == 'CLIENT_RANDOM' and parts[1] == crandom.hex():
|
||||
master = bytes.fromhex(parts[2])
|
||||
if not master:
|
||||
sys.exit('master secret not found in keylog')
|
||||
print('master_secret:', master.hex())
|
||||
|
||||
kb = p_sha256(master, b'key expansion' + srandom + crandom, 96)
|
||||
client_mac, server_mac = kb[0:32], kb[32:64]
|
||||
client_key, server_key = kb[64:80], kb[80:96]
|
||||
print('client_key: %s server_key: %s' % (client_key.hex(), server_key.hex()))
|
||||
|
||||
names = {0x15: 'alert', 0x16: 'handshake', 0x17: 'appdata', 0x14: 'ccs'}
|
||||
for who, stream, key in (('card', client, client_key),
|
||||
('server', server, server_key)):
|
||||
app_seq = 0
|
||||
for rtype, ver, body in record_payloads(stream):
|
||||
if rtype not in (0x15, 0x17) or len(body) < 16 + 32:
|
||||
continue
|
||||
iv, ct, mac = body[:16], body[16:-32], body[-32:]
|
||||
try:
|
||||
pt = aes_cbc_decrypt(key, iv, ct)
|
||||
except RuntimeError as e:
|
||||
print('%s seq%d %s: decrypt failed: %s' % (who, app_seq, names.get(rtype), e))
|
||||
app_seq += 1
|
||||
continue
|
||||
# verify the record MAC (seq, type, version, len, plaintext)
|
||||
h = hmac.new(client_mac if who == 'card' else server_mac,
|
||||
app_seq.to_bytes(8, 'big') + bytes([rtype]) + ver +
|
||||
len(pt).to_bytes(2, 'big') + pt, hashlib.sha256).digest()
|
||||
mac_ok = hmac.compare_digest(h, mac)
|
||||
desc = ''
|
||||
if rtype == 0x15 and len(pt) >= 2:
|
||||
level = {1: 'warning', 2: 'fatal'}.get(pt[0], str(pt[0]))
|
||||
alerts = {0: 'close_notify', 10: 'unexpected_message',
|
||||
20: 'bad_record_mac', 40: 'handshake_failure',
|
||||
46: 'protocol_version', 47: 'illegal_parameter',
|
||||
48: 'unknown_ca', 49: 'access_denied',
|
||||
50: 'decode_error', 51: 'decrypt_error',
|
||||
80: 'internal_error', 90: 'user_canceled',
|
||||
100: 'no_renegotiation', 110: 'unsupported_extension',
|
||||
112: 'unrecognized_name'}
|
||||
desc = 'ALERT %s %s' % (level, alerts.get(pt[1], pt[1]))
|
||||
print('%s seq%d %-9s mac_ok=%s pt=%s %s'
|
||||
% (who, app_seq, names.get(rtype), mac_ok, pt[:48].hex(), desc))
|
||||
app_seq += 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user