Compare commits
119 Commits
30969f8f04
..
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 | |||
| dfb9551eb7 | |||
| 8459040856 | |||
| a5cccee17c | |||
| 2a559371ae | |||
| e0e86e31af | |||
| 2ec7a2d6c0 | |||
| 26f0063696 | |||
| ddc6cb8f9d | |||
| 85a66af7a5 | |||
| 92271d6726 | |||
| 4598a70db2 | |||
| 82fb2c8800 | |||
| f16e24ba11 | |||
| 8b814eb2f8 | |||
| 56c0e60462 | |||
| 68aa37f093 | |||
| 5485d61390 | |||
| 45c72d874f | |||
| 1fe5c6347d | |||
| c4ed064318 | |||
| 2a43424ae6 | |||
| 5b9e821d6d | |||
| f1ed6a2a48 | |||
| 4b19b58bb0 | |||
| 6f63b5172c | |||
| 6d30e7095e | |||
| a261675aad | |||
| 450f1de68a | |||
| 74efc20bf5 | |||
| d2bd2ed798 | |||
| ccd0b6018c | |||
| 9363f209ee | |||
| 33443d4f28 | |||
| fe10603aea | |||
| 57eb412b6d | |||
| 7288c22830 | |||
| 25787017e5 | |||
| b50be83da4 | |||
| 6c57ff4fd5 | |||
| 895d0b7b36 | |||
| ef00b2c3f4 | |||
| 0fa9548c59 | |||
| 30ff0f7a74 | |||
| 5d096453fb | |||
| d728a5a341 | |||
| f64f2dac7c | |||
| 612f267349 | |||
| 00c84307aa | |||
| efbdb5024c | |||
| fa4064f1e8 | |||
| 71da10b637 | |||
| aa6a9ed1b5 | |||
| 36281e02fd | |||
| 88f67f5e13 | |||
| 994075920d | |||
| 130b9d7e3b | |||
| 1e067dc883 | |||
| 6bfbb00994 | |||
| 944fccf67b | |||
| 758603a9ad | |||
| 47539c594f | |||
| 8d725642dd | |||
| 2d7b2f4ef0 | |||
| 2f299c769d | |||
| 74741a37cd | |||
| 26ee8974cc | |||
| 025e90a270 | |||
| 0e459d1224 | |||
| 2e889e0dce | |||
| a9275477fb | |||
| dbf9e8559b | |||
| ad543fd731 | |||
| 16c183f79c | |||
| d7204b3e96 | |||
| 8f24200537 | |||
| 0ecfd5f720 | |||
| d9b2cb7db2 | |||
| 486f57eb30 | |||
| 69b4fbfb42 | |||
| 5355a62d88 | |||
| 7614067b8f | |||
| 181544b360 | |||
| c31885d40e | |||
| 3ca014991a |
@@ -0,0 +1 @@
|
||||
*.html linguist-detectable=false
|
||||
@@ -4,3 +4,4 @@ __pycache__/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
AGENTS.md
|
||||
|
||||
@@ -31,13 +31,13 @@ npm run build
|
||||
|
||||
## Interface
|
||||
|
||||
Four top-level tabs: **C-APDU**, **SCP80**, **Response parser**, **Card reader**. The C-APDU and SCP80 tabs each have sub-tabs.
|
||||
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**.
|
||||
|
||||
---
|
||||
|
||||
## C-APDU tab
|
||||
## Remote APDU tab
|
||||
|
||||
Builds command APDUs (C-APDUs). Five sub-tabs cover different card generations and command sets.
|
||||
Builds command APDUs (C-APDUs). Seven sub-tabs cover different card generations, command sets and decoding tools: **SIM RFM**, **USIM RFM**, **Expanded Script**, **RAM/GP**, **HTTP OTA**, **C-APDU Parser**, and **Response parser**.
|
||||
|
||||
### SIM RFM
|
||||
|
||||
@@ -312,8 +312,41 @@ Swaps nibble pairs of an even-length hex string.
|
||||
- ETSI TS 102 225: Secured packet structure for (U)SIM toolkit
|
||||
- pySim: enc_imsi() implementation
|
||||
|
||||
### C-APDU Parser
|
||||
|
||||
Pastes raw APDU hex and renders a collapsible tree. It auto-detects the container: an **Expanded Script** (leading `AA` or `AE80`, decoded per ETSI TS 102 226 §5.2.1) or a **Compact C-APDU chain** (a sequence of ISO 7816 C-APDUs). Each node shows its label, hex, and a short description; parent nodes expand to reveal their sub-elements.
|
||||
|
||||
### HTTP OTA
|
||||
|
||||
Builds the Remote Application Management over HTTP payloads defined in GlobalPlatform **GPC v2.2 Amendment B v1.1** (§4.7). Two modes:
|
||||
|
||||
- **Trigger (Push SMS)** — administration session triggering parameters (`81 > 83 > 84/[85]/[86]/89`, Table 4-3). This is the message that asks the card's Security Domain to dial out and start an HTTP session.
|
||||
- **Store (SD admin params)** — writes the same parameters as card (Security Domain) data via **STORE DATA in TLV mode** (`80 E2 90 00`, P1=90 = last block + BER-TLV per GP v2.2 Amendment B v1.1.3), wrapped in tag `85` (or `A5`) per Table 4-4.
|
||||
|
||||
| Section | Tag | Contents |
|
||||
|---|---|---|
|
||||
| Connection parameters | `84` | COMPREHENSION-TLVs needed to open the TCP connection (OPEN CHANNEL per TS 102 223): Device Identities `02`, Alpha `80`, Bearer `01`, vendor TLVs. Row editor + presets, editable hex. |
|
||||
| Security parameters | `85` | Table 4-6: LV PSK Identity (text), LV Key version/KID. Identifies the PSK TLS key (RFC 4279). |
|
||||
| Retry policy | `86` | Table 4-7: retry counter (2 bytes, e.g. `B000`), retry waiting delay as the TS 102 223 timer TLV (`25 03 HH MM SS`), optional vendor-specific report-failure TLV. |
|
||||
| HTTP POST | `89` | Tables 4-8/9/10: Host header (`8A`), X-Admin-From agent ID (`8B`), URI (`8C`) — text converted to octets. |
|
||||
|
||||
The **Command Scripting template** checkbox wraps the whole `81` triggering command in the definite-length Expanded Remote Application data format (`AA`, ETSI TS 102 226 §5.2.1) for TARs that process the expanded format. **Pack into Secured packet** sends the built payload to the SCP80 tab for SPI/counter filling — insert the TAR the SD listens on (typically the OTASD TAR) there.
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Response parser
|
||||
|
||||
Decodes a raw command response: pick the command that was sent, enter the SW (e.g. `9000`) and the response data hex, then press **Decode**.
|
||||
|
||||
- **Command** — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).
|
||||
- **SW decode** — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.
|
||||
- **Privilege decode** — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.
|
||||
- **Response data** — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## SCP80 tab
|
||||
|
||||
The **SCP80** top-level tab groups SCP80-related views, switched by three pills: **Secured Packet**, **Cards**, and **RAM**. Assembles secured packets per ETSI TS 102 225.
|
||||
@@ -443,17 +476,6 @@ Delete confirms via a browser prompt before sending the GP `DELETE` command via
|
||||
|
||||
---
|
||||
|
||||
## Response parser tab
|
||||
|
||||
Decodes a raw command response: pick the command that was sent, enter the SW (e.g. `9000`) and the response data hex, then press **Decode**.
|
||||
|
||||
- **Command** — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).
|
||||
- **SW decode** — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.
|
||||
- **Privilege decode** — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.
|
||||
- **Response data** — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).
|
||||
|
||||
---
|
||||
|
||||
## Card Reader (pySim integration)
|
||||
|
||||
Connects to the bundled [`pysim-otaman-server`](pysim_otaman_server/) for live card operations.
|
||||
@@ -464,24 +486,69 @@ Connects to the bundled [`pysim-otaman-server`](pysim_otaman_server/) for live c
|
||||
|
||||
Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Click to read contents.
|
||||
|
||||
- Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills above the tree, remembered in `localStorage`)
|
||||
- **Read** — reads the selected file (auto-detects transparent vs record files)
|
||||
- **Edit** — switch to edit mode, modify hex data, click **Save** to write back
|
||||
- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view
|
||||
- Selecting a file shows its FID, file type, size / record layout and the decoded FCI above the contents
|
||||
- Missing files are shown in red (✗); a present but empty DF shows `(empty)`
|
||||
- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy
|
||||
|
||||
### Custom Files
|
||||
### Command Hints
|
||||
|
||||
Type a command name in the **pySim command line** input. Usage hints appear as a tooltip after 300ms. Command autocomplete suggestions appear above the input.
|
||||
|
||||
---
|
||||
|
||||
## Profiler
|
||||
|
||||
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; **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:
|
||||
|
||||
- **Path** — `MF`-rooted (e.g. `MF/7F10/6F3A`) or ADF AID-rooted (e.g. `A0000000871002/6F07`).
|
||||
- **FCP/FCI check** — **Filetype only (FCP)**, **Filetype + size (FCP)** (adds file size, or record length/count for record files), or **Exact FCI** (byte-for-byte comparison of the raw SELECT FCP template `'62'`, catching FID/AID, life-cycle status, security-attribute, and proprietary-parameter changes).
|
||||
- **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*. 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
|
||||
|
||||
The scan dialog asks for a profile name and offers the FCP/FCI mode described above, an **Ignore contents of files** checklist of frequently-overwritten files (all checked by default except `EF.ARR`; the header checkbox toggles the whole list) — `EF.LOCI`, `EF.PSLOCI`, `EF.EPSLOCI`, `EF.5GS3GPPLOCI`, `EF.Keys`, `EF.KeysPS`, `EF.SMS`, `EF.Kc`, `EF.KcGPRS`, `EF.LOCIGPRS`, `EF.CBMID`, `EF.SMSS`, `EF.ACC`, `EF.EPSNSC`, `EF.START-HFN`, `EF.ARR` — and two checked-by-default mask options that capture only the first 4 bytes of `EF.IMSI` and `EF.ICCID` (uncheck for exact matching). A progress line shows *N / total files* with the current path; the options are locked while scanning. Rules are created only for files that actually exist (a FCP template is returned); custom files from the **Custom files** sub-tab are included under the same existence check.
|
||||
|
||||
#### Card snapshots
|
||||
|
||||
The list view has two tabs — **Profiles** and **Card snapshots**. A 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 every card command (SELECT / READ BINARY / READ RECORD) from command to response and stores min/avg/max per type plus the total scan time; the snapshot view shows these in the summary and the select/read time per file (read time per record). Timings are display-only and ignored by checks/comparisons.
|
||||
|
||||
- **New snapshot** scans the card; **Import snapshot** loads JSON.
|
||||
- Each snapshot row has **Open** (all captured data read-only, raw FCI with decoded FCI and contents; only the name is editable), **Export**, and **Delete**.
|
||||
- **Check card snapshot** on a profile row runs the profile rules against a snapshot picked from the list, without a card reader. Files whose contents were not captured are reported as unverifiable errors.
|
||||
- **Compare snapshots** compares two snapshots offline exactly like a profile check: pick the *master* snapshot and the *snapshot to check*, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same report. Every file must match exactly (exact FCI, contents); files present only in the checked snapshot are reported as extra files. In the comparison report the mismatch fields and FCI comparison columns are labeled with the master/checked snapshot names instead of expected/actual.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
|
||||
#### Custom files
|
||||
|
||||
Files not in pysim's model can be added manually:
|
||||
|
||||
1. Switch to the **Custom files** sub-tab
|
||||
1. Switch to the **Custom files** tab in the Profiler list
|
||||
2. Enter the file path (e.g., `3F00/6F46`) and an alias (e.g., `EF.SPN`)
|
||||
3. Click **Add** — the file appears in the tree in italics (unverified)
|
||||
4. Click the file to verify existence — on success, it behaves like a model file
|
||||
4. Use **Edit** on a row to reload it into the form (the button becomes **Save** and a **Cancel** button appears) or **Delete** to remove it
|
||||
5. Click the file to verify existence — on success, it behaves like a model file
|
||||
|
||||
Custom files persist in `localStorage` across sessions. Export/import as JSON for sharing.
|
||||
|
||||
### Proactive UICC Pill
|
||||
## Phone simulator
|
||||
|
||||
The **Proactive UICC** sub-tab in the Card Reader provides real-time CAT session interaction:
|
||||
The **Phone simulator** tab provides real-time CAT session interaction. It has two pills: **Phone** (STK menu, STATUS and polling, subscribed events, proactive command log) and **TR Config** (response data injected into TERMINAL RESPONSEs for proactive commands).
|
||||
|
||||
**Subscribed Events** — the card's SET UP EVENT LIST is displayed with per-event **Send** buttons. Clicking opens a form specific to the event type:
|
||||
|
||||
@@ -489,14 +556,18 @@ The **Proactive UICC** sub-tab in the Card Reader provides real-time CAT session
|
||||
- **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.
|
||||
|
||||
**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):
|
||||
**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):
|
||||
|
||||
| Code | Decoded fields |
|
||||
|------|--------------|
|
||||
@@ -513,11 +584,15 @@ The **Proactive UICC** sub-tab in the Card Reader provides real-time CAT session
|
||||
|
||||
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.
|
||||
|
||||
### Command Hints
|
||||
## SCP81
|
||||
|
||||
Type a command name in the **pySim command line** input. Usage hints appear as a tooltip after 300ms. Command autocomplete suggestions appear above the input.
|
||||
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
|
||||
|
||||
@@ -526,6 +601,14 @@ OTAMan is a Progressive Web App and can be installed for offline use. Use the **
|
||||
- Service worker pre-caches all assets on first visit
|
||||
- App icons at 192×192 and 512×512
|
||||
|
||||
## Theme
|
||||
|
||||
A dark theme is included. It follows the system preference and can be toggled manually with the header button (🌙/☀️); the choice is stored in `localStorage`.
|
||||
|
||||
## Localization
|
||||
|
||||
The interface is in English with Russian support. The language is detected from `navigator.language`; the header toggle (EN/RU) stores the choice in `localStorage`. Switching the language also re-renders visible dynamic views (profile lists, check reports, snapshots, cards, proactive views).
|
||||
|
||||
## Server (pysim-otaman-server)
|
||||
|
||||
The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki) and serves both the OTAMan PWA (from `frontend/`) and a JSON API under `/api/*`.
|
||||
@@ -568,8 +651,12 @@ 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) |
|
||||
| `--poll-interval` | Idle interval before automatic STATUS polling (default 30s) |
|
||||
| `--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) |
|
||||
| `--menu-timeout` | Auto-answer a paused STK command with a timeout TERMINAL RESPONSE (default 60s; `0` disables) |
|
||||
| `--timing` | Log phase durations, card resets and APDU counters with elapsed timestamps |
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
+105
-26
@@ -31,13 +31,13 @@ npm run build
|
||||
|
||||
## Интерфейс
|
||||
|
||||
Четыре вкладки: **C-APDU**, **SCP80**, **Response parser**, **Card reader**. Вкладки C-APDU и SCP80 имеют подвкладки.
|
||||
Шесть вкладок: **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**.
|
||||
|
||||
---
|
||||
|
||||
## Вкладка C-APDU
|
||||
## Вкладка Remote APDU
|
||||
|
||||
Построение команд APDU (C-APDU). Пять подвкладок для разных поколений карт и наборов команд.
|
||||
Построение команд APDU (C-APDU). Семь подвкладок для разных поколений карт, наборов команд и инструментов разбора: **SIM RFM**, **USIM RFM**, **Expanded Script**, **RAM/GP**, **HTTP OTA**, **Разбор C-APDU** и **«Парсер ответов»**.
|
||||
|
||||
### SIM RFM
|
||||
|
||||
@@ -286,8 +286,41 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со
|
||||
- ETSI TS 102 225
|
||||
- pySim: enc_imsi()
|
||||
|
||||
### Разбор C-APDU
|
||||
|
||||
Вставьте сырой hex APDU — отобразится раскрывающееся дерево. Контейнер определяется автоматически: **Expanded Script** (ведущие `AA` или `AE80`, разбор по ETSI TS 102 226 §5.2.1) или **компактная цепочка C-APDU** (последовательность ISO 7816 C-APDU). У каждого узла — метка, hex и краткое описание; родительские узлы раскрываются до подэлементов.
|
||||
|
||||
### HTTP OTA
|
||||
|
||||
Сборка payload-ов Remote Application Management over HTTP по GlobalPlatform **GPC v2.2 Amendment B v1.1** (§4.7). Два режима:
|
||||
|
||||
- **Trigger (Push SMS)** — параметры запуска административной сессии (`81 > 83 > 84/[85]/[86]/89`, Table 4-3). Это сообщение просит Security Domain карты выйти в сеть и начать HTTP-сессию.
|
||||
- **Store (SD admin params)** — запись тех же параметров как данных карты (Security Domain) через **STORE DATA в TLV-режиме** (`80 E2 90 00`, P1=90 = последний блок + BER-TLV по GP v2.2 Amendment B v1.1.3), обёрнутых в тег `85` (или `A5`) по Table 4-4.
|
||||
|
||||
| Секция | Тег | Содержимое |
|
||||
|---|---|---|
|
||||
| Параметры соединения | `84` | COMPREHENSION-TLV для открытия TCP-соединения (OPEN CHANNEL по TS 102 223): Device Identities `02`, Alpha `80`, Bearer `01`, вендорские TLV. Редактор строк + пресеты, редактируемый hex. |
|
||||
| Параметры безопасности | `85` | Table 4-6: LV PSK Identity (текст), LV Key version/KID. Идентифицирует ключ PSK TLS (RFC 4279). |
|
||||
| Политика повторов | `86` | Table 4-7: счётчик повторов (2 байта, напр. `B000`), задержка повтора как timer TLV TS 102 223 (`25 03 HH MM SS`), опциональный вендорский TLV отчёта об ошибке. |
|
||||
| HTTP POST | `89` | Tables 4-8/9/10: заголовок Host (`8A`), X-Admin-From agent ID (`8B`), URI (`8C`) — текст преобразуется в октеты. |
|
||||
|
||||
Чекбокс **Command Scripting template** оборачивает всю команду `81` в формат Expanded Remote Application с определённой длиной (`AA`, ETSI TS 102 226 §5.2.1) для TAR, обрабатывающих расширенный формат. **Pack into Secured packet** отправляет собранный payload на вкладку SCP80 для заполнения SPI/счётчика — укажите там TAR, который слушает SD (обычно OTASD).
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Парсер ответов
|
||||
|
||||
Декодирование ответа команды: выберите отправленную команду, введите SW (например, `9000`) и данные ответа в hex, затем нажмите **Decode**.
|
||||
|
||||
- **Команда** — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, операции с PIN, CAT-команды TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, аутентификация, команды SCP).
|
||||
- **Декодирование SW** — статусные слова по картам generic, UICC (TS 102 221) и GlobalPlatform с автоопределением контекста.
|
||||
- **Декодирование привилегий** — байты привилегий из ответов GET DATA / INSTALL в читаемые флаги.
|
||||
- **Данные ответа** — hex с интерпретацией по команде (например, шаблоны FCP из SELECT).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Вкладка SCP80
|
||||
|
||||
Вкладка **SCP80** группирует SCP80-виды, переключаемые тремя пиллами: **Secured Packet**, **Cards** и **RAM**. Сборка защищённых пакетов по ETSI TS 102 225.
|
||||
@@ -417,17 +450,6 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
---
|
||||
|
||||
## Вкладка Response parser
|
||||
|
||||
Декодирование ответа команды: выберите отправленную команду, введите SW (например, `9000`) и данные ответа в hex, затем нажмите **Decode**.
|
||||
|
||||
- **Команда** — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, операции с PIN, CAT-команды TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, аутентификация, команды SCP).
|
||||
- **Декодирование SW** — статусные слова по картам generic, UICC (TS 102 221) и GlobalPlatform с автоопределением контекста.
|
||||
- **Декодирование привилегий** — байты привилегий из ответов GET DATA / INSTALL в читаемые флаги.
|
||||
- **Данные ответа** — hex с интерпретацией по команде (например, шаблоны FCP из SELECT).
|
||||
|
||||
---
|
||||
|
||||
## Card Reader (интеграция с pySim)
|
||||
|
||||
Подключение к встроенному [`pysim-otaman-server`](pysim_otaman_server/) для работы с картой.
|
||||
@@ -438,35 +460,84 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). Клик для чтения содержимого.
|
||||
|
||||
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы над деревом, выбор сохраняется в `localStorage`)
|
||||
- **Read** — чтение файла (автоопределение transparent/record)
|
||||
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
|
||||
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
|
||||
- При выборе файла над содержимым показываются FID, тип файла, размер / структура записей и декодированный FCI
|
||||
- Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
||||
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым
|
||||
|
||||
### Пользовательские файлы
|
||||
### Подсказки команд
|
||||
|
||||
Введите имя команды в **pySim command line**. Подсказки по использованию появляются через 300 мс. Автодополнение команд — над полем ввода.
|
||||
|
||||
---
|
||||
|
||||
## Профайлер
|
||||
|
||||
Проверка соответствия карты именованному **профилю** — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в `localStorage`.
|
||||
|
||||
- **Новый профиль** создаёт пустой набор правил; **Профиль с карты** сканирует подключённую карту и создаёт по правилу на каждый существующий файл; **Профиль из снимка** создаёт тот же набор правил из сохранённого снимка (те же опции игнорирования/масок/FCP-FCI, без картридера, имя подставляется из снимка); **Импорт профиля** загружает набор из JSON (имя хранится внутри файла).
|
||||
- В каждой строке профиля: **Проверить карту ▶** (на подключённой карте), **Проверить снимок карты** (offline по сохранённому снимку), **Редактировать**, **Экспорт** и **Удалить**.
|
||||
|
||||
Правило файловой системы задаётся:
|
||||
|
||||
- **Путь** — от `MF` (напр. `MF/7F10/6F3A`) или от AID ADF (напр. `A0000000871002/6F07`).
|
||||
- **Проверка FCP/FCI** — **Только тип файла (FCP)**, **Тип файла + размер (FCP)** (добавляет размер файла или длину/число записей) либо **Полный FCI** (побайтовое сравнение сырого шаблона FCP `'62'` из ответа SELECT — ловит изменения FID/AID, life-cycle, security attributes и проприетарных параметров).
|
||||
- **Атрибуты файла** — тип, размер, длина и число записей из шаблона FCP (любое можно не задавать).
|
||||
- **Проверка содержимого** (опционально) — **Exact** (точное равенство hex) или **Mask**, где `?` — пониббловый джокер (маска без `?` — префиксное совпадение, напр. `0891` для MCC/MNC IMSI). Для record-файлов хранится список записей.
|
||||
|
||||
Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. В отчёте поля расхождений и колонки сравнения FCI подписаны `ожидалось (имя профиля)` и `фактически (ICCID карты)` для проверки карты либо `фактически (имя снимка)` для проверки снимка; в заголовке отчёта — `Результаты проверки профиля: <профиль> → <ICCID карты>` (или `… → <имя снимка>`; для сравнения снимков — `Результаты сравнения снимков: <эталон> → <проверяемый>`). Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки.
|
||||
|
||||
#### Опции сканирования «Профиль с карты»
|
||||
|
||||
Диалог сканирования запрашивает имя профиля и предлагает режим FCP/FCI, список **«Игнорировать содержимое файлов»** (все включены, кроме `EF.ARR`; чекбокс в заголовке переключает весь список) — `EF.LOCI`, `EF.PSLOCI`, `EF.EPSLOCI`, `EF.5GS3GPPLOCI`, `EF.Keys`, `EF.KeysPS`, `EF.SMS`, `EF.Kc`, `EF.KcGPRS`, `EF.LOCIGPRS`, `EF.CBMID`, `EF.SMSS`, `EF.ACC`, `EF.EPSNSC`, `EF.START-HFN`, `EF.ARR` — и две включённые по умолчанию маски, сохраняющие только первые 4 байта `EF.IMSI` и `EF.ICCID`. Строка прогресса показывает *N / всего файлов* с текущим путём; во время сканирования опции заблокированы. Правила создаются только для существующих файлов; пользовательские файлы из подвкладки **Custom files** проверяются на существование так же.
|
||||
|
||||
#### Снимки карт
|
||||
|
||||
Представление списка имеет две вкладки — **«Профили»** и **«Снимки карт»**. Снимок — неизменяемая фиксация файловой системы: путь, символьное имя, тип, размер (или длина/число записей), сырой FCI и содержимое (если читается) каждого существующего файла. ICCID декодируется из EF.ICCID и показывается рядом с именем. При сканировании также измеряется время каждой команды карты (SELECT / READ BINARY / READ RECORD) от отправки до ответа; сохраняются min/сред/max по типам и общее время сканирования — они показываются в сводке снимка и по файлам/записям. Время носит информационный характер и не используется при проверках и сравнении.
|
||||
|
||||
- **Новый снимок** сканирует карту; **Импорт снимка** загружает JSON.
|
||||
- В строке снимка: **Открыть** (все данные только для чтения, сырой FCI с декодированным и содержимое; редактируется только имя), **Экспорт**, **Удалить**.
|
||||
- **Проверить снимок карты** в строке профиля выполняет правила профиля на выбранном снимке без картридера. Файлы без захваченного содержимого помечаются как непроверяемые ошибки.
|
||||
- **Сравнить снимки** сравнивает два снимка offline так же, как проверка профиля: выберите *эталонный* снимок и *снимок для проверки*, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию). Всё должно совпадать точно (FCI, содержимое); файлы только в проверяемом снимке помечаются как лишние. В отчёте поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
|
||||
#### Пользовательские файлы
|
||||
|
||||
Файлы, отсутствующие в модели pysim, можно добавить вручную:
|
||||
|
||||
1. Перейдите на вкладку **Custom files**
|
||||
1. Перейдите на вкладку **Custom files** в списке профайлера
|
||||
2. Введите путь (например, `3F00/6F46`) и псевдоним (например, `EF.SPN`)
|
||||
3. Нажмите **Add** — файл появится в дереве курсивом (непроверенный)
|
||||
4. Кликните для проверки существования — при успехе работает как обычный файл
|
||||
4. Кнопка **Edit** загружает запись в форму (кнопка становится **Save**, появляется **Cancel**), **Delete** удаляет запись без подтверждения
|
||||
5. Кликните для проверки существования — при успехе работает как обычный файл
|
||||
|
||||
Пользовательские файлы сохраняются в `localStorage`. Экспорт/импорт в JSON для обмена.
|
||||
|
||||
### Proactive UICC
|
||||
## Симулятор телефона
|
||||
|
||||
Подраздел **Proactive UICC** во вкладке Card Reader обеспечивает взаимодействие с CAT-сессией в реальном времени:
|
||||
Вкладка **«Симулятор телефона»** обеспечивает взаимодействие с CAT-сессией в реальном времени. Две подвкладки: **«Телефон»** (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и **«Конфигурация TR»** (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).
|
||||
|
||||
**Subscribed Events** — список событий SET UP EVENT LIST с кнопками **Send**. Клик открывает форму для конкретного типа события:
|
||||
|
||||
- **События без данных** (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-тегами.
|
||||
|
||||
**PLI Data Dictionary** — редактируемые hex-значения для всех 22 квалификаторов PROVIDE LOCAL INFORMATION (TS 102 223 + TS 131 111). 10 квалификаторов имеют встроенные формы декодирования/кодирования:
|
||||
**Управление таймерами** — сервер выполняет роль терминала для 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 квалификаторов имеют встроенные формы декодирования/кодирования:
|
||||
|
||||
| Код | Декодированные поля |
|
||||
|------|--------------|
|
||||
@@ -483,11 +554,15 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
Значения сохраняются на сервере до перезапуска. Apply → hex обновляется; Save → POST на сервер.
|
||||
|
||||
### Подсказки команд
|
||||
## SCP81
|
||||
|
||||
Введите имя команды в **pySim command line**. Подсказки по использованию появляются через 300 мс. Автодополнение команд — над полем ввода.
|
||||
Вкладка **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
|
||||
|
||||
@@ -502,7 +577,7 @@ OTAMan — Progressive Web App. Можно установить для offline-
|
||||
|
||||
## Локализация
|
||||
|
||||
Интерфейс на английском с поддержкой русского языка. Язык определяется из `navigator.language`. Кнопка переключения (EN/RU) в заголовке сохраняет выбор в `localStorage`.
|
||||
Интерфейс на английском с поддержкой русского языка. Язык определяется из `navigator.language`. Кнопка переключения (EN/RU) в заголовке сохраняет выбор в `localStorage`. При переключении языка динамические представления (списки профилей, отчёты проверок, снимки, карты, proactive) перерисовываются.
|
||||
|
||||
## Совместимость версий
|
||||
|
||||
@@ -557,7 +632,11 @@ pysim-otaman-server --http-port 8080
|
||||
| `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) |
|
||||
| `--apdu-trace` | Лог APDU-трафика в stderr |
|
||||
| `--log-requests` | Лог запросов/ответов в stderr |
|
||||
| `--poll-interval` | Интервал автоопроса STATUS (по умолчанию 30с) |
|
||||
| `--poll-interval` | Интервал автоопроса STATUS (по умолчанию 30с; `0` отключает опрос) |
|
||||
| `--full-pysim-init` | Штатная инициализация/equip из pysim (с лишними сбросами карты). По умолчанию инициализация без лишних сбросов — карта переподключается только по явным equip/reset |
|
||||
| `--no-auto-equip` | Не инициализировать карту автоматически сразу после вставки (по умолчанию автоинициализация включена) |
|
||||
| `--menu-timeout` | Автоответ timeout TERMINAL RESPONSE на приостановленную STK-команду (по умолчанию 60с; `0` отключает) |
|
||||
| `--timing` | Лог длительности фаз, сбросов карты и счётчиков APDU с отметками времени |
|
||||
|
||||
### Устранение неполадок
|
||||
|
||||
|
||||
+362
-68
@@ -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": "1.9.12"}
|
||||
{"version": "2.1.2"}
|
||||
```
|
||||
|
||||
### `GET /api/status`
|
||||
@@ -143,7 +153,7 @@ The SPI2 `por_in_submit` bit (0x20) selects submit-mode PoR.
|
||||
|
||||
### `POST /api/ram-install`
|
||||
|
||||
Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE and its PoR is checked; the sequence aborts on the first PoR error. Requires pySim with `pySim.javacard.CapFile` and `pySim.global_platform` available on the server.
|
||||
Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE and its PoR is checked; the sequence aborts on the first PoR error. The `.cap` archive (a ZIP of nested components) is parsed server-side in `_cap_parse`; no external tooling is required.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
@@ -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",
|
||||
@@ -213,60 +232,6 @@ and the decoded SPI fields.
|
||||
"diffs": [], "spi": {"counter": "counter_must_be_higher", ...}}
|
||||
```
|
||||
|
||||
### `POST /api/ram-install`
|
||||
|
||||
Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE and its PoR is checked; the sequence aborts on the first PoR error. Requires pySim with `pySim.javacard.CapFile` and `pySim.global_platform` available on the server.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"cap_hex": "DECAFFED...",
|
||||
"sd_aid": "A000000003000000",
|
||||
"install_params": "C90000",
|
||||
"stk_params": "",
|
||||
"nv_quota": 0,
|
||||
"volatile_quota": 0,
|
||||
"make_selectable": true,
|
||||
"spi1": "0E", "spi2": "01",
|
||||
"kic": "15", "kid": "15",
|
||||
"tar": "000000",
|
||||
"cntr": "0000000001",
|
||||
"kicKey": "D6FCC023...",
|
||||
"kidKey": "1B07E7E0..."
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Req | Description |
|
||||
|---|---|---|
|
||||
| `cap_hex` | yes | Even-length hex of the `.cap` file (zipped Java Card CAP), max 48 kB (98304 hex chars) |
|
||||
| `sd_aid` | no | Security Domain AID for INSTALL[for load]; empty → default ISD `A000000003000000` |
|
||||
| `install_params` | no | Hex C9 TLV install parameters; if empty, `gen_install_parameters()` is used with the quota/stk params |
|
||||
| `stk_params` | no | Hex CA TLV (TS 102 226 §8.2.1.3.2.1) for SIM toolkit app-specific params |
|
||||
| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) for `gen_install_parameters()` |
|
||||
| `make_selectable` | no | If true (default), final INSTALL uses P1=`0C` (install + make selectable) |
|
||||
|
||||
**Response (success):**
|
||||
```json
|
||||
{"success": true, "failed_step": null,
|
||||
"steps": [{"name": "install_for_load", "apdu": "80E60200...", "por_status": "por_ok", "sw": "9000"},
|
||||
{"name": "load_0", "apdu": "80E80000...", "por_status": "por_ok", "sw": "9000"},
|
||||
{"name": "install_for_install", "apdu": "80E60C00...", "por_status": "por_ok", "sw": "9000"}],
|
||||
"final_cntr": "0000000004",
|
||||
"load_file_aid": "A000000003000000",
|
||||
"module_aid": "A000000003000000",
|
||||
"application_aid": "A000000003000000"}
|
||||
```
|
||||
|
||||
**Response (failure):**
|
||||
```json
|
||||
{"success": false, "failed_step": "load_1",
|
||||
"steps": [{"name": "install_for_load", "por_status": "por_ok", "sw": "9000"},
|
||||
{"name": "load_1", "por_status": "rc_error", "sw": null}],
|
||||
"error": "..."}
|
||||
```
|
||||
|
||||
The `steps` array contains one entry per GP command. `final_cntr` is the counter value after all successful steps (use it to update the card preset). The response is not streamed — all steps run server-side before the JSON is returned.
|
||||
|
||||
### `GET /api/menu`
|
||||
|
||||
Returns the SIM Toolkit SETUP MENU captured from the card's TERMINAL PROFILE
|
||||
@@ -299,7 +264,9 @@ or
|
||||
### `POST /api/menu-respond`
|
||||
|
||||
Sends `TERMINAL RESPONSE` to the current proactive command with the given result
|
||||
code. Continues the proactive chain if the card responds with `91XX`.
|
||||
code. Continues the proactive chain if the card responds with `91XX`. If no
|
||||
response arrives within `--menu-timeout` seconds (default 60, `0` disables), the
|
||||
server watchdog sends the `timeout` result itself.
|
||||
|
||||
```json
|
||||
{"result": "ok", "item_id": 1}
|
||||
@@ -308,9 +275,9 @@ code. Continues the proactive chain if the card responds with `91XX`.
|
||||
| `result` | TERMINAL RESPONSE code | Meaning |
|
||||
|---|---|---|
|
||||
| `ok` | `0x00` | Command performed successfully |
|
||||
| `back` | `0x12` | Backward move requested |
|
||||
| `cancel` | `0x10` | Proactive session terminated |
|
||||
| `timeout` | `0x11` | No response from user |
|
||||
| `cancel` | `0x10` | Proactive session terminated by the user |
|
||||
| `back` | `0x11` | Backward move in the proactive session requested by the user |
|
||||
| `timeout` | `0x12` | No response from the user |
|
||||
|
||||
### `GET /api/stk-status`
|
||||
|
||||
@@ -324,25 +291,38 @@ Returns the current STK session state.
|
||||
Read file content. Auto-detects transparent vs record files.
|
||||
|
||||
```json
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00", "mode": "raw"}
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"], "mode": "raw"}
|
||||
```
|
||||
|
||||
Returns:
|
||||
Returns transparent data:
|
||||
```json
|
||||
{"success": true, "sw": "9000", "file_type": "transparent", "data": "..."}
|
||||
{"success": true, "sw": "9000", "file_type": "transparent", "data": "...",
|
||||
"apdu_times": [{"type": "select", "ms": 12}, {"type": "read_binary", "ms": 9}]}
|
||||
```
|
||||
|
||||
Returns records:
|
||||
```json
|
||||
{"success": true, "sw": "9000", "file_type": "linear_fixed",
|
||||
"records": [{"num": 1, "data": "..."}, {"num": 2, "data": "..."}],
|
||||
"apdu_times": [{"type": "select", "ms": 12},
|
||||
{"type": "read_record", "ms": 11}, {"type": "read_record", "ms": 13}]}
|
||||
```
|
||||
|
||||
`apdu_times` reports each command's duration (command sent to response
|
||||
received) classified as `select`, `read_binary` or `read_record`; the PWA uses
|
||||
it for snapshot timing statistics. Other commands are not reported.
|
||||
|
||||
### `POST /api/write`
|
||||
|
||||
Write raw hex data to a file.
|
||||
|
||||
```json
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_sel": "3F00"}
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_path": ["MF"]}
|
||||
```
|
||||
|
||||
For record files:
|
||||
```json
|
||||
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_sel": "7F10"}
|
||||
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_path": ["MF", "7F10"]}
|
||||
```
|
||||
|
||||
Returns:
|
||||
@@ -355,14 +335,32 @@ Returns:
|
||||
Select a file by name or FID, with optional parent selection.
|
||||
|
||||
```json
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00"}
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"]}
|
||||
```
|
||||
|
||||
`parent_path` lists the path segments from MF to the parent (ADF names or
|
||||
FIDs); the legacy single-segment `parent_sel` is still accepted but is only
|
||||
unambiguous for ADFs. Resolution is strictly parent-scoped: model-known files
|
||||
are selected through the requested parent only (pySim `select_file()`), never
|
||||
via pySim's global selectables or its `probe_file()` model injection, so a
|
||||
same-FID file under another parent is never picked and the filesystem model
|
||||
is not modified. `allow_probe: true` (PWA custom files) additionally allows a
|
||||
model-unknown 4-hex FID to be selected directly; any temporary model object
|
||||
created for it is detached again before the response is sent.
|
||||
|
||||
Returns:
|
||||
```json
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent", "exists": true}
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent",
|
||||
"file_size": 10, "record_len": null, "num_of_rec": null,
|
||||
"fci_hex": "621082024021...",
|
||||
"apdu_times": [{"type": "select", "ms": 12}], "exists": true}
|
||||
```
|
||||
|
||||
`fci_hex` is the raw FCP template (`'62'`) from the SELECT response, used by
|
||||
the PWA's Exact FCI checks; `file_size` / `record_len` / `num_of_rec` drive
|
||||
the profiler's size and record checks. When the file does not exist the
|
||||
endpoint responds `404` with `{"error": "...", "exists": false}`.
|
||||
|
||||
### `POST /api/tree`
|
||||
|
||||
Get directory listing with typed children.
|
||||
@@ -371,7 +369,303 @@ Get directory listing with typed children.
|
||||
{"name": "MF", "fid": "3F00"}
|
||||
```
|
||||
|
||||
Use `parent_path` (or the legacy `parent_sel`) to list a subdirectory, e.g.
|
||||
`{"name": "DF.GSM-ACCESS", "fid": "5F3B", "parent_path": ["MF", "ADF.USIM"]}`.
|
||||
|
||||
Returns:
|
||||
```json
|
||||
{"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]}
|
||||
```
|
||||
|
||||
### `GET /api/events`
|
||||
|
||||
Returns the event list captured from the card's SET UP EVENT LIST (an array of
|
||||
event byte values, or `[]` when none was received).
|
||||
|
||||
### `POST /api/event-send`
|
||||
|
||||
Sends an `ENVELOPE(Event Download)` for a subscribed event.
|
||||
|
||||
```json
|
||||
{"event_type": 4, "event_data": "01A0"}
|
||||
```
|
||||
|
||||
`event_type` is required (the SET UP EVENT LIST event byte); `event_data` is
|
||||
optional hex for events that carry data. Returns the SW and any response data:
|
||||
|
||||
```json
|
||||
{"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
|
||||
first:
|
||||
|
||||
```json
|
||||
[{"type_hex": "25", "type_name": "SET UP MENU", "elapsed": 3.2, "bytes": 97}]
|
||||
```
|
||||
|
||||
### `POST /api/status-poll`
|
||||
|
||||
Manually sends `STATUS` (F2) and, if the card answers `91XX`, runs the
|
||||
proactive chain (FETCH → TERMINAL RESPONSE) until it settles. Returns:
|
||||
|
||||
```json
|
||||
{"sw": "9000", "proactive": true}
|
||||
```
|
||||
|
||||
### `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
|
||||
(plus the profile used):
|
||||
|
||||
```json
|
||||
{"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.
|
||||
|
||||
```json
|
||||
{"enabled": true, "interval": 300}
|
||||
```
|
||||
|
||||
### `POST /api/poll-toggle`
|
||||
|
||||
Turns background STATUS polling on or off.
|
||||
|
||||
```json
|
||||
{"enabled": true}
|
||||
```
|
||||
|
||||
Returns the new state (`{"enabled": ..., "interval": ...}`).
|
||||
|
||||
### `GET /api/pli-qualifiers`
|
||||
|
||||
Lists the PROVIDE LOCAL INFORMATION qualifier codes with their names.
|
||||
|
||||
```json
|
||||
[{"code": "00", "name": "Location Information"}, {"code": "0A", "name": "Battery Charge Level"}]
|
||||
```
|
||||
|
||||
### `GET /api/pli-dict`
|
||||
|
||||
Returns the current PLI data dictionary as a qualifier-code map.
|
||||
|
||||
```json
|
||||
{"00": "0291...", "0A": "64"}
|
||||
```
|
||||
|
||||
### `POST /api/pli-dict`
|
||||
|
||||
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).
|
||||
+179
-129
@@ -35,18 +35,27 @@
|
||||
<li>3GPP TS 23.038 — алфавит GSM 7-bit и DCS</li>
|
||||
<li>3GPP TS 24.008 / 24.301 / 24.501 — коды причин NAS</li>
|
||||
<li>GlobalPlatform Card Specification v2.3.1</li>
|
||||
<li>GlobalPlatform GPC v2.2 Amendment B v1.1 — Remote Application Management over HTTP</li>
|
||||
<li>ISO/IEC 7816-4 — команды обмена</li>
|
||||
<li>ISO/IEC 9797-1 — алгоритмы MAC</li>
|
||||
</ul>
|
||||
|
||||
<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>. Рядом с заголовком небольшой индикатор показывает состояние сервера/карты (серая точка = подключение, красная = нет сервера, значки карты = нет карты / инициализация / карта готова) и компактный значок <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>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>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Вкладка C-APDU</h2>
|
||||
<p class="mb-3">Построение командных APDU (C-APDU). Пять подвкладок охватывают разные поколения карт и наборы команд: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong> и <strong>C-APDU Parser</strong>.</p>
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Вкладка Remote APDU</h2>
|
||||
<p class="mb-3">Построение командных APDU (C-APDU). Семь подвкладок охватывают разные поколения карт, наборы команд и инструменты разбора: <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>.</p>
|
||||
|
||||
<h3 id="sim-rfm" class="text-lg font-medium mb-2">2.1 SIM RFM</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">A0</code> (GSM 11.11 / TS 151 011, ISO 7816-4). Удалённое управление файлами классических SIM-карт.</p>
|
||||
<p class="mb-2">Команды собираются в виде <strong>цепочки</strong>: нажмите кнопку <code class="font-mono text-sm">+ Command</code>, чтобы добавить строку, заполните её поля — предпросмотр цепочки (над кнопкой упаковки) обновится автоматически. Добавьте строку <strong>GET RESPONSE</strong>, чтобы получить данные после SELECT. Кнопка <strong>Pack into Secured packet</strong> упаковывает всю цепочку в пакет SCP80.</p>
|
||||
<p class="mb-2">Команды собираются в виде <strong>цепочки</strong>: нажмите кнопку <code class="font-mono text-sm">+ SELECT</code> (или другую командную кнопку), чтобы добавить строку, заполните её поля — предпросмотр цепочки (над кнопкой упаковки) обновится автоматически. Добавьте строку <strong>GET RESPONSE</strong>, чтобы получить данные после SELECT. Кнопка <strong>Упаковать в Secured packet</strong> упаковывает всю цепочку в пакет SCP80.</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">INS</th><th class="text-left py-1 px-2">Описание</th>
|
||||
@@ -106,14 +115,13 @@
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">C-APDU</td><td class="py-1 px-2 font-mono">22</td><td class="py-1 px-2">APDU</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Immediate Action</td><td class="py-1 px-2 font-mono">81</td><td class="py-1 px-2">Проактивная команда или action indicator</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Error Action</td><td class="py-1 px-2 font-mono">82</td><td class="py-1 px-2">Условное восстановление при ошибках с action indicator или проактивной командой</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Script Chaining</td><td class="py-1 px-2 font-mono">83</td><td class="py-1 px-2">Многопакетное выполнение скрипта с флагами First/Intermediary/Last</td></tr>
|
||||
<tr><td class="py-1 px-2">Response Type</td><td class="py-1 px-2 font-mono">-</td><td class="py-1 px-2">Индикатор типа ответа: expanded/compact/none</td></tr>
|
||||
<tr><td class="py-1 px-2">Script Chaining</td><td class="py-1 px-2 font-mono">83</td><td class="py-1 px-2">Многопакетное выполнение скрипта с флагами First/Intermediary/Last</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">Сборщик Immediate Action предлагает action indicator (<code class="font-mono text-sm">81</code>/<code class="font-mono text-sm">82</code>), структурированный сборщик проактивных команд (REFRESH, DISPLAY TEXT, PLAY TONE с авто-генерацией COMPREHENSION-TLV), или ручной hex-ввод.</p>
|
||||
|
||||
<h4 id="ber-error-action" class="font-medium mb-2 text-base">Error Action TLV (Tag 82)</h4>
|
||||
<p class="text-sm mb-2">Восстановление при ошибках по TS 102 226 §5.2.1.3 — одна из трёх форм:</p>
|
||||
<p class="text-sm mb-2">Восстановление при ошибках по TS 102 226 §5.2.1.3 — одна из четырёх форм:</p>
|
||||
<ul class="text-sm list-disc pl-5 mb-2">
|
||||
<li><strong>Проактивная команда:</strong> набор COMPREHENSION-TLV с DISPLAY TEXT или PLAY TONE (в Error Action допустимы только эти две, TS 102 226 Table 5.9)</li>
|
||||
<li><strong>Без действия:</strong> <code class="font-mono text-sm">82 00</code></li>
|
||||
@@ -130,14 +138,8 @@
|
||||
<li><strong>Сохранение контекста:</strong> UICC сохраняет состояние безопасности/транзакции между пакетами</li>
|
||||
</ul>
|
||||
|
||||
<h4 id="expanded-response" class="font-medium mb-2 text-base">Expanded Remote Response (TS 102 226 §5.2.2)</h4>
|
||||
<p class="text-sm mb-2">Результаты по каждой команде с деталями ошибок и контекстом цепочки:</p>
|
||||
<ul class="text-sm list-disc pl-5">
|
||||
<li>Номер команды, статус слова, данные ответа для каждой команды</li>
|
||||
<li>Код ошибки и информация о ней для неудачных команд (подсвечено красным)</li>
|
||||
<li>Идентификатор скрипта и позиция для корреляции цепочки (ID, FIRST, LAST)</li>
|
||||
<li>Индикатор типа ответа: 'expanded' vs 'compact' vs 'none'</li>
|
||||
</ul>
|
||||
<h4 id="expanded-response" class="font-medium mb-2 text-base">Декодирование ответов (TS 102 226 §5.2.2)</h4>
|
||||
<p class="text-sm mb-2">Входящие ответы Proof of Receipt декодируются сервером — формат expanded Remote Application response data (TS 102 226 §5.2.2) или компактный формат. Представление Secured Packet показывает результат после <strong>Отправить на карту</strong> (см. <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>): статус PoR (TAR, счётчик, сырой PoR), а статусное слово и данные ответа последней команды подставляются в подвкладку <strong>«Парсер ответов»</strong> (Remote APDU).</p>
|
||||
|
||||
<h3 id="ram-gp" class="text-lg font-medium mb-2">2.4 RAM/GP</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">80</code> (GlobalPlatform Card Specification v2.3.1). Команды удалённого управления приложениями. Строятся тем же сборщиком цепочки, что и SIM/USIM.</p>
|
||||
@@ -203,13 +205,43 @@
|
||||
<li><strong>Nibble swap</strong> — поменять пары полубайтов hex-строки чётной длины.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="c-apdu-parser" class="text-lg font-medium mb-2">2.6 C-APDU Parser</h3>
|
||||
<p class="text-sm mb-3">Вставка raw APDU hex и отображение сворачиваемого дерева. Автоматически определяет контейнер: <strong>Expanded Script</strong> (начало <code class="font-mono text-sm">AA</code> или <code class="font-mono text-sm">AE80</code>, декодируется по ETSI TS 102 226 §5.2.1) или <strong>Compact C-APDU chain</strong> (последовательность C-APDU ISO 7816). Каждый узел показывает метку, hex и краткое описание; родительские узлы раскрываются в подэлементы.
|
||||
<h3 id="c-apdu-parser" class="text-lg font-medium mb-2">2.6 Разбор C-APDU</h3>
|
||||
<p class="text-sm mb-3">Вставка raw APDU hex и отображение сворачиваемого дерева. Автоматически определяет контейнер: <strong>Expanded Script</strong> (начало <code class="font-mono text-sm">AA</code> или <code class="font-mono text-sm">AE80</code>, декодируется по ETSI TS 102 226 §5.2.1) или <strong>Compact C-APDU chain</strong> (последовательность C-APDU ISO 7816). Каждый узел показывает метку, hex и краткое описание; родительские узлы раскрываются в подэлементы.</p>
|
||||
|
||||
<h3 id="http-ota" class="text-lg font-medium mb-2">2.7 HTTP OTA</h3>
|
||||
<p class="text-sm mb-3">Сборка payload’ов Remote Application Management over HTTP по GlobalPlatform <strong>GPC v2.2 Amendment B v1.1</strong> (§4.7). Два режима:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Триггер (Push SMS)</strong> — параметры запуска административной сессии (<code class="font-mono text-sm">81 > 83 > 84/[85]/[86]/89</code>, таблица 4-3). Сообщение, которое просит Security Domain карты исходящим запросом начать HTTP-сессию.</li>
|
||||
<li><strong>Store (параметры SD)</strong> — записывает те же параметры как данные карты (параметры Security Domain) командой <strong>STORE DATA в TLV-режиме</strong> (<code class="font-mono text-sm">80 E2 90 00</code>, P1=90 = последний блок + BER-TLV по GP v2.2 Amendment B v1.1.3), обёрнутые в тег <code class="font-mono text-sm">85</code> (или <code class="font-mono text-sm">A5</code>) по таблице 4-4.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-2">Разделы соответствуют таблицам спецификации:</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">Tag</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">Параметры соединения</td><td class="py-1 px-2 font-mono">84</td><td class="py-1 px-2">Любые COMPREHENSION-TLV для открытия TCP-соединения (OPEN CHANNEL по TS 102 223): Device Identities <code class="font-mono text-sm">02</code>, Alpha <code class="font-mono text-sm">80</code>, Bearer <code class="font-mono text-sm">01</code>, вендорские TLV. Редактор строк + пресеты, редактируемый hex.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Параметры безопасности</td><td class="py-1 px-2 font-mono">85</td><td class="py-1 px-2">Таблица 4-6: LV PSK Identity (текст), LV Key version/KID. Идентифицирует ключ PSK TLS (RFC 4279).</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Политика повтора</td><td class="py-1 px-2 font-mono">86</td><td class="py-1 px-2">Таблица 4-7: счётчик повторов (2 байта, напр. <code class="font-mono text-sm">B000</code>), задержка повторной попытки как timer TLV из TS 102 223 (<code class="font-mono text-sm">25 03 HH MM SS</code>), опциональный вендорский TLV отчёта о сбое.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">HTTP POST</td><td class="py-1 px-2 font-mono">89</td><td class="py-1 px-2">Таблицы 4-8/9/10: Host-заголовок (<code class="font-mono text-sm">8A</code>), X-Admin-From agent ID (<code class="font-mono text-sm">8B</code>), URI (<code class="font-mono text-sm">8C</code>) — текст преобразуется в октеты.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-2">Флажок <strong>Обёртка в Command Scripting template ('AA')</strong> оборачивает всю команду <code class="font-mono text-sm">81</code> в формат Expanded Remote Application data с определённой длиной (<code class="font-mono text-sm">AA</code>, ETSI TS 102 226 §5.2.1) для TAR-ов, обрабатывающих расширенный формат (RAM-over-HTTP §4.7).</p>
|
||||
<p class="text-sm mb-2"><strong>Упаковать в Secured packet</strong> отправляет готовый payload на вкладку SCP80 для заполнения SPI/счётчика — там укажите TAR, который слушает SD (обычно TAR OTASD).</p>
|
||||
|
||||
|
||||
|
||||
<h3 id="response-parser" class="text-lg font-medium mb-2">2.8 «Парсер ответов»</h3>
|
||||
<p class="mb-3">Декодирует raw-ответ команды: выберите отправленную команду, введите SW (например, <code class="font-mono text-sm">9000</code>) и hex данных ответа, затем нажмите <strong>Декодировать</strong>. Поля также автоматически заполняются статусным словом и данными ответа последней команды после успешного нажатия <strong>Отправить на карту</strong> (см. <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>Команда</strong> — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, PIN-операции, CAT-команды типа TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, …) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP-команды).</li>
|
||||
<li><strong>Декодирование SW</strong> — статусные слова разрешаются по generic-, UICC- (TS 102 221) и GlobalPlatform-таблицам, контекст определяется автоматически.</li>
|
||||
<li><strong>Декодирование привилегий</strong> — ответы GET DATA / INSTALL декодируют байты привилегий в читаемые флаги.</li>
|
||||
<li><strong>Данные ответа</strong> — raw hex отображается и интерпретируется согласно команде (например, FCP-шаблоны SELECT).</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<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>Cards</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>
|
||||
@@ -240,10 +272,10 @@
|
||||
<li>AES требует счётчик с защитой от повтора: биты SPI1 b5 b4 должны быть <code class="font-mono text-sm">10</code> (счётчик больше) или <code class="font-mono text-sm">11</code> (счётчик +1) согласно TS 102 225 §5.1.2/§5.1.3.1</li>
|
||||
<li>Байт паддинга настраивается (<code class="font-mono text-sm">00</code> по умолчанию или <code class="font-mono text-sm">FF</code>)</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Кнопка «Verify vs pySim» сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка «Send to Card» доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу).</p>
|
||||
<p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>«Парсер ответов»</strong> (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.</p>
|
||||
|
||||
<h3 id="cards" class="text-lg font-medium mb-2">3.2 Cards</h3>
|
||||
<p class="mb-2">Хранит предустановки карт локально в браузере (<code class="font-mono text-sm">localStorage</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры.</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 могло автоматически подставлять ключи и параметры, а слушатель 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>
|
||||
@@ -253,72 +285,141 @@
|
||||
<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>Export as JSON</strong> / <strong>Import JSON from clipboard</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>Cards</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">Explore Card (all GP data)</td><td class="py-1 px-2">Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками <strong>Delete</strong> для каждого элемента.</td></tr>
|
||||
<tr><td class="py-1 px-2">Install Package (.cap file)</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 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)]. Load-файл делится на LOAD APDU, каждый из которых помещается в один SMS SCP80; поле <strong>размер блока LOAD</strong> переопределяет авто-подобранный размер (пусто = максимальный размер, чей secured-пакет укладывается в 140 октетов), так что большой <code>.cap</code> просто занимает несколько SMS.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 id="ram-explorer" class="font-medium mb-1">Обзор карты (Explorer View)</h4>
|
||||
<p class="text-sm mb-2">После выполнения «Explore Card» отображается:</p>
|
||||
<p class="text-sm mb-2">После выполнения «Обзор карты» отображается:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>ISD</strong> — AID, жизненный цикл, привилегии (без удаления; ISD нельзя удалить)</li>
|
||||
<li><strong>Приложения</strong> — AID, жизненный цикл, привилегии, связанный ELF/SD. Каждое имеет кнопку <strong>Delete</strong> (GP <code class="font-mono text-sm">DELETE</code> по AID).</li>
|
||||
<li><strong>Executable Load Files</strong> — AID, жизненный цикл, версии, AID модулей. Каждый имеет <strong>Delete</strong> (только ELF) и <strong>Delete All</strong> (каскадное: ELF + модули + установленные приложения, P2=<code class="font-mono text-sm">0x80</code>).</li>
|
||||
<li><strong>Приложения</strong> — AID, жизненный цикл, привилегии, связанный ELF/SD. Каждое имеет кнопку <strong>Удалить</strong> (GP <code class="font-mono text-sm">DELETE</code> по AID).</li>
|
||||
<li><strong>Исполняемые файлы загрузки (ELF) / пакеты</strong> — AID, жизненный цикл, версии, AID модулей. Каждый имеет <strong>Удалить</strong> (только ELF) и <strong>Удалить все</strong> (каскадное: ELF + модули + установленные приложения, P2=<code class="font-mono text-sm">0x80</code>).</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Удаление подтверждается через диалог браузера перед отправкой команды GP <code class="font-mono text-sm">DELETE</code> через SCP80. Обзор автоматически обновляется после успешного удаления.</p>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="response-parser" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка Response parser</h2>
|
||||
<p class="mb-3">Декодирует raw-ответ команды: выберите отправленную команду, введите SW (например, <code class="font-mono text-sm">9000</code>) и hex данных ответа, затем нажмите <strong>Decode</strong>.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>Команда</strong> — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, PIN-операции, CAT-команды типа TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, …) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP-команды).</li>
|
||||
<li><strong>Декодирование SW</strong> — статусные слова разрешаются по generic-, UICC- (TS 102 221) и GlobalPlatform-таблицам, контекст определяется автоматически.</li>
|
||||
<li><strong>Декодирование привилегий</strong> — ответы GET DATA / INSTALL декодируют байты привилегий в читаемые флаги.</li>
|
||||
<li><strong>Данные ответа</strong> — raw hex отображается и интерпретируется согласно команде (например, FCP-шаблоны SELECT).</li>
|
||||
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка «Картридер» (pySim)</h2>
|
||||
<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> (пиллы и кнопка <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>
|
||||
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li>
|
||||
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 Командная строка pySim</h3>
|
||||
<p class="text-sm mb-3">Выполнение любых команд pySim-shell с подсказками (300 мс) и автодополнением.</p>
|
||||
|
||||
<h3 id="raw-apdu" class="text-lg font-medium mb-2">4.3 Отправка APDU</h3>
|
||||
<p class="text-sm mb-3">Отправка произвольного APDU и просмотр ответа.</p>
|
||||
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">4.4 Сценарии использования</h3>
|
||||
|
||||
<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>Выберите корень, введите путь родительского 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>
|
||||
<li>Экспортируйте список пользовательских файлов в JSON для переноса на другие машины.</li>
|
||||
</ol>
|
||||
|
||||
<h4 id="scenario-b" class="font-medium mb-1">Сценарий B — Симуляция реальной сетевой среды для тестирования SIM</h4>
|
||||
<p class="text-sm mb-1"><strong>B.1 Ответы на PROVIDE LOCAL INFORMATION (PLI)</strong></p>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Откройте <strong>Симулятор телефона</strong> → <strong>Данные для PROVIDE LOCAL INFORMATION</strong>.</li>
|
||||
<li>Используйте формы декодирования/кодирования для IMEI (<code class="font-mono text-sm">01</code>), Location Info (<code class="font-mono text-sm">00</code>), Access Technology (<code class="font-mono text-sm">06</code>) и т.д.</li>
|
||||
<li>Нажмите <strong>Сохранить</strong> — значения сохранятся на сервере.</li>
|
||||
<li>Включите <strong>Опрос</strong> (интервал 30 с), чтобы карта периодически выдавала PLI.</li>
|
||||
<li>Сервер вставляет значения словаря в каждый TERMINAL RESPONSE.</li>
|
||||
<li>Проверьте в журнале проактивных команд: запись PLI покажет декодированный ответ.</li>
|
||||
</ol>
|
||||
<p class="text-sm mb-1"><strong>B.2 Симуляция сетевых действий через ENVELOPE (event download)</strong></p>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Проверьте список <strong>подписанных событий</strong> (из SET UP EVENT LIST).</li>
|
||||
<li>Нажмите <strong>Отправить</strong> на событии (например, Location Status) и заполните форму; будет отправлен <code class="font-mono text-sm">ENVELOPE(Event Download)</code>.</li>
|
||||
<li>Для <strong>Network Rejection</strong> выберите тип регистрации → поля местоположения → технологию доступа → причину отклонения.</li>
|
||||
<li>Карта может ответить проактивной командой, которую обработчик цепочки зарегистрирует и обработает автоматически.</li>
|
||||
</ol>
|
||||
<p class="text-sm mb-1"><strong>B.3 Проверка симулированной среды</strong></p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li>Журнал проактивных команд показывает полный цикл (команда + байты TERMINAL RESPONSE).</li>
|
||||
<li>Кнопка <strong>Отправить STATUS</strong> / автопросмотр поддерживают сессию CAT (цикл дренажа).</li>
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Вкладка Card reader (pySim)</h2>
|
||||
<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> для работы с картой. Подвкладки: <strong>File manager</strong>, <strong>Custom files</strong>, <strong>Profiler</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong> и <strong>Proactive UICC</strong>.</p>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">5.1 File manager</h3>
|
||||
<h2 id="profiler" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Профайлер</h2>
|
||||
<p class="text-sm mb-2">Проверяет соответствие карты именованному <strong>профилю</strong> — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
<h4 class="font-medium mb-1">Список профилей</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Read</strong> — чтение файла (автоопределение transparent/record)</li>
|
||||
<li><strong>Edit</strong> — изменить hex-данные, <strong>Save</strong> для записи</li>
|
||||
<li><strong>Raw / Decoded</strong> — переключение между hex-дампом и декодированным JSON</li>
|
||||
<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>, <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>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Путь</strong> — начинается с <code class="font-mono text-sm">MF</code> (например, <code class="font-mono text-sm">MF/7F10/6F3A</code>) или с AID ADF (например, <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
|
||||
<li><strong>Проверка FCP/FCI</strong> — какая часть информации об управлении файлом проверяется: <strong>Только тип файла (FCP)</strong> (существование + тип файла), <strong>Тип файла + размер (FCP)</strong> (добавляются размер файла либо длина/число записей для record-файлов) или <strong>Полный FCI</strong> (добавляется побайтовое сравнение сырого ответа SELECT — шаблона FCP <code class="font-mono text-sm">'62'</code> — выявляет изменения FID/AID, жизненного цикла, атрибутов безопасности и проприетарных параметров).</li>
|
||||
<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> со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения 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> включаются с той же проверкой существования. Тот же диалог и опции использует <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>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Новый снимок</strong> — запрашивает имя и сканирует карту, затем возвращает к списку.</li>
|
||||
<li><strong>Импорт снимка</strong> — загружает снимок из JSON-файла.</li>
|
||||
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
|
||||
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты: фактическая сторона подписана именем снимка (<em>фактически (имя снимка)</em>), а в заголовке — <em>Результаты проверки профиля: <профиль> → <имя снимка></em>; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
|
||||
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля, но <strong>всегда точно</strong> (маскирование содержимого не применяется): выберите <em>эталонный</em> снимок и <em>снимок для проверки</em> и получите такой же отчёт; в этом отчёте заголовок — <em>Результаты сравнения снимков: <эталон> → <проверяемый></em>, а поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="custom-files" class="text-lg font-medium mb-2">5.2 Custom files</h3>
|
||||
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim. Сохраняется в <code class="font-mono text-sm">localStorage</code>; экспорт/импорт JSON.</p>
|
||||
</section>
|
||||
|
||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">5.3 pySim command line</h3>
|
||||
<p class="text-sm mb-3">Выполнение любых команд pySim-shell с подсказками (300 мс) и автодополнением.</p>
|
||||
|
||||
<h3 id="raw-apdu" class="text-lg font-medium mb-2">5.4 Raw APDU</h3>
|
||||
<p class="text-sm mb-3">Отправка произвольного APDU и просмотр ответа.</p>
|
||||
|
||||
<h3 id="proactive-uicc" class="text-lg font-medium mb-2">5.5 Proactive UICC</h3>
|
||||
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit: меню STK, подписанные события, журнал проактивных команд, словарь данных PROVIDE LOCAL INFORMATION и опрос STATUS.</p>
|
||||
<h4 id="custom-files" class="font-medium mb-1">Пользовательские файлы</h4>
|
||||
<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>
|
||||
|
||||
<h4 id="stk-menu" class="font-medium mb-1">5.5.1 Меню STK</h4>
|
||||
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «STK menu» с изумрудной кнопкой <strong>STK: <название></strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «No menu set by the card». Состояние меню обновляется при каждом открытии представления.</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>
|
||||
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Две подвкладки: <strong>«Телефон»</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и <strong>«Конфигурация TR»</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).</p>
|
||||
|
||||
<h4 id="subscribed-events" class="font-medium mb-1">5.5.2 Подписанные события (SET UP EVENT LIST)</h4>
|
||||
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Send</strong>, открывающая форму, специфичную для типа события:</p>
|
||||
<h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 Меню STK</h3>
|
||||
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой <strong>STK: <название></strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает). <strong>Назад</strong> и <strong>Timeout</strong> продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять.</p>
|
||||
|
||||
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Подписанные события (SET UP EVENT LIST)</h3>
|
||||
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>События без данных</strong> (User Activity, Idle Screen, Data Available, …) — уведомление в один клик</li>
|
||||
<li><strong>Location Status</strong> — выпадающий список: Normal / Limited / No service (тег <code class="font-mono text-sm">9B</code>)</li>
|
||||
@@ -327,10 +428,16 @@
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Отправка события использует <code class="font-mono text-sm">ENVELOPE(Event Download)</code> по TS 102 223 / TS 131 111.</p>
|
||||
|
||||
<h4 id="proactive-log" class="font-medium mb-1">5.5.3 Журнал проактивных команд</h4>
|
||||
<p class="text-sm mb-2">Хронологический список извлечённых проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор (для команд, у которых он есть). Для команд с данными ответа показывается строка <code class="font-mono text-sm">Response:</code> с байтами TERMINAL RESPONSE (без служебных TLV); ответы PROVIDE LOCAL INFORMATION декодируются через словарь данных PLI.</p>
|
||||
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Журнал проактивных команд</h3>
|
||||
<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>
|
||||
|
||||
<h4 id="pli-dict" class="font-medium mb-1">5.5.4 Словарь данных PROVIDE LOCAL INFORMATION</h4>
|
||||
<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.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>
|
||||
@@ -340,71 +447,13 @@
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Когда карта выдаёт PLI, сервер вставляет значения словаря в TERMINAL RESPONSE.</p>
|
||||
|
||||
<h4 id="status-polling" class="font-medium mb-1">5.5.5 Опрос STATUS</h4>
|
||||
<p class="text-sm mb-3">Кнопка <strong>Send STATUS</strong> отправляет STATUS (F2) вручную. Переключатель <strong>Polling</strong> включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера <code class="font-mono text-sm">--poll-interval</code>, 1–255 с, по умолчанию 30 с) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.</p>
|
||||
|
||||
<h3 id="profiler" class="text-lg font-medium mb-2">5.6 Profiler</h3>
|
||||
<p class="text-sm mb-2">Проверяет соответствие карты именованному <strong>профилю</strong> — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
<h4 class="font-medium mb-1">Список профилей</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>New profile</strong> — создаёт пустой набор правил, запросив имя.</li>
|
||||
<li><strong>Profile from card</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
|
||||
<li><strong>Import profile</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li>
|
||||
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Edit</strong>, <strong>Check</strong>, <strong>Export</strong> (скачать JSON) и <strong>Delete</strong>.</li>
|
||||
</ul>
|
||||
<h4 class="font-medium mb-1">Правила файловой системы</h4>
|
||||
<p class="text-sm mb-2">Правила выполняются последовательно. Правило файловой системы задаётся:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Путь</strong> — начинается с <code class="font-mono text-sm">MF</code> (например, <code class="font-mono text-sm">MF/7F10/6F3A</code>) или с AID ADF (например, <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
|
||||
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCI-шаблона (любой можно оставить незаданным).</li>
|
||||
<li><strong>Содержимое</strong> (опционально) — <strong>Exact</strong> (точное совпадение hex) или <strong>Mask</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>Check</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения (существование, каждый атрибут FCI и совпадение содержимого).</p>
|
||||
<h4 class="font-medium mb-1">Опции сканирования «Profile from card»</h4>
|
||||
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает список <strong>«Ignore contents of»</strong> (все отмечены по умолчанию) часто перезаписываемых файлов, содержимое которых пропускается: <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>. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCI-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>Custom files</strong> включаются с той же проверкой существования.</p>
|
||||
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Сценарии использования</h3>
|
||||
|
||||
<h4 id="scenario-a" class="font-medium mb-1">Сценарий A — Работа с файлами, не входящими в модель pySim (Custom files)</h4>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Получите FID целевого файла (документация вендора или анализ ATR/файловой системы; такие файлы часто отсутствуют в открытых спецификациях).</li>
|
||||
<li>Откройте вкладку <strong>Card reader</strong> → подвкладку <strong>Custom files</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>Add</strong> — файл появится в дереве курсивом (непроверенный).</li>
|
||||
<li>Кликните по файлу для проверки существования; при успехе (<code class="font-mono text-sm">9000</code>) он работает как обычный файл.</li>
|
||||
<li>Читайте, редактируйте и сохраняйте hex-данные; переключайте Raw/Decoded.</li>
|
||||
<li>Экспортируйте список пользовательских файлов в JSON для переноса на другие машины.</li>
|
||||
</ol>
|
||||
|
||||
<h4 id="scenario-b" class="font-medium mb-1">Сценарий B — Симуляция реальной сетевой среды для тестирования SIM</h4>
|
||||
<p class="text-sm mb-1"><strong>B.1 Ответы на PROVIDE LOCAL INFORMATION (PLI)</strong></p>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Откройте <strong>Proactive UICC</strong> → <strong>PROVIDE LOCAL INFORMATION response data</strong>.</li>
|
||||
<li>Используйте формы декодирования/кодирования для IMEI (<code class="font-mono text-sm">01</code>), Location Info (<code class="font-mono text-sm">00</code>), Access Technology (<code class="font-mono text-sm">06</code>) и т.д.</li>
|
||||
<li>Нажмите <strong>Save</strong> — значения сохранятся на сервере.</li>
|
||||
<li>Включите <strong>Polling</strong> (интервал 30 с), чтобы карта периодически выдавала PLI.</li>
|
||||
<li>Сервер вставляет значения словаря в каждый TERMINAL RESPONSE.</li>
|
||||
<li>Проверьте в журнале проактивных команд: запись PLI покажет декодированный ответ.</li>
|
||||
</ol>
|
||||
<p class="text-sm mb-1"><strong>B.2 Симуляция сетевых действий через ENVELOPE (event download)</strong></p>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Проверьте список <strong>подписанных событий</strong> (из SET UP EVENT LIST).</li>
|
||||
<li>Нажмите <strong>Send</strong> на событии (например, Location Status) и заполните форму; будет отправлен <code class="font-mono text-sm">ENVELOPE(Event Download)</code>.</li>
|
||||
<li>Для <strong>Network Rejection</strong> выберите тип регистрации → поля местоположения → технологию доступа → причину отклонения.</li>
|
||||
<li>Карта может ответить проактивной командой, которую обработчик цепочки зарегистрирует и обработает автоматически.</li>
|
||||
</ol>
|
||||
<p class="text-sm mb-1"><strong>B.3 Проверка симулированной среды</strong></p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li>Журнал проактивных команд показывает полный цикл (команда + байты TERMINAL RESPONSE).</li>
|
||||
<li>Кнопка STATUS / автопросмотр поддерживают сессию CAT (цикл дренажа).</li>
|
||||
</ul>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Установка сервера</h2>
|
||||
<p class="mb-3">Для работы с картой (вкладка Card reader, Proactive UICC, доставка OTA) нужен локальный <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — встроенный в OTAMan HTTP-сервер, оборачивающий pySim, работающий с ридером через PC/SC или serial и раздающий сам PWA (откройте <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Установка сервера</h2>
|
||||
<p class="mb-3">Для работы с картой (вкладка «Картридер», «Симулятор телефона», доставка OTA) нужен локальный <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — встроенный в OTAMan HTTP-сервер, оборачивающий pySim, работающий с ридером через PC/SC или serial и раздающий сам PWA (откройте <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
|
||||
|
||||
<h3 id="prerequisites" class="text-lg font-medium mb-2">6.1 Требования</h3>
|
||||
<h3 id="prerequisites" class="text-lg font-medium mb-2">7.1 Требования</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Python 3.8+</strong> с <code class="font-mono text-sm">pip</code></li>
|
||||
<li><strong>Git</strong></li>
|
||||
@@ -412,20 +461,20 @@
|
||||
<li><strong>Только Windows</strong> — используйте <strong>Python 3.10–3.13</strong> (рекомендуется 3.13): <code class="font-mono text-sm">pyscard</code> (обёртка драйвера PC/SC) поставляет готовые wheels для этих версий. На Python 3.9 / 3.14 pip собирает <code class="font-mono text-sm">pyscard</code> из исходников, для чего требуются Microsoft C++ Build Tools («Desktop development with C++»). Мост SMPP (<code class="font-mono text-sm">smpp.twisted3</code>) на Windows намеренно не устанавливается, поэтому для Python 3.10–3.13 C++ Build Tools не нужны.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">6.2 Быстрый старт — Linux / macOS</h3>
|
||||
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">7.2 Быстрый старт — Linux / macOS</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
chmod +x setup.sh start.sh
|
||||
./setup.sh # создаёт .venv, устанавливает pysim + сервер (однократно)
|
||||
./start.sh # запускает сервер (PWA + API, автоопределение ридера)</pre>
|
||||
|
||||
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">6.3 Быстрый старт — Windows</h3>
|
||||
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">7.3 Быстрый старт — Windows</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
setup.bat # создаёт .venv, устанавливает pysim + сервер (однократно)
|
||||
start.bat # запускает сервер (PWA + API)</pre>
|
||||
|
||||
<h3 id="helper-scripts" class="text-lg font-medium mb-2">6.4 Вспомогательные скрипты</h3>
|
||||
<h3 id="helper-scripts" class="text-lg font-medium mb-2">7.4 Вспомогательные скрипты</h3>
|
||||
<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>
|
||||
@@ -434,15 +483,16 @@ start.bat # запускает сервер (PWA + API)</pre>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">6.5 Автоопределение ридера (<code class="font-mono text-sm">start.sh</code>)</h3>
|
||||
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">7.5 Автоопределение ридера</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>PC/SC (Linux)</strong> — если запущен демон <code class="font-mono text-sm">pcscd</code>, передаёт <code class="font-mono text-sm">-p 0</code></li>
|
||||
<li><strong>Serial (Linux)</strong> — если существует <code class="font-mono text-sm">/dev/ttyUSB0</code>, передаёт <code class="font-mono text-sm">-d /dev/ttyUSB0</code></li>
|
||||
<li><strong>PC/SC (Windows)</strong> — всегда использует <code class="font-mono text-sm">-p 0</code> (PC/SC встроен в Windows)</li>
|
||||
<li><strong>PC/SC (Linux)</strong> — <code class="font-mono text-sm">start.sh</code> передаёт <code class="font-mono text-sm">-p 0</code>, если запущен демон <code class="font-mono text-sm">pcscd</code></li>
|
||||
<li><strong>PC/SC (Windows)</strong> — <code class="font-mono text-sm">start.bat</code> всегда использует <code class="font-mono text-sm">-p 0</code> (PC/SC встроен в Windows)</li>
|
||||
<li><strong>Резерв сервера</strong> — при запуске без аргументов ридера сервер сам опрашивает PC/SC-ридер при старте (3 попытки с интервалом 2 с)</li>
|
||||
<li><strong>Serial-ридеры</strong> — запустите сервер вручную с <code class="font-mono text-sm">-d /dev/ttyUSB0</code> (Linux)</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Если ридер не обнаружен, сервер запускается без аргументов и показывает «Reader: none». Карту можно инициализировать позже кнопкой <strong>Equip</strong> на вкладке Card reader.</p>
|
||||
<p class="text-sm mb-3">Если карта отсутствует, вкладка «Картридер» показывает «Карта не обнаружена. Вставьте карту и нажмите Подключить карту».</p>
|
||||
|
||||
<h3 id="manual-install" class="text-lg font-medium mb-2">6.6 Ручная установка</h3>
|
||||
<h3 id="manual-install" class="text-lg font-medium mb-2">7.6 Ручная установка</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Создать и активировать venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS
|
||||
@@ -461,7 +511,7 @@ pysim-otaman-server --http-port 8080</pre>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Совместимость версий</h2>
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">8. Совместимость версий</h2>
|
||||
<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">PWA (OTAMan)</th><th class="text-left py-1 px-2">Сервер</th><th class="text-left py-1 px-2">Статус</th></tr></thead>
|
||||
<tbody>
|
||||
|
||||
+161
-111
@@ -35,18 +35,27 @@
|
||||
<li>3GPP TS 23.038 — GSM 7-bit alphabet and DCS</li>
|
||||
<li>3GPP TS 24.008 / 24.301 / 24.501 — NAS cause codes</li>
|
||||
<li>GlobalPlatform Card Specification v2.3.1</li>
|
||||
<li>GlobalPlatform GPC v2.2 Amendment B v1.1 — Remote Application Management over HTTP</li>
|
||||
<li>ISO/IEC 7816-4 — commands for interchange</li>
|
||||
<li>ISO/IEC 9797-1 — MAC algorithms</li>
|
||||
</ul>
|
||||
|
||||
<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. 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>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>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. C-APDU tab</h2>
|
||||
<p class="mb-3">Builds command APDUs (C-APDUs). Five sub-tabs cover different card generations and command sets: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, and <strong>C-APDU Parser</strong>.</p>
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Remote APDU tab</h2>
|
||||
<p class="mb-3">Builds command APDUs (C-APDUs). Seven sub-tabs cover different card generations, command sets and decoding tools: <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>, and <strong>Response parser</strong>.</p>
|
||||
|
||||
<h3 id="sim-rfm" class="text-lg font-medium mb-2">2.1 SIM RFM</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">A0</code> (GSM 11.11 / TS 151 011, ISO 7816-4). Remote File Management for classic SIM cards.</p>
|
||||
<p class="mb-2">Commands are assembled as a <strong>chain</strong>: press a <code class="font-mono text-sm">+ Command</code> button to append a row, fill that row’s fields, and the chain preview (above the pack button) updates automatically. Add a <strong>GET RESPONSE</strong> row to fetch data following a SELECT. Press <strong>Pack into Secured packet</strong> to wrap the whole chain into an SCP80 packet.</p>
|
||||
<p class="mb-2">Commands are assembled as a <strong>chain</strong>: press a command button (e.g. <code class="font-mono text-sm">+ SELECT</code>) to append a row, fill that row’s fields, and the chain preview (above the pack button) updates automatically. Add a <strong>GET RESPONSE</strong> row to fetch data following a SELECT. Press <strong>Pack into Secured packet</strong> to wrap the whole chain into an SCP80 packet.</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">Command</th><th class="text-left py-1 px-2">INS</th><th class="text-left py-1 px-2">Description</th>
|
||||
@@ -106,14 +115,13 @@
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">C-APDU</td><td class="py-1 px-2 font-mono">22</td><td class="py-1 px-2">Raw APDU hex</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Immediate Action</td><td class="py-1 px-2 font-mono">81</td><td class="py-1 px-2">Proactive command or action indicator</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Error Action</td><td class="py-1 px-2 font-mono">82</td><td class="py-1 px-2">Conditional error recovery with action indicator or proactive command</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Script Chaining</td><td class="py-1 px-2 font-mono">83</td><td class="py-1 px-2">Multi-packet script execution with First/Intermediary/Last flags</td></tr>
|
||||
<tr><td class="py-1 px-2">Response Type</td><td class="py-1 px-2 font-mono">-</td><td class="py-1 px-2">Expanded/Compact/None response parsing indicator</td></tr>
|
||||
<tr><td class="py-1 px-2">Script Chaining</td><td class="py-1 px-2 font-mono">83</td><td class="py-1 px-2">Multi-packet script execution with First/Intermediary/Last flags</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">The Immediate Action builder offers an action indicator (<code class="font-mono text-sm">81</code>/<code class="font-mono text-sm">82</code>), a structured proactive command builder (REFRESH, DISPLAY TEXT, PLAY TONE with auto-generated COMPREHENSION-TLV objects), or a freeform hex input.</p>
|
||||
|
||||
<h4 id="ber-error-action" class="font-medium mb-2 text-base">Error Action TLV (Tag 82)</h4>
|
||||
<p class="text-sm mb-2">Error recovery per TS 102 226 §5.2.1.3 — one of three forms:</p>
|
||||
<p class="text-sm mb-2">Error recovery per TS 102 226 §5.2.1.3 — one of four forms:</p>
|
||||
<ul class="text-sm list-disc pl-5 mb-2">
|
||||
<li><strong>Proactive command:</strong> COMPREHENSION-TLV set with DISPLAY TEXT or PLAY TONE (only these two are allowed in an Error Action, TS 102 226 Table 5.9)</li>
|
||||
<li><strong>No action:</strong> <code class="font-mono text-sm">82 00</code></li>
|
||||
@@ -130,14 +138,8 @@
|
||||
<li><strong>Context Preservation:</strong> UICC keeps security/transaction state open across chained scripts</li>
|
||||
</ul>
|
||||
|
||||
<h4 id="expanded-response" class="font-medium mb-2 text-base">Expanded Remote Response (TS 102 226 §5.2.2)</h4>
|
||||
<p class="text-sm mb-2">Per-command results with error details and chaining context:</p>
|
||||
<ul class="text-sm list-disc pl-5">
|
||||
<li>Command number, status word, response data for each command</li>
|
||||
<li>Error code and error info for failed commands (highlighted in red)</li>
|
||||
<li>Script ID and position for chained script correlation (ID, FIRST, LAST)</li>
|
||||
<li>Response type indicator: 'expanded' vs 'compact' vs 'none'</li>
|
||||
</ul>
|
||||
<h4 id="expanded-response" class="font-medium mb-2 text-base">Response decoding (TS 102 226 §5.2.2)</h4>
|
||||
<p class="text-sm mb-2">Incoming Proof-of-Receipt responses are decoded by the server — expanded Remote Application response data (TS 102 226 §5.2.2) or the compact format. The Secured Packet view shows the outcome after <strong>Send to Card</strong> (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>): the PoR status (TAR, counter, raw PoR), with the last command’s status word and response data filled into the <strong>Response parser</strong> pill under Remote APDU.</p>
|
||||
|
||||
<h3 id="ram-gp" class="text-lg font-medium mb-2">2.4 RAM/GP</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">80</code> (GlobalPlatform Card Specification v2.3.1). Remote Application Management commands for card content management. Built with the same chain builder as SIM/USIM: add rows, fill fields, and the chain preview updates automatically.</p>
|
||||
@@ -204,7 +206,37 @@
|
||||
</ul>
|
||||
|
||||
<h3 id="c-apdu-parser" class="text-lg font-medium mb-2">2.6 C-APDU Parser</h3>
|
||||
<p class="text-sm mb-3">Pastes raw APDU hex and renders a collapsible tree. It auto-detects the container: an <strong>Expanded Script</strong> (leading <code class="font-mono text-sm">AA</code> or <code class="font-mono text-sm">AE80</code>, decoded per ETSI TS 102 226 §5.2.1) or a <strong>Compact C-APDU chain</strong> (a sequence of ISO 7816 C-APDUs). Each node shows its label, hex and a short description; parent nodes expand to reveal their sub-elements.
|
||||
<p class="text-sm mb-3">Pastes raw APDU hex and renders a collapsible tree. It auto-detects the container: an <strong>Expanded Script</strong> (leading <code class="font-mono text-sm">AA</code> or <code class="font-mono text-sm">AE80</code>, decoded per ETSI TS 102 226 §5.2.1) or a <strong>Compact C-APDU chain</strong> (a sequence of ISO 7816 C-APDUs). Each node shows its label, hex and a short description; parent nodes expand to reveal their sub-elements.</p>
|
||||
|
||||
<h3 id="http-ota" class="text-lg font-medium mb-2">2.7 HTTP OTA</h3>
|
||||
<p class="text-sm mb-3">Builds the Remote Application Management over HTTP payloads defined in GlobalPlatform <strong>GPC v2.2 Amendment B v1.1</strong> (§4.7). Two modes:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Trigger (Push SMS)</strong> — administration session triggering parameters (<code class="font-mono text-sm">81 > 83 > 84/[85]/[86]/89</code>, Table 4-3). This is the message that asks the card's Security Domain to dial out and start an HTTP session.</li>
|
||||
<li><strong>Store (SD admin params)</strong> — writes the same parameters as card (Security Domain) data via <strong>STORE DATA in TLV mode</strong> (<code class="font-mono text-sm">80 E2 90 00</code>, P1=90 = last block + BER-TLV per GP v2.2 Amendment B v1.1.3), wrapped in tag <code class="font-mono text-sm">85</code> (or <code class="font-mono text-sm">A5</code>) per Table 4-4.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-2">Sections mirror the spec tables:</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">Section</th><th class="text-left py-1 px-2">Tag</th><th class="text-left py-1 px-2">Contents</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Connection parameters</td><td class="py-1 px-2 font-mono">84</td><td class="py-1 px-2">Any COMPREHENSION-TLV needed to open the TCP connection (OPEN CHANNEL per TS 102 223): Device Identities <code class="font-mono text-sm">02</code>, Alpha <code class="font-mono text-sm">80</code>, Bearer <code class="font-mono text-sm">01</code>, vendor TLVs. Row editor + presets, editable hex.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Security parameters</td><td class="py-1 px-2 font-mono">85</td><td class="py-1 px-2">Table 4-6: LV PSK Identity (text), LV Key version/KID. Identifies the PSK TLS key (RFC 4279).</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Retry policy</td><td class="py-1 px-2 font-mono">86</td><td class="py-1 px-2">Table 4-7: retry counter (2 bytes, e.g. <code class="font-mono text-sm">B000</code>), retry waiting delay as the TS 102 223 timer TLV (<code class="font-mono text-sm">25 03 HH MM SS</code>), optional vendor-specific report-failure TLV.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">HTTP POST</td><td class="py-1 px-2 font-mono">89</td><td class="py-1 px-2">Tables 4-8/9/10: Host header (<code class="font-mono text-sm">8A</code>), X-Admin-From agent ID (<code class="font-mono text-sm">8B</code>), URI (<code class="font-mono text-sm">8C</code>) — text converted to octets.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-2"><strong>Command Scripting template</strong> (checkbox) wraps the whole <code class="font-mono text-sm">81</code> triggering command in the definite-length Expanded Remote Application data format (<code class="font-mono text-sm">AA</code>, ETSI TS 102 226 §5.2.1) for TARs that process the expanded format (RAM-over-HTTP §4.7).</p>
|
||||
<p class="text-sm mb-2"><strong>Pack into Secured packet</strong> sends the built payload to the SCP80 tab for SPI/counter filling — insert the TAR the SD listens on (typically the OTASD TAR) there.</p>
|
||||
|
||||
|
||||
|
||||
<h3 id="response-parser" class="text-lg font-medium mb-2">2.8 Response parser</h3>
|
||||
<p class="mb-3">Decodes a raw command response: pick the command that was sent, enter the SW (e.g. <code class="font-mono text-sm">9000</code>) and the response data hex, then press <strong>Decode</strong>. The fields are also auto-filled with the last command’s status word and response data after a successful “Send to Card” (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>Command</strong> — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, …) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).</li>
|
||||
<li><strong>SW decode</strong> — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.</li>
|
||||
<li><strong>Privilege decode</strong> — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.</li>
|
||||
<li><strong>Response data</strong> — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
@@ -240,10 +272,10 @@
|
||||
<li>AES requires a replay-protected counter: SPI1 bits b5 b4 must be <code class="font-mono text-sm">10</code> (counter higher) or <code class="font-mono text-sm">11</code> (counter +1) per TS 102 225 §5.1.2/§5.1.3.1</li>
|
||||
<li>Padding byte configurable (<code class="font-mono text-sm">00</code> default, or <code class="font-mono text-sm">FF</code>)</li>
|
||||
</ul>
|
||||
<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).</p>
|
||||
<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>
|
||||
@@ -253,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"><strong>Export as JSON</strong> / <strong>Import JSON from clipboard</strong> share presets. 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>
|
||||
|
||||
@@ -281,95 +315,31 @@
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="response-parser" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Response parser tab</h2>
|
||||
<p class="mb-3">Decodes a raw command response: pick the command that was sent, enter the SW (e.g. <code class="font-mono text-sm">9000</code>) and the response data hex, then press <strong>Decode</strong>.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>Command</strong> — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, …) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).</li>
|
||||
<li><strong>SW decode</strong> — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.</li>
|
||||
<li><strong>Privilege decode</strong> — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.</li>
|
||||
<li><strong>Response data</strong> — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).</li>
|
||||
</ul>
|
||||
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Card reader (pySim) tab</h2>
|
||||
<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>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Card reader (pySim) tab</h2>
|
||||
<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. Sub-tabs: <strong>File manager</strong>, <strong>Custom files</strong>, <strong>Profiler</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>, and <strong>Proactive UICC</strong>.</p>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">5.1 File manager</h3>
|
||||
<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 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</li>
|
||||
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
|
||||
<li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li>
|
||||
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="custom-files" class="text-lg font-medium mb-2">5.2 Custom files</h3>
|
||||
<p class="text-sm mb-3">Add files that pySim’s model does not cover. Persists in <code class="font-mono text-sm">localStorage</code>; JSON export/import.</p>
|
||||
|
||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">5.3 pySim command line</h3>
|
||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 pySim command line</h3>
|
||||
<p class="text-sm mb-3">Execute any pySim-shell command with usage hints (300 ms) and autocomplete.</p>
|
||||
|
||||
<h3 id="raw-apdu" class="text-lg font-medium mb-2">5.4 Raw APDU</h3>
|
||||
<h3 id="raw-apdu" class="text-lg font-medium mb-2">4.3 Raw APDU</h3>
|
||||
<p class="text-sm mb-3">Send an arbitrary APDU and view the raw response.</p>
|
||||
|
||||
<h3 id="proactive-uicc" class="text-lg font-medium mb-2">5.5 Proactive UICC</h3>
|
||||
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session: the STK menu, subscribed events, the proactive command log, the PROVIDE LOCAL INFORMATION data dictionary, and STATUS polling.</p>
|
||||
|
||||
<h4 id="stk-menu" class="font-medium mb-1">5.5.1 STK menu</h4>
|
||||
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald <strong>STK: <title></strong> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened.</p>
|
||||
|
||||
<h4 id="subscribed-events" class="font-medium mb-1">5.5.2 Subscribed events (SET UP EVENT LIST)</h4>
|
||||
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>No-data events</strong> (User Activity, Idle Screen, Data Available, …) — one-click notification</li>
|
||||
<li><strong>Location Status</strong> — dropdown: Normal / Limited / No service (tag <code class="font-mono text-sm">9B</code>)</li>
|
||||
<li><strong>Access Technology Change</strong> — 13 RAT types (tag <code class="font-mono text-sm">BF</code>)</li>
|
||||
<li><strong>Network Rejection</strong> — full adaptive form: registration type (LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access technology, and a 53-cause unified rejection cause dropdown covering EMM, GMM, 5GMM and LU causes</li>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
<h4 id="proactive-log" class="font-medium mb-1">5.5.3 Proactive command log</h4>
|
||||
<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>
|
||||
|
||||
<h4 id="pli-dict" class="font-medium mb-1">5.5.4 PROVIDE LOCAL INFORMATION data dictionary</h4>
|
||||
<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>
|
||||
<li><strong>01</strong> IMEI · <strong>03</strong> Date/Time/TZ · <strong>04</strong> Language · <strong>05</strong> Timing Advance</li>
|
||||
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
||||
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Values persist server-side until restart. When the card issues PLI, the server injects the dictionary values into the TERMINAL RESPONSE.</p>
|
||||
|
||||
<h4 id="status-polling" class="font-medium mb-1">5.5.5 STATUS polling</h4>
|
||||
<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) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.</p>
|
||||
|
||||
<h3 id="profiler" class="text-lg font-medium mb-2">5.6 Profiler</h3>
|
||||
<p class="text-sm mb-2">Verifies that a card matches a named <strong>profile</strong> — an ordered set of rules describing the expected file system and (optionally) file contents. Profiles are stored in <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
<h4 class="font-medium mb-1">Profile list</h4>
|
||||
<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>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>Edit</strong>, <strong>Check</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li>
|
||||
</ul>
|
||||
<h4 class="font-medium mb-1">Filesystem rules</h4>
|
||||
<p class="text-sm mb-2">Rules run sequentially. A filesystem rule is defined by:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Path</strong> — starts with <code class="font-mono text-sm">MF</code> (e.g. <code class="font-mono text-sm">MF/7F10/6F3A</code>) or an ADF AID (e.g. <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
|
||||
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCI template (any may be left unset).</li>
|
||||
<li><strong>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</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report (existence, each FCI attribute, and the content match).</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 an <strong>“Ignore contents of”</strong> checklist (all checked by default) 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>. Rules are created only for files that actually exist on the card (an FCI 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>
|
||||
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Usage scenarios</h3>
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">4.4 Usage scenarios</h3>
|
||||
|
||||
<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>
|
||||
@@ -379,7 +349,7 @@
|
||||
<h4 id="scenario-b" class="font-medium mb-1">Scenario B — Simulating a real network environment for SIM testing</h4>
|
||||
<p class="text-sm mb-1"><strong>B.1 Answer PROVIDE LOCAL INFORMATION (PLI)</strong></p>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Open <strong>Proactive UICC</strong> → <strong>PROVIDE LOCAL INFORMATION response data</strong>.</li>
|
||||
<li>Open <strong>Phone simulator</strong> → <strong>PROVIDE LOCAL INFORMATION response data</strong>.</li>
|
||||
<li>Use the decode/encode forms to set IMEI (<code class="font-mono text-sm">01</code>), Location Info (<code class="font-mono text-sm">00</code>), Access Technology (<code class="font-mono text-sm">06</code>), etc.</li>
|
||||
<li>Click <strong>Save</strong> — values persist server-side.</li>
|
||||
<li>Enable <strong>Polling</strong> (interval 30 s) so the card issues PLI periodically.</li>
|
||||
@@ -400,11 +370,90 @@
|
||||
</ul>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Server installation</h2>
|
||||
<p class="mb-3">Live card operations (Card reader tab, Proactive UICC, OTA delivery) require the local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — a small HTTP server bundled with OTAMan that wraps pySim, talks to the reader over PC/SC or serial, and also serves the PWA itself (open <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
|
||||
|
||||
<h3 id="prerequisites" class="text-lg font-medium mb-2">6.1 Prerequisites</h3>
|
||||
<section class="mb-10">
|
||||
<h2 id="profiler" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Profiler</h2>
|
||||
<p class="text-sm mb-2">Verifies that a card matches a named <strong>profile</strong> — an ordered set of rules describing the expected file system and (optionally) file contents. Profiles are stored in <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
<h4 class="font-medium mb-1">Profile list</h4>
|
||||
<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>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>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Path</strong> — starts with <code class="font-mono text-sm">MF</code> (e.g. <code class="font-mono text-sm">MF/7F10/6F3A</code>) or an ADF AID (e.g. <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
|
||||
<li><strong>FCP/FCI check</strong> — how much of the file control information to verify: <strong>Filetype only (FCP)</strong> (existence + file type), <strong>Filetype + size (FCP)</strong> (adds file size, or record length/count for record files), or <strong>Exact FCI</strong> (adds a byte-for-byte comparison of the raw SELECT response — the FCP template <code class="font-mono text-sm">'62'</code> — catching FID/AID, life-cycle status, security-attribute and proprietary-parameter changes).</li>
|
||||
<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. 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. 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>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<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: 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>
|
||||
|
||||
|
||||
|
||||
<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. 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>
|
||||
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has two pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log) and <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands).</p>
|
||||
|
||||
<h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 STK menu</h3>
|
||||
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald <strong>STK: <title></strong> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute.</p>
|
||||
|
||||
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Subscribed events (SET UP EVENT LIST)</h3>
|
||||
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>No-data events</strong> (User Activity, Idle Screen, Data Available, …) — one-click notification</li>
|
||||
<li><strong>Location Status</strong> — dropdown: Normal / Limited / No service (tag <code class="font-mono text-sm">9B</code>)</li>
|
||||
<li><strong>Access Technology Change</strong> — 13 RAT types (tag <code class="font-mono text-sm">BF</code>)</li>
|
||||
<li><strong>Network Rejection</strong> — full adaptive form: registration type (LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access technology, and a 53-cause unified rejection cause dropdown covering EMM, GMM, 5GMM and LU causes</li>
|
||||
</ul>
|
||||
<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). 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="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.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>
|
||||
<li><strong>01</strong> IMEI · <strong>03</strong> Date/Time/TZ · <strong>04</strong> Language · <strong>05</strong> Timing Advance</li>
|
||||
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
||||
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Values persist server-side until restart. When the card issues PLI, the server injects the dictionary values into the TERMINAL RESPONSE.</p>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Server installation</h2>
|
||||
<p class="mb-3">Live card operations (Card reader tab, Phone simulator, OTA delivery) require the local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — a small HTTP server bundled with OTAMan that wraps pySim, talks to the reader over PC/SC or serial, and also serves the PWA itself (open <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
|
||||
|
||||
<h3 id="prerequisites" class="text-lg font-medium mb-2">7.1 Prerequisites</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Python 3.8+</strong> with <code class="font-mono text-sm">pip</code></li>
|
||||
<li><strong>Git</strong></li>
|
||||
@@ -412,20 +461,20 @@
|
||||
<li><strong>Windows only</strong> — use <strong>Python 3.10–3.13</strong> (3.13 recommended): <code class="font-mono text-sm">pyscard</code> (the PC/SC driver wrapper) ships precompiled wheels for these versions. On Python 3.9 / 3.14 pip builds <code class="font-mono text-sm">pyscard</code> from source, which requires Microsoft C++ Build Tools (“Desktop development with C++”). The SMPP bridge (<code class="font-mono text-sm">smpp.twisted3</code>) is intentionally not installed on Windows, so no C++ Build Tools are needed for Python 3.10–3.13.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">6.2 Quick start — Linux / macOS</h3>
|
||||
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">7.2 Quick start — Linux / macOS</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
chmod +x setup.sh start.sh
|
||||
./setup.sh # creates .venv, installs pysim + server (run once)
|
||||
./start.sh # starts the server (serves PWA + API, auto-detects reader)</pre>
|
||||
|
||||
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">6.3 Quick start — Windows</h3>
|
||||
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">7.3 Quick start — Windows</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
setup.bat # creates .venv, installs pysim + server (run once)
|
||||
start.bat # starts the server (serves PWA + API)</pre>
|
||||
|
||||
<h3 id="helper-scripts" class="text-lg font-medium mb-2">6.4 Helper scripts</h3>
|
||||
<h3 id="helper-scripts" class="text-lg font-medium mb-2">7.4 Helper scripts</h3>
|
||||
<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">Script</th><th class="text-left py-1 px-2">Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -434,15 +483,16 @@ start.bat # starts the server (serves PWA + API)</pre>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">6.5 Reader auto-detection (<code class="font-mono text-sm">start.sh</code>)</h3>
|
||||
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">7.5 Reader auto-detection</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>PC/SC (Linux)</strong> — if the <code class="font-mono text-sm">pcscd</code> daemon is running, passes <code class="font-mono text-sm">-p 0</code></li>
|
||||
<li><strong>Serial (Linux)</strong> — if <code class="font-mono text-sm">/dev/ttyUSB0</code> exists, passes <code class="font-mono text-sm">-d /dev/ttyUSB0</code></li>
|
||||
<li><strong>PC/SC (Windows)</strong> — always uses <code class="font-mono text-sm">-p 0</code> (PC/SC is built into Windows)</li>
|
||||
<li><strong>PC/SC (Linux)</strong> — <code class="font-mono text-sm">start.sh</code> passes <code class="font-mono text-sm">-p 0</code> when the <code class="font-mono text-sm">pcscd</code> daemon is running</li>
|
||||
<li><strong>PC/SC (Windows)</strong> — <code class="font-mono text-sm">start.bat</code> always uses <code class="font-mono text-sm">-p 0</code> (PC/SC is built into Windows)</li>
|
||||
<li><strong>Server fallback</strong> — started without reader arguments, the server itself probes for a PC/SC reader at startup (3 attempts, 2 s apart)</li>
|
||||
<li><strong>Serial readers</strong> — start the server manually with <code class="font-mono text-sm">-d /dev/ttyUSB0</code> (Linux)</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">If no reader is detected, the server starts without reader arguments and shows “Reader: none”. The card can be initialized later via the <strong>Equip</strong> button in the Card reader tab.</p>
|
||||
<p class="text-sm mb-3">If no card is present, the Card reader tab shows “No card detected”. Insert the card and click <strong>Equip card</strong> to initialize it.</p>
|
||||
|
||||
<h3 id="manual-install" class="text-lg font-medium mb-2">6.6 Manual installation</h3>
|
||||
<h3 id="manual-install" class="text-lg font-medium mb-2">7.6 Manual installation</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Create and activate a venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS
|
||||
@@ -461,7 +511,7 @@ pysim-otaman-server --http-port 8080</pre>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Version compatibility</h2>
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">8. Version compatibility</h2>
|
||||
<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">PWA (OTAMan)</th><th class="text-left py-1 px-2">Server</th><th class="text-left py-1 px-2">Status</th></tr></thead>
|
||||
<tbody>
|
||||
|
||||
+4518
-572
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@
|
||||
"description": "Standalone offline HTML/JS tool for building APDU commands for SIM, USIM, and GlobalPlatform RAM, plus encoding conversions.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js",
|
||||
"build:prod": "NODE_ENV=production npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js --minify",
|
||||
"build": "npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js && cat src/contrast.css >> style.css",
|
||||
"build:prod": "NODE_ENV=production npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js --minify && cat src/contrast.css >> style.css",
|
||||
"test": "node --test"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/* ===== Theme contrast adjustments ===== */
|
||||
|
||||
/* Light theme: darken softer (muted) text one step for higher contrast */
|
||||
.text-gray-400 {
|
||||
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-500 {
|
||||
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-600 {
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: lighten softer (muted) text one step for higher contrast */
|
||||
.dark\:text-slate-500:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark\:text-slate-400:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: red text is too dark on dark backgrounds — lighten it */
|
||||
.dark :where(.text-red-500) {
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark :where(.text-red-600) {
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark :where(.text-red-700) {
|
||||
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Light theme: darken light gray shades (backgrounds & borders) one step */
|
||||
.bg-gray-50 {
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-100 {
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-200 {
|
||||
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.border-gray-100 {
|
||||
border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-gray-200 {
|
||||
border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-gray-300 {
|
||||
border-color: rgb(156 163 175 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.text-gray-300 {
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-100:hover {
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-200:hover {
|
||||
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-300:hover {
|
||||
background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: lighten gray fonts */
|
||||
.dark\:text-gray-400:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark\:text-gray-600:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: gray text without an explicit dark variant (ids, timestamps,
|
||||
expand markers, ...) — match the lighten-on-dark level used above */
|
||||
.dark .text-gray-300:not([class*="dark:text-"]) {
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-400:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-500:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-600:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-700:not([class*="dark:text-"]) {
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-800:not([class*="dark:text-"]) {
|
||||
color: rgb(241 245 249 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: brighten gray borders one step so they stay visible on dark
|
||||
backgrounds */
|
||||
.dark\:border-slate-600:is(.dark *) {
|
||||
border-color: rgb(100 116 139 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.dark\:border-slate-700:is(.dark *) {
|
||||
border-color: rgb(71 85 105 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.dark\:border-slate-700\/50:is(.dark *) {
|
||||
border-color: rgb(71 85 105 / 0.5);
|
||||
}
|
||||
.dark\:border-slate-800:is(.dark *) {
|
||||
border-color: rgb(51 65 85 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
/* Dark theme: normal (non-muted) UI text — keep one step brighter than the
|
||||
muted gray level so the two remain distinguishable */
|
||||
.dark\:text-slate-300:is(.dark *) {
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
+44
-3
@@ -1702,7 +1702,7 @@ video {
|
||||
|
||||
/* Dark theme: lighten softer (muted) text one step for higher contrast */
|
||||
.dark\:text-slate-500:is(.dark *) {
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity, 1));
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark\:text-slate-400:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
@@ -1753,8 +1753,49 @@ video {
|
||||
|
||||
/* Dark theme: lighten gray fonts */
|
||||
.dark\:text-gray-400:is(.dark *) {
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark\:text-gray-600:is(.dark *) {
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: gray text without an explicit dark variant (ids, timestamps,
|
||||
expand markers, ...) — match the lighten-on-dark level used above */
|
||||
.dark .text-gray-300:not([class*="dark:text-"]) {
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-400:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-500:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-600:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-700:not([class*="dark:text-"]) {
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-800:not([class*="dark:text-"]) {
|
||||
color: rgb(241 245 249 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: brighten gray borders one step so they stay visible on dark
|
||||
backgrounds */
|
||||
.dark\:border-slate-600:is(.dark *) {
|
||||
border-color: rgb(100 116 139 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.dark\:border-slate-700:is(.dark *) {
|
||||
border-color: rgb(71 85 105 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.dark\:border-slate-700\/50:is(.dark *) {
|
||||
border-color: rgb(71 85 105 / 0.5);
|
||||
}
|
||||
.dark\:border-slate-800:is(.dark *) {
|
||||
border-color: rgb(51 65 85 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
/* Dark theme: normal (non-muted) UI text — keep one step brighter than the
|
||||
muted gray level so the two remain distinguishable */
|
||||
.dark\:text-slate-300:is(.dark *) {
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
+18
-4
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v47';
|
||||
const CACHE = 'otaman-v176';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
@@ -31,19 +31,33 @@ self.addEventListener('activate', e => {
|
||||
);
|
||||
});
|
||||
|
||||
const OFFLINE_RESPONSE = new Response('Offline: page not cached', {
|
||||
status: 503,
|
||||
statusText: 'Offline',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
if (!e.request.url.startsWith('http')) return;
|
||||
if (new URL(e.request.url).pathname.startsWith('/api/')) return; // live data, never cache
|
||||
const path = new URL(e.request.url).pathname;
|
||||
if (path.startsWith('/api/')) return; // live data, never cache
|
||||
if (e.request.method !== 'GET') return;
|
||||
const isNavigate = e.request.mode === 'navigate' || e.request.url.endsWith('sw.js');
|
||||
const isNavigate = e.request.mode === 'navigate';
|
||||
const isSwScript = path.endsWith('/sw.js');
|
||||
if (isNavigate) {
|
||||
e.respondWith(
|
||||
fetch(e.request).then(res => {
|
||||
const clone = res.clone();
|
||||
caches.open(CACHE).then(c => c.put(e.request, clone));
|
||||
return res;
|
||||
}).catch(() => caches.match(e.request))
|
||||
}).catch(() =>
|
||||
caches.match(e.request)
|
||||
.then(r => r || caches.match('index.html'))
|
||||
.then(r => r || OFFLINE_RESPONSE)
|
||||
)
|
||||
);
|
||||
} else if (isSwScript) {
|
||||
e.respondWith(fetch(e.request).catch(() => OFFLINE_RESPONSE));
|
||||
} else {
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(r => r || fetch(e.request).then(res => {
|
||||
|
||||
@@ -165,6 +165,39 @@ test('parseTlvList handles BER-TLVs', () => {
|
||||
assert.strictEqual(tlvs[1].tag, '82');
|
||||
});
|
||||
|
||||
test('parseBerLen handles short and long form lengths (ISO 7816-4 5.2)', () => {
|
||||
assert.deepStrictEqual(parseBerLen('1200', 0), { len: 18, consumed: 2 });
|
||||
assert.deepStrictEqual(parseBerLen('8112', 0), { len: 18, consumed: 4 });
|
||||
assert.deepStrictEqual(parseBerLen('820100', 0), { len: 256, consumed: 6 });
|
||||
assert.deepStrictEqual(parseBerLen('820182' + '0102030405060708', 0), { len: 386, consumed: 6 });
|
||||
});
|
||||
|
||||
test('parseTlvList parses long-form lengths (81/82)', () => {
|
||||
const short = parseTlvList('6212' + '8202412183026F078A010580020009880110');
|
||||
assert.strictEqual(short.length, 1);
|
||||
assert.strictEqual(short[0].tag, '62');
|
||||
assert.strictEqual(short[0].length, 18);
|
||||
|
||||
// same content with a long-form outer length
|
||||
const long81 = parseTlvList('628112' + '8202412183026F078A010580020009880110');
|
||||
assert.strictEqual(long81.length, 1);
|
||||
assert.strictEqual(long81[0].length, 18);
|
||||
assert.strictEqual(long81[0].value, short[0].value);
|
||||
|
||||
// 2-byte length form
|
||||
const long82 = parseTlvList('62820004' + '80020009');
|
||||
assert.strictEqual(long82.length, 1);
|
||||
assert.strictEqual(long82[0].length, 4);
|
||||
assert.strictEqual(long82[0].value, '80020009');
|
||||
|
||||
// inner TLV with a long-form length (A5 81 05 85 03 00 00 00)
|
||||
const inner = parseTlvList('A581058503000000');
|
||||
assert.strictEqual(inner.length, 1);
|
||||
assert.strictEqual(inner[0].tag, 'A5');
|
||||
assert.strictEqual(inner[0].length, 5);
|
||||
assert.strictEqual(inner[0].value, '8503000000');
|
||||
});
|
||||
|
||||
test('gsm7Decode unpacks "HI" from C824', () => {
|
||||
const bytes = new Uint8Array([0xC8, 0x24]);
|
||||
assert.strictEqual(gsm7Decode(bytes), 'HI');
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
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 _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
|
||||
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\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;
|
||||
_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, adm, calls };
|
||||
}
|
||||
|
||||
function status(extra) {
|
||||
return Object.assign({ connected: false, card_present: false, equipping: false, auto_equip: false, card_session: 1 }, extra);
|
||||
}
|
||||
|
||||
test('disconnect without card shows the no-card message', () => {
|
||||
const { el, calls } = setup();
|
||||
pysimCardStateUpdate(status({}));
|
||||
assert.deepStrictEqual(calls.connected, [false]);
|
||||
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('disconnect with card present shows the Equip hint when auto-equip is off', () => {
|
||||
const { el } = setup();
|
||||
pysimCardStateUpdate(status({ card_present: true }));
|
||||
assert.ok(el.innerHTML.includes('Card inserted — press Equip'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('disconnect with auto-equip shows the initializing message', () => {
|
||||
const { el } = setup();
|
||||
pysimCardStateUpdate(status({ card_present: true, auto_equip: true }));
|
||||
assert.ok(el.innerHTML.includes('initializing'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('unchanged state key does not touch the UI again', () => {
|
||||
const { el, calls } = setup();
|
||||
pysimCardStateUpdate(status({ card_session: 7 }));
|
||||
el.innerHTML = 'unchanged';
|
||||
calls.connected.length = 0;
|
||||
pysimCardStateUpdate(status({ card_session: 7 }));
|
||||
assert.deepStrictEqual(calls.connected, []);
|
||||
assert.strictEqual(el.innerHTML, 'unchanged');
|
||||
});
|
||||
|
||||
test('connected restores the UI and reloads card data', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2 }));
|
||||
assert.deepStrictEqual(calls.connected, [true]);
|
||||
assert.deepStrictEqual(calls.resets, [true]);
|
||||
});
|
||||
|
||||
test('card session change triggers a data reset', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ card_session: 3 }));
|
||||
calls.resets.length = 0;
|
||||
pysimCardStateUpdate(status({ card_session: 4 }));
|
||||
assert.deepStrictEqual(calls.resets, [false]);
|
||||
});
|
||||
|
||||
test('first observation does not trigger a reset on its own', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ card_session: 9 }));
|
||||
assert.deepStrictEqual(calls.resets, []);
|
||||
});
|
||||
|
||||
test('payload without connected flag is ignored', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate({ reader: 'x' });
|
||||
pysimCardStateUpdate(null);
|
||||
assert.deepStrictEqual(calls.connected, []);
|
||||
});
|
||||
|
||||
test('availability state and control gating follow server/card state', () => {
|
||||
_pysimServerAvailable = null;
|
||||
assert.strictEqual(pysimAvailabilityState(), 'server-down');
|
||||
assert.strictEqual(pysimControlDisabled('server', 'server-down'), true);
|
||||
assert.strictEqual(pysimControlDisabled('card', 'server-down'), true);
|
||||
|
||||
_pysimServerAvailable = true;
|
||||
_pysimCardEquipped = false;
|
||||
assert.strictEqual(pysimAvailabilityState(), 'no-card');
|
||||
assert.strictEqual(pysimControlDisabled('server', 'no-card'), false);
|
||||
assert.strictEqual(pysimControlDisabled('card', 'no-card'), true);
|
||||
|
||||
_pysimCardEquipped = true;
|
||||
assert.strictEqual(pysimAvailabilityState(), 'card');
|
||||
assert.strictEqual(pysimControlDisabled('card', 'card'), false);
|
||||
assert.strictEqual(pysimControlDisabled('server', 'card'), false);
|
||||
});
|
||||
|
||||
test('no card with auto-equip enabled still shows the no-card message', () => {
|
||||
const { el } = setup();
|
||||
pysimCardStateUpdate(status({ connected: false, card_present: false, auto_equip: true }));
|
||||
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
|
||||
assert.ok(!el.innerHTML.includes('initializing'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('proactive log refreshes when the status sequence changes', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ proactive_seq: 7 }));
|
||||
pysimCardStateUpdate(status({ proactive_seq: 7 }));
|
||||
assert.strictEqual(calls.proactive, 1);
|
||||
pysimCardStateUpdate(status({ proactive_seq: 8 }));
|
||||
assert.strictEqual(calls.proactive, 2);
|
||||
});
|
||||
|
||||
test('proactive log is not refreshed while the phone view is hidden', () => {
|
||||
const { calls } = setup();
|
||||
globalThis.isViewVisible = () => false;
|
||||
pysimCardStateUpdate(status({ proactive_seq: 3 }));
|
||||
assert.strictEqual(calls.proactive, 0);
|
||||
});
|
||||
|
||||
test('pysimProactiveSeqChanged tracks the last sequence', () => {
|
||||
_pysimProactiveSeq = null;
|
||||
assert.ok(pysimProactiveSeqChanged(4));
|
||||
assert.ok(!pysimProactiveSeqChanged(4));
|
||||
assert.ok(pysimProactiveSeqChanged(5));
|
||||
assert.ok(!pysimProactiveSeqChanged(undefined));
|
||||
assert.ok(!pysimProactiveSeqChanged(null));
|
||||
});
|
||||
|
||||
test('pysimStkStatusChanged detects menu state transitions', () => {
|
||||
_pysimStkSig = null;
|
||||
assert.ok(pysimStkStatusChanged({ active: false, pending: false }));
|
||||
assert.ok(!pysimStkStatusChanged({ active: false, pending: false }));
|
||||
assert.ok(pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
|
||||
assert.ok(!pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
|
||||
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'));
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
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 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 += 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,
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
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-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'),
|
||||
'pysim-cf-cancel-btn': fakeEl('pysim-cf-cancel-btn'),
|
||||
};
|
||||
els['pysim-cf-cancel-btn'].classList.add('hidden');
|
||||
const store = {};
|
||||
globalThis.localStorage = {
|
||||
getItem: k => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
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('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\(1\)/);
|
||||
});
|
||||
|
||||
test('render flags dropped legacy entries', () => {
|
||||
const els = setup([]);
|
||||
_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');
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
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('(?: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;
|
||||
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 = '';
|
||||
code += extractFunc(html, 'getParentSel') + '\n';
|
||||
code += extractFunc(html, 'getParentPath') + '\n';
|
||||
code += extractFunc(html, 'pysimFsLoadChildren') + '\n';
|
||||
code += 'globalThis.pysimCustomInject = () => {};\n';
|
||||
eval(code);
|
||||
|
||||
let calls = [];
|
||||
let responses = [];
|
||||
let renders = 0;
|
||||
|
||||
function setup() {
|
||||
calls = [];
|
||||
responses = [];
|
||||
renders = 0;
|
||||
globalThis.pysimFetch = async (p, body) => {
|
||||
calls.push({ path: p, body: JSON.parse(JSON.stringify(body)) });
|
||||
const r = responses.shift();
|
||||
if (r instanceof Error) throw r;
|
||||
return JSON.parse(JSON.stringify(r));
|
||||
};
|
||||
globalThis.pysimFsRenderTree = () => { renders++; };
|
||||
const node = { name: 'DF.USIM', fid: '7fff', isDir: true, children: null, exists: null, parent: { name: 'MF', fid: '3f00' } };
|
||||
return node;
|
||||
}
|
||||
|
||||
test('tree error payload marks the directory as absent', async () => {
|
||||
const node = setup();
|
||||
responses = [
|
||||
{ success: false, error: 'SW ... 6a82', exists: false },
|
||||
{ success: false, error: 'SW ... 6a82', exists: false },
|
||||
];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, false);
|
||||
assert.strictEqual(node.children, null);
|
||||
assert.strictEqual(renders, 1);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
assert.strictEqual(calls[0].body.parent_sel, 'MF');
|
||||
assert.deepStrictEqual(calls[0].body.parent_path, ['MF']);
|
||||
});
|
||||
|
||||
test('error payload without exists is not treated as an empty listing', async () => {
|
||||
const node = setup();
|
||||
responses = [
|
||||
{ success: false, error: 'boom' },
|
||||
{ success: false, error: 'boom' },
|
||||
];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, false);
|
||||
assert.strictEqual(node.children, null);
|
||||
assert.strictEqual(renders, 1);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
test('a 200 exists:false response marks the directory absent without retrying', async () => {
|
||||
const node = setup();
|
||||
responses = [{ exists: false }];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, false);
|
||||
assert.strictEqual(node.children, null);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
test('a node with loaded children is not fetched again', async () => {
|
||||
const node = setup();
|
||||
node.exists = true;
|
||||
node.children = [{ name: 'EF.UPLMNWLAN', fid: '4f42', isDir: false, exists: true }];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, true);
|
||||
assert.strictEqual(node.children.length, 1);
|
||||
assert.strictEqual(calls.length, 0);
|
||||
});
|
||||
|
||||
test('empty successful listing keeps the directory present', async () => {
|
||||
const node = setup();
|
||||
responses = [{ exists: true, children: [] }];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, true);
|
||||
assert.deepStrictEqual(node.children, []);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
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('(?: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;
|
||||
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 pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n';
|
||||
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';
|
||||
eval(code);
|
||||
|
||||
function fakeEl() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
textContent: '',
|
||||
attrs: {},
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
toggle: (c, on) => { if (on === undefined ? !classes.has(c) : on) classes.add(c); else classes.delete(c); },
|
||||
},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
};
|
||||
}
|
||||
|
||||
let els = {};
|
||||
let calls = [];
|
||||
|
||||
function setup(routes) {
|
||||
els = { 'pysim-fs-probe-status': fakeEl(), 'pysim-fs-probe-btn': fakeEl() };
|
||||
calls = [];
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.pysimFetch = async (p, body) => {
|
||||
calls.push({ path: p, body: body || {} });
|
||||
for (const r of routes) {
|
||||
if (r.path !== p) continue;
|
||||
if (r.name && (!body || body.name !== r.name)) continue;
|
||||
return typeof r.reply === 'function' ? r.reply(body, calls) : JSON.parse(JSON.stringify(r.reply));
|
||||
}
|
||||
throw new Error('unexpected fetch ' + p + ' ' + JSON.stringify(body));
|
||||
};
|
||||
globalThis.pysimFsRenderTree = () => {};
|
||||
}
|
||||
|
||||
function root(children) {
|
||||
pysimFsTreeRoot = { name: 'MF', fid: '3f00', isDir: true, expanded: true, exists: true, children: null, parent: null };
|
||||
pysimFsTreeRoot.children = children.map(c => Object.assign({ children: null, expanded: false, exists: true, parent: pysimFsTreeRoot }, c));
|
||||
return pysimFsTreeRoot;
|
||||
}
|
||||
|
||||
const df = (name, fid) => ({ name, fid, isDir: true });
|
||||
const ef = (name, fid) => ({ name, fid, isDir: false });
|
||||
const selectNames = () => calls.filter(c => c.path === '/api/select').map(c => c.body.name);
|
||||
|
||||
test('probes dirs and files, including custom entries, and reports counts', async () => {
|
||||
root([df('DF.A', '5f01'), ef('EF.ROOT', '2f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
|
||||
let sawStop = null;
|
||||
setup([
|
||||
{ path: '/api/tree', name: 'DF.A', reply: { exists: true, children: [{ name: 'EF.1', fid: '6f01', isDir: false }] } },
|
||||
{ path: '/api/select', name: 'EF.1', reply: () => { sawStop = els['pysim-fs-probe-btn'].textContent; return { exists: true }; } },
|
||||
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
|
||||
{ path: '/api/select', name: 'EF.CUSTOM', reply: { exists: true } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.strictEqual(sawStop, 'Stop');
|
||||
assert.deepStrictEqual(selectNames(), ['EF.1', 'EF.ROOT', 'EF.CUSTOM']);
|
||||
assert.strictEqual(pysimFsTreeRoot.children[0].children[0].exists, true);
|
||||
assert.strictEqual(pysimFsTreeRoot.children[1].exists, false);
|
||||
assert.strictEqual(pysimFsTreeRoot.children[2].exists, true);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.match(status, /4\/4 files/);
|
||||
assert.match(status, /3 present/);
|
||||
assert.match(status, /1 absent/);
|
||||
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
|
||||
assert.strictEqual(els['pysim-fs-probe-btn'].attrs['data-l10n'], 'Probe all files');
|
||||
});
|
||||
|
||||
test('an absent directory is marked and its subtree is never fetched', async () => {
|
||||
root([df('DF.B', '5f02'), ef('EF.ROOT', '2f01')]);
|
||||
setup([
|
||||
{ path: '/api/tree', name: 'DF.B', reply: { success: false, error: 'SW 6a82', exists: false } },
|
||||
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.strictEqual(pysimFsTreeRoot.children[0].exists, false);
|
||||
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
|
||||
assert.strictEqual(calls.filter(c => c.path === '/api/tree' && c.body.name === 'DF.B').length, 1);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.match(status, /2\/2 files/);
|
||||
assert.match(status, /0 present/);
|
||||
assert.match(status, /2 absent/);
|
||||
});
|
||||
|
||||
test('stop halts the walk and still reports a summary', async () => {
|
||||
root([ef('EF.X', '6f01'), ef('EF.Y', '6f02')]);
|
||||
setup([
|
||||
{ path: '/api/select', name: 'EF.X', reply: () => { _pysimFsProbe.stop = true; return { exists: true }; } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.deepStrictEqual(selectNames(), ['EF.X']);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.ok(status.startsWith('Stopped —'), status);
|
||||
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
|
||||
});
|
||||
|
||||
test('select bodies carry the parent path and set allow_probe only for custom files', async () => {
|
||||
root([df('DF.A', '5f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
|
||||
const body = pysimFsSelectBody(pysimFsTreeRoot.children[0]);
|
||||
assert.deepStrictEqual(body.parent_path, ['MF']);
|
||||
assert.strictEqual(body.parent_sel, 'MF');
|
||||
assert.ok(!body.allow_probe);
|
||||
const custom = pysimFsSelectBody(pysimFsTreeRoot.children[1]);
|
||||
assert.deepStrictEqual(custom.parent_path, ['MF']);
|
||||
assert.strictEqual(custom.allow_probe, true);
|
||||
const nested = { name: 'EF.1', fid: '6f01', isDir: false, parent: pysimFsTreeRoot.children[0] };
|
||||
assert.deepStrictEqual(pysimFsSelectBody(nested).parent_path, ['MF', '5f01']);
|
||||
});
|
||||
|
||||
test('children of an absent directory are neither fetched nor selected', async () => {
|
||||
const stale = Object.assign(df('DF.C', '5f03'), {
|
||||
exists: false,
|
||||
children: [{ name: 'EF.STALE', fid: '6f0e', isDir: false, exists: true, children: null }],
|
||||
});
|
||||
root([stale, ef('EF.ROOT', '2f01')]);
|
||||
setup([
|
||||
{ path: '/api/select', name: 'EF.ROOT', reply: { exists: true } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
|
||||
assert.strictEqual(stale.exists, false);
|
||||
assert.strictEqual(calls.filter(c => c.path === '/api/tree').length, 0);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.match(status, /2\/2 files/);
|
||||
assert.match(status, /1 present/);
|
||||
assert.match(status, /1 absent/);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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('(?: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;
|
||||
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 pysimFsSort = "fid";\n';
|
||||
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
||||
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
const ef = (name, fid) => ({ name, fid, isDir: false, exists: true, children: null, expanded: false });
|
||||
const df = (name, fid, extra) => Object.assign({ name, fid, isDir: true, exists: true, children: null, expanded: false }, extra || {});
|
||||
|
||||
test('absent directory renders a cross and no expand toggle', () => {
|
||||
const node = df('DF.WLAN', '5f40', { exists: false, children: [] });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('✗'), out);
|
||||
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
||||
assert.ok(!out.includes('▶'), out);
|
||||
});
|
||||
|
||||
test('expanded empty directory shows the (empty) placeholder', () => {
|
||||
const node = df('DF.EMPTY', '5f00', { children: [], expanded: true });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('(empty)'), out);
|
||||
});
|
||||
|
||||
test('expanded directory with children renders no placeholder', () => {
|
||||
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: true });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('EF.IMSI'), out);
|
||||
assert.ok(!out.includes('(empty)'), out);
|
||||
});
|
||||
|
||||
test('collapsed directory hides its children', () => {
|
||||
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: false });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(!out.includes('EF.IMSI'), out);
|
||||
});
|
||||
|
||||
test('non-existing directory hides previously loaded children', () => {
|
||||
const node = df('DF.WLAN', '5f40', { exists: false, expanded: true, children: [ef('EF.UPLMNWLAN', '4f42')] });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('✗'), out);
|
||||
assert.ok(!out.includes('EF.UPLMNWLAN'), out);
|
||||
assert.ok(!out.includes('(empty)'), out);
|
||||
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
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, 'pysimFsSortChildren'));
|
||||
|
||||
const f = (fid, name) => ({ fid, name, isDir: false });
|
||||
const d = (fid, name) => ({ fid, name, isDir: true });
|
||||
|
||||
test('DFs sort before EFs in both modes', () => {
|
||||
const children = [f('6F07', 'EF.IMSI'), d('7F20', 'DF.GSM'), f('2FE2', 'EF.ICCID'), d('7F10', 'DF.TELECOM')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['7F10', '7F20', '2FE2', '6F07']);
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['DF.GSM', 'DF.TELECOM', 'EF.ICCID', 'EF.IMSI']);
|
||||
});
|
||||
|
||||
test('FID mode sorts EFs numerically by FID string', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'EF.ICCID'), f('6F07', 'EF.IMSI')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07', '6F3A']);
|
||||
});
|
||||
|
||||
test('name mode sorts case-insensitively', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'ef.iccid'), f('6F07', 'EF.IMSI')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['EF.ADN', 'ef.iccid', 'EF.IMSI']);
|
||||
});
|
||||
|
||||
test('missing name falls back to the FID as the sort key', () => {
|
||||
const children = [f('2FE2', null), f('6F07', 'EF.IMSI'), f('6F3A', '')];
|
||||
// keys: '2FE2', 'EF.IMSI', '6F3A' -> '2FE2' < '6F3A' < 'EF.IMSI'
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F3A', '6F07']);
|
||||
});
|
||||
|
||||
test('custom entries use their isDir flag for the DF priority', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), d('7F20', 'DF.GSM')];
|
||||
assert.strictEqual(pysimFsSortChildren(children, 'fid')[0].name, 'DF.GSM');
|
||||
});
|
||||
|
||||
test('equal keys keep a deterministic tie-break by the other field', () => {
|
||||
const children = [f('6F07', 'EF.SAME'), f('2FE2', 'EF.SAME')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F07']);
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07']);
|
||||
});
|
||||
|
||||
test('does not mutate the input array', () => {
|
||||
const children = [f('6F3A', 'B'), f('2FE2', 'A')];
|
||||
pysimFsSortChildren(children, 'fid');
|
||||
assert.deepStrictEqual(children.map(x => x.fid), ['6F3A', '2FE2']);
|
||||
});
|
||||
@@ -10,3 +10,139 @@ const closes = (html.match(/<\/div>/g) || []).length;
|
||||
test('HTML <div> tags are balanced', () => {
|
||||
assert.strictEqual(opens, closes, `Unbalanced divs: ${opens} opens vs ${closes} closes`);
|
||||
});
|
||||
|
||||
test('top-level tabs match the rearranged views', () => {
|
||||
const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]);
|
||||
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'scp81', '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"'));
|
||||
});
|
||||
|
||||
test('profiler and phone simulator are top-level tab contents', () => {
|
||||
assert.ok(html.includes('id="tab-profiler" class="tab-content hidden"'));
|
||||
assert.ok(html.includes('id="tab-phone" class="tab-content hidden"'));
|
||||
});
|
||||
|
||||
test('phone simulator has Phone / TR Config pills', () => {
|
||||
assert.match(html, /data-phone-sub="phone" onclick="phoneSwitchSubtab\('phone'\)"/);
|
||||
assert.match(html, /data-phone-sub="tr" onclick="phoneSwitchSubtab\('tr'\)"/);
|
||||
assert.ok(html.includes('id="phone-sub-phone"'));
|
||||
assert.ok(html.includes('id="phone-sub-tr"'));
|
||||
});
|
||||
|
||||
test('scan name input starts scanning on Enter', () => {
|
||||
assert.match(html, /id="profiler-scan-name"[^>]*onkeydown="profilerScanNameKeydown\(event\)"/);
|
||||
});
|
||||
|
||||
test('snapshot view has a timing summary block', () => {
|
||||
assert.ok(html.includes('id="snapshot-summary"'));
|
||||
});
|
||||
|
||||
test('header state indicator and profiler custom-files tab', () => {
|
||||
assert.ok(html.includes('id="state-indicator"'));
|
||||
assert.ok(html.includes('id="profiler-list-custom"'));
|
||||
assert.ok(html.includes('data-list-tab="custom"'));
|
||||
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'));
|
||||
});
|
||||
|
||||
test('file manager has a probe-all-files button and status line', () => {
|
||||
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-needs="card"/);
|
||||
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-l10n="Probe all files"/);
|
||||
assert.ok(html.includes('onclick="pysimFsProbeAll()"'));
|
||||
assert.ok(html.includes('id="pysim-fs-probe-status"'));
|
||||
});
|
||||
|
||||
test('file manager shows FCI info and keeps the selection in state, not the DOM', () => {
|
||||
const detail = html.indexOf('id="pysim-fs-detail"');
|
||||
const info = html.indexOf('id="pysim-fs-info"');
|
||||
const content = html.indexOf('id="pysim-fs-content"');
|
||||
assert.ok(detail !== -1 && info > detail && info < content, 'pysim-fs-info must sit above the content');
|
||||
assert.ok(html.includes('function pysimFsInfoHtml'));
|
||||
assert.ok(html.includes("pysimFsInfoHtml(sel)"));
|
||||
assert.ok(!html.includes('pysim-fs-filename'));
|
||||
assert.ok(html.includes('let pysimFsSelected = null;'));
|
||||
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('));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
const FNS = ['_hotaHex', '_hotaAsciiHex', '_hotaBerLen', '_hotaTlv',
|
||||
'hotaTlv', 'hotaBuildConn', 'hotaBuildSec', 'hotaBuildRetry',
|
||||
'hotaBuildHttpPost', 'hotaBuildTrigger', 'hotaBuildStore', 'hotaBuild'];
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
|
||||
eval(code);
|
||||
|
||||
test('_hotaHex strips non-hex and uppercases', () => {
|
||||
assert.strictEqual(_hotaHex(' 81 82 '), '8182');
|
||||
assert.strictEqual(_hotaHex('b0-00'), 'B000');
|
||||
assert.strictEqual(_hotaHex(''), '');
|
||||
assert.strictEqual(_hotaHex(undefined), '');
|
||||
});
|
||||
|
||||
test('_hotaAsciiHex encodes text as ASCII bytes', () => {
|
||||
assert.strictEqual(_hotaAsciiHex('megafon.ru'), '6D656761666F6E2E7275');
|
||||
assert.strictEqual(_hotaAsciiHex('v1.0'), '76312E30');
|
||||
assert.strictEqual(_hotaAsciiHex('/sd'), '2F7364');
|
||||
assert.strictEqual(_hotaAsciiHex(''), '');
|
||||
});
|
||||
|
||||
test('_hotaBerLen encodes definite lengths', () => {
|
||||
assert.strictEqual(_hotaBerLen(0), '00');
|
||||
assert.strictEqual(_hotaBerLen(11), '0B');
|
||||
assert.strictEqual(_hotaBerLen(127), '7F');
|
||||
assert.strictEqual(_hotaBerLen(128), '8180');
|
||||
assert.strictEqual(_hotaBerLen(255), '81FF');
|
||||
assert.strictEqual(_hotaBerLen(256), '820100');
|
||||
});
|
||||
|
||||
test('hotaTlv wraps tag + definite length + value', () => {
|
||||
assert.strictEqual(hotaTlv('02', '8182'), '02028182');
|
||||
assert.strictEqual(hotaTlv('05', ''), '0500');
|
||||
assert.strictEqual(hotaTlv('8A', '6D656761666F6E2E7275'), '8A0A6D656761666F6E2E7275');
|
||||
});
|
||||
|
||||
test('hotaBuildConn produces a full 84 TLV', () => {
|
||||
assert.strictEqual(hotaBuildConn([{ tag: '02', value: '8182' }]), '840402028182');
|
||||
assert.strictEqual(
|
||||
hotaBuildConn([
|
||||
{ tag: '01', value: '014001' },
|
||||
{ tag: '02', value: '81 82' },
|
||||
{ tag: '05', value: '' },
|
||||
]),
|
||||
'840B0103014001020281820500');
|
||||
assert.strictEqual(hotaBuildConn([{ tag: '02', value: '8182' }, { tag: '', value: 'FF' }]), '840402028182');
|
||||
assert.strictEqual(hotaBuildConn([]), '8400');
|
||||
});
|
||||
|
||||
test('hotaBuildSec produces a full 85 TLV per Table 4-6', () => {
|
||||
assert.strictEqual(
|
||||
hotaBuildSec({ pskIdentity: 'Test123', kvn: '01', kid: '01' }),
|
||||
'850B0754657374313233020101');
|
||||
assert.strictEqual(
|
||||
hotaBuildSec({ pskIdentity: '', kvn: '01', kid: '01' }),
|
||||
'850400020101');
|
||||
});
|
||||
|
||||
test('hotaBuildRetry uses TS 102 223 timer TLV (25 03) and wraps in 86', () => {
|
||||
assert.strictEqual(
|
||||
hotaBuildRetry({ counter: 'B000', delayH: 1, delayM: 2, delayS: 3, reportFailure: '' }),
|
||||
'8607B0002503010203');
|
||||
assert.strictEqual(
|
||||
hotaBuildRetry({ counter: 'b0 00', delayH: '1', delayM: '2', delayS: '3', reportFailure: '0A080102030405060708' }),
|
||||
'8611B00025030102030A080102030405060708');
|
||||
assert.strictEqual(
|
||||
hotaBuildRetry({ counter: '0000', delayH: 0, delayM: 0, delayS: 0, reportFailure: '0A0' }),
|
||||
'860700002503000000');
|
||||
});
|
||||
|
||||
test('hotaBuildHttpPost wraps 8A/8B/8C in a full 89 TLV', () => {
|
||||
assert.strictEqual(
|
||||
hotaBuildHttpPost({ host: '', agent: '', uri: '' }),
|
||||
'89068A008B008C00');
|
||||
assert.strictEqual(
|
||||
hotaBuildHttpPost({ host: 'megafon.ru', agent: 'v1.0', uri: '/sd' }),
|
||||
'89178A0A6D656761666F6E2E72758B0476312E308C032F7364');
|
||||
});
|
||||
|
||||
test('hotaBuildTrigger wraps 81 > 83 > (84/85/86/89)', () => {
|
||||
const conn = '840402028182';
|
||||
const sec = '850400020101';
|
||||
const retry = '8607B0002503010203';
|
||||
const httpPost = '89068A008B008C00';
|
||||
assert.strictEqual(
|
||||
hotaBuildTrigger(conn, sec, retry, httpPost, false),
|
||||
'811F831D8404020281828504000201018607B000250301020389068A008B008C00');
|
||||
});
|
||||
|
||||
test('hotaBuildTrigger expanded wraps the 81 command in Command Scripting template AA', () => {
|
||||
const conn = '840402028182';
|
||||
const plain = hotaBuildTrigger(conn, '', '', '', false);
|
||||
const expanded = hotaBuildTrigger(conn, '', '', '', true);
|
||||
const byteLen = plain.length / 2;
|
||||
assert.strictEqual(expanded, 'AA' + _hotaBerLen(byteLen) + plain);
|
||||
assert.ok(expanded.startsWith('AA'));
|
||||
});
|
||||
|
||||
test('hotaBuildStore emits STORE DATA TLV-mode APDU (80 E2 90 00)', () => {
|
||||
assert.strictEqual(
|
||||
hotaBuildStore('840402028182', '', '', '', '85'),
|
||||
'80E29000088506840402028182');
|
||||
assert.strictEqual(
|
||||
hotaBuildStore('840402028182', '', '', '', 'A5'),
|
||||
'80E2900008A506840402028182');
|
||||
});
|
||||
|
||||
test('hotaBuild dispatches on mode', () => {
|
||||
assert.strictEqual(
|
||||
hotaBuild('trigger', '840402028182', '', '', '', '85', false),
|
||||
hotaBuildTrigger('840402028182', '', '', '', false));
|
||||
assert.strictEqual(
|
||||
hotaBuild('store', '840402028182', '', '', '', '85', false),
|
||||
hotaBuildStore('840402028182', '', '', '', '85'));
|
||||
assert.strictEqual(
|
||||
hotaBuild('store', '840402028182', '', '8607B0002503010203', '', '85', false),
|
||||
hotaBuildStore('840402028182', '', '8607B0002503010203', '', '85'));
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
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 _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\n';
|
||||
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n';
|
||||
code += 'globalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
function fakeEl() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
classes,
|
||||
attrs: {},
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
removeAttribute(k) { delete this.attrs[k]; },
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const els = {
|
||||
'state-indicator': fakeEl(),
|
||||
'state-indicator-dot': fakeEl(),
|
||||
'state-indicator-img': fakeEl(),
|
||||
};
|
||||
els['state-indicator-img'].src = '';
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
_pysimServerAvailable = null;
|
||||
_pysimCardEquipped = false;
|
||||
_pysimEquipping = false;
|
||||
return els;
|
||||
}
|
||||
|
||||
test('unprobed server shows a gray dot and a Connecting title', () => {
|
||||
const els = setup();
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
|
||||
assert.ok(dot.classes.has('text-gray-400'));
|
||||
assert.ok(!dot.classes.has('hidden'));
|
||||
assert.ok(img.classes.has('hidden'));
|
||||
assert.strictEqual(wrap.attrs.title, 'Connecting...');
|
||||
assert.strictEqual(dot.attrs.title, 'Connecting...');
|
||||
});
|
||||
|
||||
test('unreachable server shows a red dot', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = false;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
|
||||
assert.ok(dot.classes.has('text-red-500'));
|
||||
assert.ok(!dot.classes.has('text-gray-400'));
|
||||
assert.ok(img.classes.has('hidden'));
|
||||
assert.strictEqual(wrap.attrs.title, 'No server connection');
|
||||
assert.strictEqual(dot.attrs.title, 'No server connection');
|
||||
});
|
||||
|
||||
test('server up without a card shows nosim.svg', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = true;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
|
||||
assert.ok(dot.classes.has('hidden'));
|
||||
assert.ok(!img.classes.has('hidden'));
|
||||
assert.strictEqual(img.src, 'nosim.svg');
|
||||
assert.strictEqual(wrap.attrs.title, 'Server connected, no card equipped');
|
||||
assert.strictEqual(dot.attrs.title, undefined);
|
||||
});
|
||||
|
||||
test('equipped card shows sim.svg', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = true;
|
||||
_pysimCardEquipped = true;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-img': img } = els;
|
||||
assert.strictEqual(img.src, 'sim.svg');
|
||||
assert.strictEqual(wrap.attrs.title, 'Card equipped');
|
||||
});
|
||||
|
||||
test('equipping shows the animated sim_anim.svg', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = true;
|
||||
_pysimEquipping = true;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-img': img } = els;
|
||||
assert.strictEqual(img.src, 'sim_anim.svg');
|
||||
assert.strictEqual(wrap.attrs.title, 'Card inserted — initializing...');
|
||||
});
|
||||
|
||||
test('dot color transitions do not accumulate', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = false;
|
||||
pysimUpdateStateIndicator();
|
||||
_pysimServerAvailable = null;
|
||||
pysimUpdateStateIndicator();
|
||||
const dot = els['state-indicator-dot'];
|
||||
assert.ok(dot.classes.has('text-gray-400'));
|
||||
assert.ok(!dot.classes.has('text-red-500'));
|
||||
});
|
||||
|
||||
test('indicator markup carries the dot and image elements', () => {
|
||||
assert.match(html, /id="state-indicator-dot"/);
|
||||
assert.match(html, /id="state-indicator-img"[^>]*src="nosim\.svg"/);
|
||||
});
|
||||
|
||||
test('indicator image stays within the 32px header row budget', () => {
|
||||
const m = /id="state-indicator-img"[^>]*style="width:(\d+)px;height:(\d+)px"/.exec(html);
|
||||
assert.ok(m, 'inline image size not found');
|
||||
assert.strictEqual(m[1], m[2]);
|
||||
const size = Number(m[1]);
|
||||
assert.ok(size >= 24 && size <= 32, 'size ' + size + 'px would change the header height');
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
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);
|
||||
}
|
||||
|
||||
const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
|
||||
'globalThis.setHelpAnchor = a => { globalThis._anchor = a; };\n' +
|
||||
'globalThis.stkCheckMenu = () => { globalThis._stk = (globalThis._stk || 0) + 1; };\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.tpRefresh = () => { globalThis._tp = (globalThis._tp || 0) + 1; };\n';
|
||||
eval(code);
|
||||
|
||||
function makeClassList() {
|
||||
const set = new Set();
|
||||
return {
|
||||
toggle: (c, on) => { on ? set.add(c) : set.delete(c); },
|
||||
has: c => set.has(c),
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const buttons = [
|
||||
{ dataset: { phoneSub: 'phone' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'tr' }, classList: makeClassList() },
|
||||
];
|
||||
const panels = {
|
||||
'phone-sub-phone': { classList: makeClassList() },
|
||||
'phone-sub-tr': { classList: makeClassList() },
|
||||
};
|
||||
globalThis.document = {
|
||||
querySelectorAll: sel => (sel === '.phone-subtab' ? buttons : []),
|
||||
getElementById: id => panels[id] || null,
|
||||
};
|
||||
globalThis._anchor = null;
|
||||
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = globalThis._tp = 0;
|
||||
return { buttons, panels };
|
||||
}
|
||||
|
||||
test('TR Config pill shows the TR panel and renders PLI data', () => {
|
||||
const { buttons, panels } = setup();
|
||||
phoneSwitchSubtab('tr');
|
||||
assert.ok(!panels['phone-sub-tr'].classList.has('hidden'));
|
||||
assert.ok(panels['phone-sub-phone'].classList.has('hidden'));
|
||||
assert.ok(buttons[1].classList.has('bg-blue-600'));
|
||||
assert.ok(!buttons[0].classList.has('bg-blue-600'));
|
||||
assert.strictEqual(globalThis._anchor, 'pli-dict');
|
||||
assert.strictEqual(globalThis._pli, 1);
|
||||
assert.strictEqual(globalThis._stk, 0);
|
||||
});
|
||||
|
||||
test('Phone pill shows the phone panel and renders CAT views', () => {
|
||||
const { buttons, panels } = setup();
|
||||
phoneSwitchSubtab('phone');
|
||||
assert.ok(!panels['phone-sub-phone'].classList.has('hidden'));
|
||||
assert.ok(panels['phone-sub-tr'].classList.has('hidden'));
|
||||
assert.ok(buttons[0].classList.has('bg-blue-600'));
|
||||
assert.strictEqual(globalThis._anchor, 'stk-menu');
|
||||
assert.strictEqual(globalThis._stk, 1);
|
||||
assert.strictEqual(globalThis._events, 1);
|
||||
assert.strictEqual(globalThis._log, 1);
|
||||
assert.strictEqual(globalThis._poll, 1);
|
||||
assert.strictEqual(globalThis._pli, 0);
|
||||
assert.strictEqual(globalThis._tp, 1);
|
||||
});
|
||||
+1447
-3
File diff suppressed because it is too large
Load Diff
+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,85 @@
|
||||
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, asyncFn) {
|
||||
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 (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = extractFunc(html, 'stkMenuRespond', true) + '\n';
|
||||
code += 'globalThis.esc = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
function setup(response) {
|
||||
const calls = { handled: null, rendered: 0 };
|
||||
globalThis.pysimFetch = async () => response;
|
||||
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
|
||||
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
|
||||
const btns = { classList: { add: () => {} } };
|
||||
const back = { style: {} };
|
||||
globalThis.document = {
|
||||
getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back : { innerHTML: '' }),
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
test('back with a fetched SELECT ITEM continues the card dialogue', async () => {
|
||||
const data = { type: 'select_item', items: [{ id: 1, text: 'Info' }] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('back with a fetched DISPLAY TEXT shows it', async () => {
|
||||
const data = { type: 'display_text', text: 'hello' };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('timeout with a fetched SELECT ITEM continues the card dialogue', async () => {
|
||||
const data = { type: 'select_item', items: [] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('timeout');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('back answered with SW 9000 falls back to the cached top menu', async () => {
|
||||
const calls = setup({ type: 'done', sw: '9000' });
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, null);
|
||||
assert.strictEqual(calls.rendered, 1);
|
||||
});
|
||||
|
||||
test('cancel falls back to the cached top menu', async () => {
|
||||
const calls = setup({ sw: '9000' });
|
||||
await stkMenuRespond('cancel');
|
||||
assert.strictEqual(calls.handled, null);
|
||||
assert.strictEqual(calls.rendered, 1);
|
||||
});
|
||||
|
||||
test('ok navigates with the server response', async () => {
|
||||
const data = { type: 'select_item', items: [{ id: 1, text: 'x' }] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('ok');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const swSource = fs.readFileSync(path.join(__dirname, '..', 'sw.js'), 'utf8');
|
||||
|
||||
class FakeResponse {
|
||||
constructor(body, init) {
|
||||
this.body = body;
|
||||
this.status = init && init.status;
|
||||
this.statusText = init && init.statusText;
|
||||
}
|
||||
clone() {
|
||||
return new FakeResponse(this.body, { status: this.status, statusText: this.statusText });
|
||||
}
|
||||
}
|
||||
|
||||
function loadSW({ fetchImpl, cacheMatch }) {
|
||||
const listeners = {};
|
||||
const puts = [];
|
||||
const sandbox = {
|
||||
self: {
|
||||
addEventListener: (type, fn) => { listeners[type] = fn; },
|
||||
skipWaiting: () => {},
|
||||
},
|
||||
caches: {
|
||||
open: async () => ({
|
||||
addAll: async () => {},
|
||||
put: async (req, res) => { puts.push([String(req && req.url || req), res]); },
|
||||
}),
|
||||
keys: async () => [],
|
||||
delete: async () => true,
|
||||
match: cacheMatch,
|
||||
},
|
||||
clients: { claim: () => {} },
|
||||
fetch: fetchImpl,
|
||||
Response: FakeResponse,
|
||||
URL,
|
||||
console,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(swSource, sandbox);
|
||||
return { listeners, puts };
|
||||
}
|
||||
|
||||
function navigateEvent(url) {
|
||||
const event = {
|
||||
request: { url, method: 'GET', mode: 'navigate' },
|
||||
responded: null,
|
||||
};
|
||||
event.respondWith = p => { event.responded = p; };
|
||||
event.passThrough = () => { event.responded = null; };
|
||||
return event;
|
||||
}
|
||||
|
||||
test('offline navigation falls back to the cached index.html', async () => {
|
||||
const index = new FakeResponse('html');
|
||||
const { listeners } = loadSW({
|
||||
fetchImpl: async () => { throw new Error('offline'); },
|
||||
cacheMatch: async req => (String(req && req.url || req) === 'index.html' ? index : undefined),
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/');
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.strictEqual(res, index);
|
||||
});
|
||||
|
||||
test('offline navigation with empty cache resolves to an offline Response', async () => {
|
||||
const { listeners } = loadSW({
|
||||
fetchImpl: async () => { throw new Error('offline'); },
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/');
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.ok(res instanceof FakeResponse);
|
||||
assert.strictEqual(res.status, 503);
|
||||
});
|
||||
|
||||
test('a successful navigation is cached and returned', async () => {
|
||||
const page = new FakeResponse('html');
|
||||
const { listeners, puts } = loadSW({
|
||||
fetchImpl: async () => page,
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/help.html');
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.strictEqual(res, page);
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.strictEqual(puts.length, 1);
|
||||
assert.strictEqual(puts[0][0], 'http://127.0.0.1:8080/help.html');
|
||||
});
|
||||
|
||||
test('api requests bypass the service worker', () => {
|
||||
const { listeners } = loadSW({
|
||||
fetchImpl: async () => { throw new Error('unexpected'); },
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/api/status');
|
||||
event.request.mode = 'cors';
|
||||
listeners.fetch(event);
|
||||
assert.strictEqual(event.responded, null);
|
||||
});
|
||||
|
||||
test('uncached asset hits the network and gets cached', async () => {
|
||||
const asset = new FakeResponse('js');
|
||||
const { listeners, puts } = loadSW({
|
||||
fetchImpl: async () => asset,
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/des-bundle.js');
|
||||
event.request.mode = 'cors';
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.strictEqual(res, asset);
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.strictEqual(puts.length, 1);
|
||||
});
|
||||
@@ -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 = "1.9.26"
|
||||
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.
|
||||
|
||||
@@ -11,7 +11,8 @@ from pySim.log import PySimLogger
|
||||
from pySim.cards import UiccCardBase
|
||||
|
||||
from .shell import load_pysim_app
|
||||
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session
|
||||
from . import fastinit
|
||||
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip
|
||||
|
||||
|
||||
_server_start = 0
|
||||
@@ -41,15 +42,32 @@ 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,
|
||||
help='Skip pysim card initialization (preserve CAT session — no file manager)')
|
||||
parser.add_argument('--timing', action='store_true', default=False,
|
||||
help='Log phase durations, card resets and APDU counters with elapsed timestamps')
|
||||
parser.add_argument('--fast-init', action='store_true', help=argparse.SUPPRESS)
|
||||
parser.add_argument('--full-pysim-init', action='store_true', default=False,
|
||||
help="Use pysim's stock init_card/equip (multiple physical card resets) instead of the default reset-free fast init")
|
||||
parser.add_argument('--menu-timeout', type=int, default=60, metavar='SECS',
|
||||
help='Auto-send a timeout TERMINAL RESPONSE if a paused STK command is not answered (default: 60, 0 disables)')
|
||||
parser.add_argument('--no-auto-equip', action='store_true', default=False,
|
||||
help='Do not automatically initialize a card right after it is inserted (default: auto-equip on)')
|
||||
|
||||
opts = parser.parse_args()
|
||||
opts.skip_card_init = opts.no_card_init
|
||||
opts.fast_init = not opts.full_pysim_init
|
||||
if opts.timing:
|
||||
_timing_on()
|
||||
if opts.menu_timeout is not None:
|
||||
_set_menu_timeout(opts.menu_timeout)
|
||||
set_auto_equip(not opts.no_auto_equip and not opts.skip_card_init)
|
||||
sl = None
|
||||
scc = None
|
||||
card = None
|
||||
@@ -77,27 +95,44 @@ def main():
|
||||
kwargs = {}
|
||||
if opts.apdu_trace:
|
||||
kwargs['apdu_tracer'] = _LoggingApduTracer()
|
||||
t_phase = time.time()
|
||||
sl = mod.init_reader(opts, **kwargs)
|
||||
_tlog('init_reader: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
scc = SimCardCommands(sl)
|
||||
scc.cat_cla = '80' # UICC CLA default; overridden for SIM after init_card
|
||||
scc._tp.proactive_handler = _DefaultProactiveHandler()
|
||||
t_phase = time.time()
|
||||
if opts.fast_init:
|
||||
try:
|
||||
rs, card = fastinit.init_card_fast(sl, opts.skip_card_init, wait=True)
|
||||
except Exception:
|
||||
print("Warning: fast card initialization failed, falling back to pysim init:", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
rs, card = mod.init_card(sl, opts.skip_card_init)
|
||||
else:
|
||||
sl.wait_for_card(3)
|
||||
rs, card = mod.init_card(sl, opts.skip_card_init)
|
||||
_tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0'
|
||||
except Exception:
|
||||
print("Warning: reader/card initialization failed:", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
ch = CardHandler(sl) if sl else None
|
||||
t_phase = time.time()
|
||||
try:
|
||||
app = mod.PysimApp(verbose=opts.verbose, card=card, rs=rs, sl=sl, ch=ch)
|
||||
except Exception:
|
||||
print("Warning: PysimApp creation failed:", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
app = None
|
||||
if scc and hasattr(scc, '_tp'):
|
||||
_tlog('pysim_app: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
if app is not None and opts.fast_init:
|
||||
fastinit.install(app)
|
||||
if scc and card is not None and hasattr(scc, '_tp'):
|
||||
scc._tp.apdu_tracer = _LoggingApduTracer()
|
||||
try:
|
||||
_init_proactive_session()
|
||||
t_phase = time.time()
|
||||
sys.stderr.write('INIT: sending TERMINAL PROFILE %s (CLA=%s)\n' % (opts.terminal_profile, scc.cat_cla))
|
||||
sm, el = _send_terminal_profile(scc, opts.terminal_profile)
|
||||
sys.stderr.write('INIT: TP done, menu=%s events=%s\n' % ('yes' if sm else 'no', 'yes' if el else 'no'))
|
||||
@@ -109,8 +144,11 @@ def main():
|
||||
if not st_sw.startswith('91'):
|
||||
break
|
||||
_handle_proactive_chain(scc, st_sw)
|
||||
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
except Exception:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
elif scc is not None:
|
||||
sys.stderr.write('INIT: card not initialized — use Equip once the card is readable\n')
|
||||
if app is not None and opts.apdu_trace:
|
||||
# PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout)
|
||||
# and drops the root level to INFO. Re-route pysim's own APDU trace logging
|
||||
@@ -141,20 +179,30 @@ 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
|
||||
server.menu_active = False
|
||||
server.stk_pending = None
|
||||
# Set server reference for polling timer and mark card as connected
|
||||
server.card_present = card is not None
|
||||
server.card_session = 1 if card is not None else 0
|
||||
server.equipping = False
|
||||
# Set server reference for polling timer and mark the card session state
|
||||
import pysim_otaman_server.server
|
||||
pysim_otaman_server.server._server_ref = server
|
||||
pysim_otaman_server.server._CARD_CONNECTED = True
|
||||
pysim_otaman_server.server._CARD_CONNECTED = card is not None
|
||||
if opts.poll_interval is not None:
|
||||
pysim_otaman_server.server._set_poll_interval(opts.poll_interval)
|
||||
# Auto-enable polling if card initialized successfully (unless interval is 0)
|
||||
if server.scc and server.card and opts.poll_interval != 0:
|
||||
pysim_otaman_server.server._poll_enable()
|
||||
# Start presence monitoring only after the startup init: pyscard reports an
|
||||
# already-present card as "added" on the first pass, and we must not
|
||||
# auto-equip over a session we just initialized. If startup init failed,
|
||||
# that event triggers auto-equip instead — the desired retry.
|
||||
if sl is not None and getattr(sl, '_reader', None) is not None:
|
||||
start_card_monitor(str(sl._reader))
|
||||
print("─" * 70)
|
||||
print(" pysim-otaman-server v%s listening on http://%s:%s" % (VERSION, opts.http_host, opts.http_port))
|
||||
print(" Open http://%s:%s in your browser for the OTAMan UI (served by this server)."
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Fast card initialization for pysim-otaman-server.
|
||||
|
||||
pySim's ``init_card()`` performs several physical card resets: one per profile
|
||||
candidate tried by ``CardProfile.pick()`` plus one at the end of
|
||||
``RuntimeState.__init__``, and ``PysimApp.equip()`` resets yet again. On common
|
||||
readers each disconnect/connect costs around a second, so the stock path spends
|
||||
most of its time re-establishing a clean state (MF selected) that can also be
|
||||
restored in software.
|
||||
|
||||
This module mirrors ``pySim.app.init_card()`` with those resets removed: all
|
||||
profile probes run back-to-back on the same connection and the runtime state
|
||||
uses a software reset. It is the default init/equip path; ``--full-pysim-init``
|
||||
restores pysim's stock behavior, and the explicit ``equip``/``reset`` commands
|
||||
keep a real reconnect/physical reset.
|
||||
"""
|
||||
|
||||
import operator
|
||||
import sys
|
||||
|
||||
from pySim.cards import CardBase, SimCardBase, UiccCardBase, card_detect
|
||||
from pySim.commands import SimCardCommands
|
||||
from pySim.exceptions import ProtocolError, SwMatchError
|
||||
from pySim.filesystem import CardApplication, CardModel
|
||||
from pySim.profile import CardProfile
|
||||
from pySim.runtime import RuntimeState
|
||||
from pySim.ts_102_221 import CardProfileUICC
|
||||
from pySim.utils import all_subclasses
|
||||
|
||||
import pySim.euicc
|
||||
|
||||
from .server import _tlog
|
||||
|
||||
|
||||
class FastRuntimeState(RuntimeState):
|
||||
"""RuntimeState whose reset() restores software state (selects MF) instead
|
||||
of power-cycling the card. Use hard_reset() for an explicit reset."""
|
||||
|
||||
def reset(self, cmd_app=None):
|
||||
try:
|
||||
return self.soft_reset(cmd_app)
|
||||
except (SwMatchError, ProtocolError) as e:
|
||||
sys.stderr.write('FAST-RESET: soft reset failed (%s), falling back to physical reset\n' % e)
|
||||
return self.hard_reset(cmd_app)
|
||||
|
||||
def soft_reset(self, cmd_app=None):
|
||||
for lchan_nr in list(self.lchan.keys()):
|
||||
self.lchan[lchan_nr].scc.scp = None
|
||||
if lchan_nr == 0:
|
||||
continue
|
||||
del self.lchan[lchan_nr]
|
||||
self.adm_verified = False
|
||||
try:
|
||||
atr = self.card._scc.get_atr()
|
||||
except Exception:
|
||||
atr = None
|
||||
if cmd_app:
|
||||
cmd_app.lchan = self.lchan[0]
|
||||
self.lchan[0].select('MF', cmd_app)
|
||||
self.lchan[0].selected_adf = None
|
||||
self.identity['ATR'] = atr
|
||||
return atr
|
||||
|
||||
def hard_reset(self, cmd_app=None):
|
||||
return super().reset(cmd_app)
|
||||
|
||||
|
||||
def pick_profile_no_reset(scc):
|
||||
"""Like CardProfile.pick(), but without a physical reset between
|
||||
candidates. Each probe selects its own discriminating file, so a reset only
|
||||
costs a reconnect without changing the outcome."""
|
||||
original_reset = scc.reset_card
|
||||
scc.reset_card = lambda: None
|
||||
try:
|
||||
profiles = sorted(all_subclasses(CardProfile), key=operator.attrgetter('ORDER'))
|
||||
for p in profiles:
|
||||
if p.match_with_card(scc):
|
||||
return p()
|
||||
return None
|
||||
finally:
|
||||
scc.reset_card = original_reset
|
||||
|
||||
|
||||
def init_card_fast(sl, skip_card_init=False, wait=True):
|
||||
"""Replacement for pySim.app.init_card() that avoids redundant resets.
|
||||
|
||||
``wait`` performs the single disconnect/connect of this init (explicit
|
||||
equip passes True; startup already connects via wait_for_card). If probing
|
||||
leaves the card in a state the software reset cannot clear, retry once
|
||||
after a physical reset."""
|
||||
try:
|
||||
return _init_card_once(sl, skip_card_init, wait)
|
||||
except (SwMatchError, ProtocolError) as e:
|
||||
sys.stderr.write('FAST-INIT: %s; retrying after physical reset\n' % e)
|
||||
sl.reset_card()
|
||||
return _init_card_once(sl, skip_card_init, wait=False)
|
||||
|
||||
|
||||
def _init_card_once(sl, skip_card_init, wait):
|
||||
scc = SimCardCommands(transport=sl)
|
||||
if wait:
|
||||
sl.wait_for_card(3)
|
||||
if skip_card_init:
|
||||
return None, CardBase(scc)
|
||||
|
||||
generic_card = False
|
||||
card = card_detect(scc)
|
||||
if card is None:
|
||||
card = SimCardBase(scc)
|
||||
generic_card = True
|
||||
|
||||
profile = pick_profile_no_reset(scc)
|
||||
if profile is None:
|
||||
return None, card
|
||||
|
||||
if generic_card and isinstance(profile, CardProfileUICC):
|
||||
card._adm_chv_num = 0x0A
|
||||
|
||||
if isinstance(profile, CardProfileUICC):
|
||||
for app_cls in all_subclasses(CardApplication):
|
||||
if hasattr(app_cls, '_' + app_cls.__name__ + '__intermediate'):
|
||||
continue
|
||||
profile.add_application(app_cls())
|
||||
if generic_card:
|
||||
card = UiccCardBase(scc)
|
||||
|
||||
rs = FastRuntimeState(card, profile)
|
||||
|
||||
CardModel.apply_matching_models(scc, rs)
|
||||
|
||||
sl.set_sw_interpreter(rs)
|
||||
|
||||
isd_r = rs.mf.applications.get(pySim.euicc.AID_ISD_R.lower(), None)
|
||||
if isd_r:
|
||||
rs.lchan[0].select_file(isd_r)
|
||||
try:
|
||||
rs.identity['EID'] = pySim.euicc.CardApplicationISDR.get_eid(scc)
|
||||
except SwMatchError:
|
||||
pass
|
||||
finally:
|
||||
rs.soft_reset()
|
||||
|
||||
return rs, card
|
||||
|
||||
|
||||
def do_equip_fast(app):
|
||||
"""Explicit equip: one real reconnect (wait_for_card) then reset-free init.
|
||||
PysimApp.equip() unregisters the old command sets itself after the new init
|
||||
succeeds, so a failed init leaves the previous card state intact."""
|
||||
rs, card = init_card_fast(app.sl, wait=True)
|
||||
app.equip(card, rs)
|
||||
|
||||
|
||||
def do_reset_fast(app):
|
||||
"""Explicit reset: always a physical card reset."""
|
||||
if app.rs is None:
|
||||
app.card._scc.reset_card()
|
||||
atr = app.card._scc.get_atr()
|
||||
else:
|
||||
atr = app.rs.hard_reset(app)
|
||||
app.poutput('Card ATR: %s' % atr)
|
||||
|
||||
|
||||
def install(app):
|
||||
"""Route the pySim-shell equip/reset commands through the fast paths."""
|
||||
def _do_equip(statement):
|
||||
_tlog('do_equip_fast: start')
|
||||
do_equip_fast(app)
|
||||
_tlog('do_equip_fast: done')
|
||||
|
||||
def _do_reset(statement):
|
||||
_tlog('do_reset_fast: start')
|
||||
do_reset_fast(app)
|
||||
_tlog('do_reset_fast: done')
|
||||
|
||||
app.do_equip = _do_equip
|
||||
app.do_reset = _do_reset
|
||||
@@ -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 = []
|
||||
+2015
-210
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for per-command APDU timing collection (card snapshot measurements)."""
|
||||
|
||||
import sys
|
||||
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))
|
||||
|
||||
import pysim_otaman_server.server as S
|
||||
|
||||
|
||||
class TestClassifyApdu(unittest.TestCase):
|
||||
def test_select(self):
|
||||
self.assertEqual(S._classify_apdu('00a40004023f0000'), 'select')
|
||||
|
||||
def test_read_binary(self):
|
||||
self.assertEqual(S._classify_apdu('00b000000a'), 'read_binary')
|
||||
|
||||
def test_read_record(self):
|
||||
self.assertEqual(S._classify_apdu('00b2010428'), 'read_record')
|
||||
|
||||
def test_other_not_classified(self):
|
||||
self.assertIsNone(S._classify_apdu('80f2000c00'))
|
||||
|
||||
def test_short_input(self):
|
||||
self.assertIsNone(S._classify_apdu(''))
|
||||
self.assertIsNone(S._classify_apdu('00'))
|
||||
|
||||
|
||||
class TestApduTimeCollection(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = (S._APDU_TIME_COLLECT, list(S._APDU_TIMES), S._server_ref)
|
||||
S._APDU_TIME_COLLECT = False
|
||||
S._APDU_TIMES.clear()
|
||||
S._server_ref = None
|
||||
|
||||
def tearDown(self):
|
||||
S._APDU_TIME_COLLECT, times, S._server_ref = self.saved
|
||||
S._APDU_TIMES[:] = times
|
||||
|
||||
def test_disabled_does_not_collect(self):
|
||||
tracer = S.StderrApduTracer()
|
||||
with mock.patch.object(S.os, 'write'):
|
||||
tracer.trace_command('00a40004023f0000')
|
||||
tracer.trace_response('00a40004023f0000', '9000', '')
|
||||
self.assertEqual(S._APDU_TIMES, [])
|
||||
|
||||
def test_collects_only_classified_commands_with_ms(self):
|
||||
S._collect_apdu_times()
|
||||
tracer = S.StderrApduTracer()
|
||||
with mock.patch.object(S.os, 'write'):
|
||||
tracer._cmd_start = time.time() - 0.025
|
||||
tracer.trace_response('00a40004023f0000', '9000', '')
|
||||
tracer._cmd_start = time.time() - 0.010
|
||||
tracer.trace_response('00b000000a', '9000', '')
|
||||
tracer._cmd_start = time.time() - 0.005
|
||||
tracer.trace_response('80f2000c00', '9000', '')
|
||||
times = S._end_apdu_time_collection()
|
||||
self.assertEqual([t['type'] for t in times], ['select', 'read_binary'])
|
||||
self.assertGreaterEqual(times[0]['ms'], 20)
|
||||
self.assertFalse(S._APDU_TIME_COLLECT)
|
||||
self.assertEqual(S._APDU_TIMES, [])
|
||||
|
||||
def test_collect_reattaches_tracer_when_missing(self):
|
||||
tp = types.SimpleNamespace(apdu_tracer=None)
|
||||
scc = types.SimpleNamespace(_tp=tp)
|
||||
S._server_ref = types.SimpleNamespace(scc=scc)
|
||||
S._collect_apdu_times()
|
||||
try:
|
||||
self.assertIsInstance(tp.apdu_tracer, S._LoggingApduTracer)
|
||||
finally:
|
||||
S._end_apdu_time_collection()
|
||||
|
||||
def test_collect_keeps_existing_tracer(self):
|
||||
tracer = S.StderrApduTracer()
|
||||
tp = types.SimpleNamespace(apdu_tracer=tracer)
|
||||
scc = types.SimpleNamespace(_tp=tp)
|
||||
S._server_ref = types.SimpleNamespace(scc=scc)
|
||||
S._collect_apdu_times()
|
||||
try:
|
||||
self.assertIs(tp.apdu_tracer, tracer)
|
||||
finally:
|
||||
S._end_apdu_time_collection()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the passive PC/SC card-presence observer and auto-equip state."""
|
||||
|
||||
import sys
|
||||
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))
|
||||
|
||||
import pysim_otaman_server.server as S
|
||||
|
||||
|
||||
class FakeCard:
|
||||
def __init__(self, reader):
|
||||
self.reader = reader
|
||||
|
||||
|
||||
class TestCardPresenceObserver(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.observer = S._CardPresenceObserver('Test Reader 00 00')
|
||||
self.server = types.SimpleNamespace(card_present=True)
|
||||
self.saved_ref = S._server_ref
|
||||
S._server_ref = self.server
|
||||
self.disconnects = []
|
||||
self.patcher = mock.patch.object(
|
||||
S, '_handle_card_disconnect',
|
||||
side_effect=lambda: self.disconnects.append(True))
|
||||
self.patcher.start()
|
||||
self.trigger = mock.patch.object(S, '_auto_equip_trigger')
|
||||
self.trigger_mock = self.trigger.start()
|
||||
self.saved_auto = S._AUTO_EQUIP
|
||||
S._AUTO_EQUIP = True
|
||||
|
||||
def tearDown(self):
|
||||
S._AUTO_EQUIP = self.saved_auto
|
||||
self.trigger.stop()
|
||||
self.patcher.stop()
|
||||
S._server_ref = self.saved_ref
|
||||
|
||||
def test_removal_of_our_reader_disconnects(self):
|
||||
self.observer.update(None, ([], [FakeCard('Test Reader 00 00')]))
|
||||
self.assertFalse(self.server.card_present)
|
||||
self.assertEqual(len(self.disconnects), 1)
|
||||
self.trigger_mock.assert_not_called()
|
||||
|
||||
def test_removal_of_other_reader_ignored(self):
|
||||
self.observer.update(None, ([], [FakeCard('Other Reader 00 00')]))
|
||||
self.assertTrue(self.server.card_present)
|
||||
self.assertEqual(self.disconnects, [])
|
||||
self.trigger_mock.assert_not_called()
|
||||
|
||||
def test_insertion_sets_card_present_and_triggers_auto_equip(self):
|
||||
self.server.card_present = False
|
||||
self.observer.update(None, ([FakeCard('Test Reader 00 00')], []))
|
||||
self.assertTrue(self.server.card_present)
|
||||
self.assertEqual(self.disconnects, [])
|
||||
self.trigger_mock.assert_called_once()
|
||||
|
||||
def test_insertion_does_not_trigger_when_disabled(self):
|
||||
S._AUTO_EQUIP = False
|
||||
self.server.card_present = False
|
||||
self.observer.update(None, ([FakeCard('Test Reader 00 00')], []))
|
||||
self.assertTrue(self.server.card_present)
|
||||
self.trigger_mock.assert_not_called()
|
||||
|
||||
def test_missing_reader_attribute_is_ignored(self):
|
||||
self.observer.update(None, ([], [types.SimpleNamespace()]))
|
||||
self.assertTrue(self.server.card_present)
|
||||
self.assertEqual(self.disconnects, [])
|
||||
self.trigger_mock.assert_not_called()
|
||||
|
||||
|
||||
class TestAutoEquipTrigger(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
S._AUTO_EQUIP = True
|
||||
S._AUTO_EQUIP_BUSY = False
|
||||
|
||||
def test_disabled_does_not_spawn(self):
|
||||
S._AUTO_EQUIP = False
|
||||
with mock.patch.object(S.threading, 'Thread') as thread:
|
||||
S._auto_equip_trigger()
|
||||
thread.assert_not_called()
|
||||
|
||||
def test_busy_does_not_spawn_twice(self):
|
||||
S._AUTO_EQUIP = True
|
||||
S._AUTO_EQUIP_BUSY = True
|
||||
with mock.patch.object(S.threading, 'Thread') as thread:
|
||||
S._auto_equip_trigger()
|
||||
thread.assert_not_called()
|
||||
|
||||
def test_spawns_worker_once(self):
|
||||
S._AUTO_EQUIP = True
|
||||
S._AUTO_EQUIP_BUSY = False
|
||||
with mock.patch.object(S.threading, 'Thread') as thread:
|
||||
S._auto_equip_trigger()
|
||||
thread.assert_called_once_with(target=S._auto_equip_worker, name='auto-equip', daemon=True)
|
||||
|
||||
|
||||
class TestCardSession(unittest.TestCase):
|
||||
def test_disconnect_bumps_session_and_clears_equipping(self):
|
||||
server = types.SimpleNamespace(
|
||||
card_session=5, card=object(), scc=object(), stk_pending=object(),
|
||||
menu_active=True, event_list=[1], sim_menu={}, equipping=True)
|
||||
saved = S._server_ref
|
||||
S._server_ref = server
|
||||
try:
|
||||
S._handle_card_disconnect()
|
||||
finally:
|
||||
S._server_ref = saved
|
||||
self.assertEqual(server.card_session, 6)
|
||||
self.assertFalse(server.equipping)
|
||||
self.assertIsNone(server.card)
|
||||
|
||||
|
||||
class TestApplyEquippedCard(unittest.TestCase):
|
||||
def test_updates_state_and_bumps_session(self):
|
||||
scc = types.SimpleNamespace(cat_cla=None)
|
||||
card = types.SimpleNamespace(_scc=scc, name='Card')
|
||||
app = types.SimpleNamespace(card=card)
|
||||
server = types.SimpleNamespace(
|
||||
app=app, card=None, scc=None, stk_pending=object(), menu_active=True,
|
||||
event_list=[1], sim_menu={}, card_session=2, card_present=False,
|
||||
equipping=False, terminal_profile='7F')
|
||||
saved_ref, saved_conn = S._server_ref, S._CARD_CONNECTED
|
||||
S._server_ref = server
|
||||
S._CARD_CONNECTED = False
|
||||
try:
|
||||
with mock.patch.object(S, '_send_terminal_profile', return_value=('menu', ['ev'])):
|
||||
with mock.patch.object(S, '_poll_enable'):
|
||||
S._apply_equipped_card(server)
|
||||
connected_after = S._CARD_CONNECTED
|
||||
finally:
|
||||
S._server_ref = saved_ref
|
||||
S._CARD_CONNECTED = saved_conn
|
||||
self.assertTrue(connected_after)
|
||||
self.assertIs(server.card, card)
|
||||
self.assertIs(server.scc, scc)
|
||||
self.assertEqual(server.card_session, 3)
|
||||
self.assertTrue(server.card_present)
|
||||
self.assertEqual(server.sim_menu, 'menu')
|
||||
self.assertEqual(server.event_list, ['ev'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the reset-free fast initialization helpers."""
|
||||
|
||||
import sys
|
||||
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.exceptions import SwMatchError
|
||||
from pySim.ts_102_221 import CardProfileUICC
|
||||
|
||||
import pysim_otaman_server.fastinit as fastinit
|
||||
from pysim_otaman_server.fastinit import (
|
||||
FastRuntimeState,
|
||||
do_reset_fast,
|
||||
pick_profile_no_reset,
|
||||
)
|
||||
|
||||
|
||||
class FakeScc:
|
||||
def __init__(self):
|
||||
self.sel_ctrl = '0004'
|
||||
self.cla_byte = '00'
|
||||
self.resets = 0
|
||||
self.selected = []
|
||||
|
||||
def reset_card(self):
|
||||
self.resets += 1
|
||||
|
||||
def select_file(self, fid):
|
||||
self.selected.append(fid)
|
||||
return ('', '9000')
|
||||
|
||||
def select_adf(self, aid):
|
||||
raise SwMatchError('6a82', '9000')
|
||||
|
||||
|
||||
class TestPickProfileNoReset(unittest.TestCase):
|
||||
def test_uicc_selected_without_any_reset(self):
|
||||
scc = FakeScc()
|
||||
profile = pick_profile_no_reset(scc)
|
||||
self.assertIsInstance(profile, CardProfileUICC)
|
||||
self.assertEqual(scc.resets, 0)
|
||||
self.assertIn('3f00', scc.selected)
|
||||
|
||||
def test_reset_card_restored_after_pick(self):
|
||||
scc = FakeScc()
|
||||
pick_profile_no_reset(scc)
|
||||
scc.reset_card()
|
||||
self.assertEqual(scc.resets, 1)
|
||||
|
||||
|
||||
class FakeLchan:
|
||||
def __init__(self):
|
||||
self.scc = types.SimpleNamespace(scp=object())
|
||||
self.selected_adf = 'SOMETHING'
|
||||
self.selected = []
|
||||
|
||||
def select(self, path, cmd_app=None):
|
||||
self.selected.append(path)
|
||||
|
||||
|
||||
class TestFastRuntimeStateSoftReset(unittest.TestCase):
|
||||
def make_rs(self):
|
||||
rs = FastRuntimeState.__new__(FastRuntimeState)
|
||||
rs.lchan = {0: FakeLchan(), 1: FakeLchan()}
|
||||
rs.adm_verified = True
|
||||
rs.card = types.SimpleNamespace(_scc=types.SimpleNamespace(get_atr=lambda: 'AABB'))
|
||||
rs.identity = {}
|
||||
return rs
|
||||
|
||||
def test_soft_reset_selects_mf_without_physical_reset(self):
|
||||
rs = self.make_rs()
|
||||
atr = rs.soft_reset()
|
||||
self.assertEqual(atr, 'AABB')
|
||||
self.assertEqual(rs.identity['ATR'], 'AABB')
|
||||
self.assertEqual(rs.lchan[0].selected, ['MF'])
|
||||
self.assertIsNone(rs.lchan[0].selected_adf)
|
||||
self.assertFalse(rs.adm_verified)
|
||||
self.assertNotIn(1, rs.lchan)
|
||||
|
||||
def test_reset_is_soft(self):
|
||||
rs = self.make_rs()
|
||||
rs.card = types.SimpleNamespace(_scc=types.SimpleNamespace(get_atr=lambda: 'EEFF'))
|
||||
self.assertEqual(rs.reset(), 'EEFF')
|
||||
self.assertEqual(rs.lchan[0].selected, ['MF'])
|
||||
|
||||
|
||||
class FakeCardScc:
|
||||
def __init__(self):
|
||||
self.resets = 0
|
||||
|
||||
def reset_card(self):
|
||||
self.resets += 1
|
||||
return 'ATR'
|
||||
|
||||
def get_atr(self):
|
||||
return 'AABB'
|
||||
|
||||
|
||||
class TestDoResetFast(unittest.TestCase):
|
||||
def test_explicit_reset_is_physical(self):
|
||||
scc = FakeCardScc()
|
||||
out = []
|
||||
app = types.SimpleNamespace(rs=None, card=types.SimpleNamespace(_scc=scc), poutput=out.append)
|
||||
do_reset_fast(app)
|
||||
self.assertEqual(scc.resets, 1)
|
||||
self.assertEqual(out, ['Card ATR: AABB'])
|
||||
|
||||
def test_explicit_reset_uses_hard_reset_with_runtime_state(self):
|
||||
calls = []
|
||||
rs = types.SimpleNamespace(hard_reset=lambda cmd_app=None: calls.append(cmd_app) or 'CCDD')
|
||||
out = []
|
||||
app = types.SimpleNamespace(rs=rs, card=None, poutput=out.append)
|
||||
do_reset_fast(app)
|
||||
self.assertEqual(calls, [app])
|
||||
self.assertEqual(out, ['Card ATR: CCDD'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class FlakyLchan:
|
||||
"""Lchan whose first MF select fails, as if probing left the card in a
|
||||
context the software reset cannot clear."""
|
||||
|
||||
def __init__(self):
|
||||
self.scc = types.SimpleNamespace(scp=object())
|
||||
self.selected_adf = 'SOMETHING'
|
||||
self.select_calls = 0
|
||||
self.selected = []
|
||||
|
||||
def select(self, path, cmd_app=None):
|
||||
self.select_calls += 1
|
||||
if self.select_calls == 1:
|
||||
raise SwMatchError('6d00', '9000')
|
||||
self.selected.append(path)
|
||||
|
||||
|
||||
class ResettableCard:
|
||||
def __init__(self):
|
||||
self.resets = 0
|
||||
self._scc = types.SimpleNamespace(get_atr=lambda: 'AABB')
|
||||
|
||||
def reset(self):
|
||||
self.resets += 1
|
||||
return 'AABB'
|
||||
|
||||
|
||||
class TestFastResetEscalation(unittest.TestCase):
|
||||
def make_rs(self):
|
||||
rs = FastRuntimeState.__new__(FastRuntimeState)
|
||||
rs.lchan = {0: FlakyLchan(), 1: types.SimpleNamespace(scc=types.SimpleNamespace(scp=None))}
|
||||
rs.adm_verified = True
|
||||
rs.card = ResettableCard()
|
||||
rs.identity = {}
|
||||
return rs
|
||||
|
||||
def test_soft_reset_escalates_to_physical(self):
|
||||
rs = self.make_rs()
|
||||
atr = rs.reset()
|
||||
self.assertEqual(atr, 'AABB')
|
||||
self.assertEqual(rs.card.resets, 1)
|
||||
self.assertEqual(rs.lchan[0].select_calls, 2)
|
||||
self.assertEqual(rs.lchan[0].selected, ['MF'])
|
||||
self.assertFalse(rs.adm_verified)
|
||||
self.assertNotIn(1, rs.lchan)
|
||||
|
||||
|
||||
class TestInitCardFastRetry(unittest.TestCase):
|
||||
def test_retries_once_after_physical_reset(self):
|
||||
calls = []
|
||||
|
||||
def once(sl, skip, wait):
|
||||
calls.append(wait)
|
||||
if len(calls) == 1:
|
||||
raise SwMatchError('6d00', '9000')
|
||||
return ('rs', 'card')
|
||||
|
||||
sl = types.SimpleNamespace(resets=0)
|
||||
|
||||
def reset_card():
|
||||
sl.resets += 1
|
||||
|
||||
sl.reset_card = reset_card
|
||||
with mock.patch.object(fastinit, '_init_card_once', side_effect=once):
|
||||
rs, card = fastinit.init_card_fast(sl, wait=True)
|
||||
self.assertEqual(calls, [True, False])
|
||||
self.assertEqual(sl.resets, 1)
|
||||
self.assertEqual((rs, card), ('rs', 'card'))
|
||||
|
||||
|
||||
class TestDoEquipFastFailure(unittest.TestCase):
|
||||
def test_failed_equip_keeps_previous_state(self):
|
||||
calls = []
|
||||
app = types.SimpleNamespace(
|
||||
sl=object(),
|
||||
rs=types.SimpleNamespace(profile=types.SimpleNamespace(shell_cmdsets=[object()])),
|
||||
unregister_command_set=lambda cs: calls.append('unregister'),
|
||||
equip=lambda card, rs: calls.append('equip'),
|
||||
)
|
||||
with mock.patch.object(fastinit, 'init_card_fast', side_effect=SwMatchError('6d00', '9000')):
|
||||
with self.assertRaises(SwMatchError):
|
||||
fastinit.do_equip_fast(app)
|
||||
self.assertEqual(calls, [])
|
||||
@@ -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
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the paused-command (STK menu) timeout watchdog."""
|
||||
|
||||
import sys
|
||||
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))
|
||||
|
||||
import pysim_otaman_server.server as S
|
||||
|
||||
|
||||
class TestMenuTimeout(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = (S._MENU_TIMEOUT, S._MENU_TIMER)
|
||||
|
||||
def tearDown(self):
|
||||
S._cancel_menu_timeout()
|
||||
S._MENU_TIMEOUT, S._MENU_TIMER = self.saved
|
||||
|
||||
def test_clamped(self):
|
||||
S._set_menu_timeout(30)
|
||||
self.assertEqual(S._MENU_TIMEOUT, 30)
|
||||
S._set_menu_timeout(0)
|
||||
self.assertEqual(S._MENU_TIMEOUT, 0)
|
||||
S._set_menu_timeout(-1)
|
||||
self.assertEqual(S._MENU_TIMEOUT, 0)
|
||||
S._set_menu_timeout(99999)
|
||||
self.assertEqual(S._MENU_TIMEOUT, 3600)
|
||||
|
||||
def test_arm_starts_timer(self):
|
||||
S._set_menu_timeout(30)
|
||||
with mock.patch.object(S.threading, 'Timer') as timer:
|
||||
S._arm_menu_timeout()
|
||||
timer.assert_called_once_with(30, S._menu_timeout_fire)
|
||||
|
||||
def test_zero_disables_arming(self):
|
||||
S._set_menu_timeout(0)
|
||||
with mock.patch.object(S.threading, 'Timer') as timer:
|
||||
S._arm_menu_timeout()
|
||||
timer.assert_not_called()
|
||||
|
||||
def test_cancel(self):
|
||||
timer = mock.Mock()
|
||||
S._MENU_TIMER = timer
|
||||
S._cancel_menu_timeout()
|
||||
timer.cancel.assert_called_once()
|
||||
self.assertIsNone(S._MENU_TIMER)
|
||||
|
||||
|
||||
class TestMenuSendResponse(unittest.TestCase):
|
||||
def test_timeout_tr_is_flat_with_general_result(self):
|
||||
sent = []
|
||||
|
||||
def send_apdu(hexstr):
|
||||
sent.append(hexstr)
|
||||
return ('', '9000')
|
||||
|
||||
server = types.SimpleNamespace(
|
||||
stk_pending={'type': 'display_text', 'cmd_num': 1, 'cmd_type': 0x21,
|
||||
'dev_src': 0x81, 'dev_dst': 0x83},
|
||||
menu_active=True,
|
||||
scc=types.SimpleNamespace(cat_cla='80', _tp=types.SimpleNamespace(send_apdu=send_apdu)),
|
||||
)
|
||||
resp, code = S._menu_send_response(server, 'timeout', None)
|
||||
self.assertEqual(code, 200)
|
||||
self.assertEqual(resp['sw'], '9000')
|
||||
self.assertEqual(resp['type'], 'done')
|
||||
self.assertIsNone(server.stk_pending)
|
||||
self.assertFalse(server.menu_active)
|
||||
tr = sent[0]
|
||||
self.assertTrue(tr.startswith('801400000d'), tr)
|
||||
self.assertIn('8103012100', tr)
|
||||
self.assertIn('82028381', tr)
|
||||
self.assertIn('83021200', tr)
|
||||
|
||||
def test_no_pending_returns_400(self):
|
||||
resp, code = S._menu_send_response(types.SimpleNamespace(stk_pending=None), 'ok')
|
||||
self.assertEqual(code, 400)
|
||||
self.assertIn('error', resp)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestFinishPendingMenu(unittest.TestCase):
|
||||
def make_server(self):
|
||||
return types.SimpleNamespace(
|
||||
stk_pending={'type': 'select_item', 'cmd_num': 1, 'cmd_type': 0x24,
|
||||
'dev_src': 0x81, 'dev_dst': 0x83, 'items': []},
|
||||
menu_active=True, scc=None)
|
||||
|
||||
def make_scc(self, sent, sw='9000'):
|
||||
return types.SimpleNamespace(
|
||||
cat_cla='80',
|
||||
_tp=types.SimpleNamespace(send_apdu=lambda h: (sent.append(h) or ('', sw))))
|
||||
|
||||
def test_no_pending_is_noop(self):
|
||||
sent = []
|
||||
S._finish_pending_menu(types.SimpleNamespace(stk_pending=None), self.make_scc(sent))
|
||||
self.assertEqual(sent, [])
|
||||
|
||||
def test_pending_finished_with_cancel_tr(self):
|
||||
server = self.make_server()
|
||||
sent = []
|
||||
scc = self.make_scc(sent)
|
||||
server.scc = scc
|
||||
S._finish_pending_menu(server, scc)
|
||||
self.assertEqual(len(sent), 1)
|
||||
tr = sent[0]
|
||||
self.assertTrue(tr.startswith('801400000d'), tr)
|
||||
self.assertIn('83021000', tr) # general result 0x10 = cancel
|
||||
self.assertIsNone(server.stk_pending)
|
||||
self.assertFalse(server.menu_active)
|
||||
|
||||
def test_91xx_answer_drains_chain(self):
|
||||
server = self.make_server()
|
||||
scc = self.make_scc([], sw='9120')
|
||||
server.scc = scc
|
||||
with mock.patch.object(S, '_handle_proactive_chain') as chain:
|
||||
S._finish_pending_menu(server, scc)
|
||||
chain.assert_called_once_with(scc, '9120')
|
||||
+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'])
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the background STATUS polling interval semantics."""
|
||||
|
||||
import sys
|
||||
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))
|
||||
|
||||
import pysim_otaman_server.server as S
|
||||
|
||||
|
||||
class TestPollInterval(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = (S._POLL_ENABLED, S._POLL_INTERVAL, S._POLL_TIMER)
|
||||
|
||||
def tearDown(self):
|
||||
S._poll_disable()
|
||||
S._POLL_ENABLED, S._POLL_INTERVAL, S._POLL_TIMER = self.saved
|
||||
|
||||
def test_zero_interval_disables_polling(self):
|
||||
S._set_poll_interval(0)
|
||||
self.assertEqual(S._POLL_INTERVAL, 0)
|
||||
with mock.patch.object(S.threading, 'Timer') as timer:
|
||||
S._poll_enable()
|
||||
timer.assert_not_called()
|
||||
self.assertFalse(S._POLL_ENABLED)
|
||||
self.assertIsNone(S._POLL_TIMER)
|
||||
|
||||
def test_negative_interval_clamped_to_zero(self):
|
||||
S._set_poll_interval(-5)
|
||||
self.assertEqual(S._POLL_INTERVAL, 0)
|
||||
|
||||
def test_positive_interval_starts_timer(self):
|
||||
S._set_poll_interval(30)
|
||||
with mock.patch.object(S.threading, 'Timer') as timer:
|
||||
S._poll_enable()
|
||||
self.assertTrue(S._POLL_ENABLED)
|
||||
timer.assert_called_once_with(30, S._do_status_poll)
|
||||
|
||||
def test_reset_timer_skipped_when_disabled(self):
|
||||
S._set_poll_interval(0)
|
||||
S._POLL_ENABLED = True
|
||||
with mock.patch.object(S.threading, 'Timer') as timer:
|
||||
S._reset_poll_timer()
|
||||
timer.assert_not_called()
|
||||
self.assertIsNone(S._POLL_TIMER)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+1189
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for the parent-scoped select helpers in pysim_otaman_server.server.
|
||||
|
||||
The helpers must resolve every model-known file strictly within the requested
|
||||
parent (no pySim global selectables, no probe_file model injection) and must
|
||||
detach any model-unknown file that had to be probed for a custom file.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
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.server import (
|
||||
_app_by_sel,
|
||||
_fid4,
|
||||
_file_by_sel,
|
||||
_find_in_tree,
|
||||
_select_path,
|
||||
_select_with_parent,
|
||||
)
|
||||
|
||||
|
||||
class FakeFile:
|
||||
def __init__(self, fid=None, name=None, parent=None, aid=None):
|
||||
self.fid = fid
|
||||
self.name = name
|
||||
self.parent = parent
|
||||
self.aid = aid
|
||||
self.sfid = None
|
||||
self.children = {}
|
||||
|
||||
def add_files(self, files):
|
||||
for f in files:
|
||||
f.parent = self
|
||||
self.children[f.fid] = f
|
||||
|
||||
|
||||
class FakeMF(FakeFile):
|
||||
def __init__(self):
|
||||
super().__init__(fid='3f00', name='MF')
|
||||
self.applications = {}
|
||||
|
||||
|
||||
class FakeLchan:
|
||||
"""No .select() on purpose: any global-resolution call would fail loudly."""
|
||||
|
||||
def __init__(self, mf):
|
||||
self.selected_file = mf
|
||||
self.selects = []
|
||||
self.probes = []
|
||||
|
||||
def select_file(self, f, app=None):
|
||||
self.selected_file = f
|
||||
self.selects.append(f)
|
||||
|
||||
def probe_file(self, fid, app=None):
|
||||
self.probes.append(fid)
|
||||
f = FakeFile(fid=fid, name='EF.' + fid.upper(), parent=self.selected_file)
|
||||
self.selected_file.add_files([f])
|
||||
self.selected_file = f
|
||||
|
||||
|
||||
def build_model():
|
||||
mf = FakeMF()
|
||||
gsm = FakeFile('7f20', 'DF.GSM', mf)
|
||||
mf.children['7f20'] = gsm
|
||||
spn = FakeFile('6f46', 'EF.SPN', gsm)
|
||||
gsm.children['6f46'] = spn
|
||||
telecom = FakeFile('7f10', 'DF.TELECOM', mf)
|
||||
mf.children['7f10'] = telecom
|
||||
tel_ph = FakeFile('5f3a', 'DF.PHONEBOOK', telecom)
|
||||
telecom.children['5f3a'] = tel_ph
|
||||
usim = FakeFile(None, 'ADF.USIM', mf, aid='A0000000871002')
|
||||
mf.applications['a0000000871002'] = usim
|
||||
imsi = FakeFile('6f07', 'EF.IMSI', usim)
|
||||
usim.children['6f07'] = imsi
|
||||
usim_ph = FakeFile('5f3a', 'DF.PHONEBOOK', usim)
|
||||
usim.children['5f3a'] = usim_ph
|
||||
return mf, usim, usim_ph, telecom, tel_ph, gsm, spn
|
||||
|
||||
|
||||
def setup():
|
||||
mf, usim, usim_ph, telecom, tel_ph, gsm, spn = build_model()
|
||||
app = SimpleNamespace(rs=SimpleNamespace(mf=mf))
|
||||
lchan = FakeLchan(mf)
|
||||
return app, lchan, mf, usim, usim_ph, gsm, spn
|
||||
|
||||
|
||||
class FidHelpersTest(unittest.TestCase):
|
||||
def test_fid4(self):
|
||||
self.assertTrue(_fid4('6F07'))
|
||||
self.assertFalse(_fid4('EF.IMSI'))
|
||||
self.assertFalse(_fid4('6F0'))
|
||||
|
||||
def test_file_by_sel_matches_fid_and_name(self):
|
||||
_, _, _, _, _, gsm, spn = setup()
|
||||
self.assertIs(_file_by_sel(gsm, '6f46'), spn)
|
||||
self.assertIs(_file_by_sel(gsm, 'EF.SPN'), spn)
|
||||
self.assertIsNone(_file_by_sel(gsm, '6f07'))
|
||||
|
||||
def test_find_in_tree_reports_duplicates(self):
|
||||
app, _, mf, _, _, _, _ = setup()
|
||||
self.assertEqual(len(_find_in_tree(mf, '5f3a')), 2)
|
||||
self.assertEqual(len(_find_in_tree(mf, 'EF.IMSI')), 1)
|
||||
|
||||
|
||||
class ParentScopedSelectTest(unittest.TestCase):
|
||||
def test_duplicate_fid_is_resolved_under_the_walked_parent(self):
|
||||
app, lchan, mf, usim, usim_ph, _, _ = setup()
|
||||
target, cleanup = _select_with_parent(lchan, '5f3a', None, app, parent_path=['MF', 'A0000000871002'])
|
||||
self.assertIs(target, usim_ph)
|
||||
self.assertIsNone(cleanup)
|
||||
self.assertEqual(lchan.selects, [mf, usim, usim_ph])
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
def test_path_with_fids_selects_exactly(self):
|
||||
app, lchan, mf, _, _, gsm, spn = setup()
|
||||
target, cleanup = _select_path(lchan, 'MF/7F20/6F46', app)
|
||||
self.assertIs(target, spn)
|
||||
self.assertIsNone(cleanup)
|
||||
self.assertEqual(lchan.selects, [mf, gsm, spn])
|
||||
|
||||
def test_path_with_aid_root_selects_application(self):
|
||||
app, lchan, _, usim, _, _, _ = setup()
|
||||
target, _ = _select_path(lchan, 'A0000000871002/6F07', app)
|
||||
self.assertEqual(target.fid, '6f07')
|
||||
self.assertEqual(lchan.selected_file.parent, usim)
|
||||
|
||||
def test_ambiguous_legacy_parent_selector_is_rejected(self):
|
||||
app, lchan, _, _, _, _, _ = setup()
|
||||
with self.assertRaisesRegex(RuntimeError, 'Ambiguous'):
|
||||
_select_with_parent(lchan, '6f07', '5f3a', app)
|
||||
|
||||
def test_unknown_name_is_not_probed(self):
|
||||
app, lchan, _, _, _, gsm, _ = setup()
|
||||
with self.assertRaisesRegex(RuntimeError, 'File not found'):
|
||||
_select_with_parent(lchan, 'NOSUCH', None, app, parent_path=['MF', '7F20'])
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
def test_unknown_fid_without_allow_probe_does_not_touch_the_card(self):
|
||||
app, lchan, _, _, _, gsm, _ = setup()
|
||||
with self.assertRaisesRegex(RuntimeError, 'File not found'):
|
||||
_select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'])
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
def test_allow_probe_detaches_the_temporary_file_and_restores_selection(self):
|
||||
app, lchan, mf, _, _, gsm, _ = setup()
|
||||
before = set(gsm.children)
|
||||
target, cleanup = _select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'], allow_probe=True)
|
||||
self.assertEqual(lchan.probes, ['6f99'])
|
||||
self.assertEqual(target.fid, '6f99')
|
||||
self.assertIsNotNone(cleanup)
|
||||
self.assertIn('6f99', gsm.children)
|
||||
cleanup()
|
||||
self.assertEqual(set(gsm.children), before)
|
||||
self.assertIs(lchan.selected_file, mf)
|
||||
|
||||
def test_custom_path_segments_are_probed_and_detached(self):
|
||||
app, lchan, mf, _, _, gsm, _ = setup()
|
||||
before = set(gsm.children)
|
||||
target, cleanup = _select_path(lchan, 'MF/7F20/A0B1/6F01', app)
|
||||
self.assertEqual(lchan.probes, ['a0b1', '6f01'])
|
||||
self.assertEqual(target.fid, '6f01')
|
||||
cleanup()
|
||||
self.assertEqual(set(gsm.children), before)
|
||||
self.assertIs(lchan.selected_file, mf)
|
||||
|
||||
def test_model_known_selection_never_mutates_the_tree(self):
|
||||
app, lchan, _, _, _, gsm, spn = setup()
|
||||
before = {id(k): k for k in gsm.children}
|
||||
_select_with_parent(lchan, '6f46', None, app, parent_path=['MF', '7F20'])
|
||||
self.assertEqual({id(k): k for k in gsm.children}, before)
|
||||
self.assertEqual(lchan.probes, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
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