Compare commits
182 Commits
bdf3d1a352
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 30969f8f04 | |||
| 569f308cec | |||
| 61ab7bc304 | |||
| 9879675df2 | |||
| dd18a24062 | |||
| 5c279147c1 | |||
| 330a1f0070 | |||
| 1daced1e18 | |||
| 9d7fa11871 | |||
| 7c1d623ca2 | |||
| 7388f39bd6 | |||
| 201b0df278 | |||
| 1db6c12d52 | |||
| f7b826b243 | |||
| 8b55a7159f | |||
| 8c41655e18 | |||
| 29c3143909 | |||
| 57c414de07 | |||
| 2c0889a8a3 | |||
| b5f871641f | |||
| 8de81a6a33 | |||
| 856e691255 | |||
| 54dfb2f6b6 | |||
| 32985a2951 | |||
| 629c9101bc | |||
| 4f8dae041d | |||
| 7e1916628e | |||
| 07c3cdc923 | |||
| 269ad51f7e | |||
| eb21715050 | |||
| bb944e7548 | |||
| 7ece8a531c | |||
| 33255dc35e | |||
| 73f46e99c4 | |||
| a36f26e8e1 | |||
| 7327a879ce | |||
| e607637782 | |||
| 072e8fe08b | |||
| feeefa3018 | |||
| 32509b102c | |||
| 71d2e80242 | |||
| 7870682f3d | |||
| 0fd91d7771 | |||
| 288c2e5954 | |||
| 0857c5f68b | |||
| b832372ac8 | |||
| 1d4693305d | |||
| 2782ff35c4 | |||
| 5f2eaf6e9d | |||
| b7d0d53b94 | |||
| 1ce23f021c | |||
| 72b0116bad | |||
| 8019326cfb | |||
| 01931b9693 | |||
| 03ec13364c | |||
| e6602c40bf | |||
| 4a6a142c3d | |||
| 0217f8dc92 | |||
| 6d8ef55398 | |||
| 43242210ab | |||
| 53daf2c6ea | |||
| 79cf003444 | |||
| e8a8ef3d1b | |||
| d75604a751 | |||
| 80a860549a | |||
| a916e8a255 | |||
| 62ba09d25e | |||
| 966ada21eb | |||
| 1971eca381 | |||
| d2c292e5b8 | |||
| 8e96c5d4a0 | |||
| 05096a184a | |||
| 65127b8cfe | |||
| a88930b7b1 | |||
| ab78455ff6 | |||
| e8158fa5c4 | |||
| 3c03b04157 | |||
| 6e2a670fc8 | |||
| 65aa38dc1b | |||
| 6418e1e37d | |||
| e3b8664286 | |||
| 925ff80d80 | |||
| 69a20baa9f | |||
| f764760b0a | |||
| a2f7b60333 | |||
| 3b7dbf1743 | |||
| 7c5d0051d4 | |||
| 302a1f87a2 | |||
| 52bcd31387 | |||
| d4f40aaaef |
@@ -1 +1,7 @@
|
||||
node_modules/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
AGENTS.md
|
||||
|
||||
@@ -1,31 +1,49 @@
|
||||
# OTAMan — APDU Helper & Secured Packet Builder, SIM OTA in PWA
|
||||
# OTAMan — SIM OTA toolkit: PWA + local card server
|
||||
|
||||
Standalone offline HTML/JS tool for building APDU commands for SIM, USIM, and GlobalPlatform RAM, assembling secure packets per ETSI TS 102 225, and constructing BER-TLV command scripts per ETSI TS 102 226.
|
||||
OTAMan is an offline HTML/JS PWA for building APDU commands (SIM, USIM, GlobalPlatform RAM), assembling SCP80 secured packets per ETSI TS 102 225, and constructing Expanded Remote Application data format APDU per ETSI TS 102 226. A bundled [`pysim-otaman-server`](pysim_otaman_server/) exposes a local HTTP API over pySim for live card operations: file manager, raw APDU, SIM Toolkit menu browsing, and OTA (SCP80) delivery.
|
||||
|
||||
Open `index.html` in any modern browser. No server required.
|
||||
**Demo:** [otaman.atroshin.ru](https://otaman.atroshin.ru) — the PWA alone, for experimenting. Install the server (below) for card-reader functions.
|
||||
|
||||
**Demo:** [otaman.atroshin.ru](https://otaman.atroshin.ru)
|
||||
## Quick start
|
||||
|
||||
**PWA only (client-side tools):** open `frontend/index.html` in any browser, or serve `frontend/` with any static server. No Python required.
|
||||
|
||||
**Full (PWA + card server):**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
./setup.sh # or setup.bat on Windows — creates .venv, installs pysim + server
|
||||
./start.sh # or start.bat — starts the server (it serves the PWA too)
|
||||
```
|
||||
|
||||
Then open http://127.0.0.1:8080 — the UI and API share one origin, so no CORS or browser-permission setup is needed.
|
||||
|
||||
## Build
|
||||
|
||||
Tailwind CSS is used for styling. After cloning, rebuild the CSS:
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Interface
|
||||
|
||||
Six tabs, each with a form and a "Сгенерировать" button.
|
||||
Five top-level tabs: **Remote APDU**, **SCP80**, **Profiler**, **Card reader**, and **Phone simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the Card reader tab has three sub-tabs: **File manager**, **pySim command line**, and **Raw APDU**; the Profiler tab lists **Profiles**, **Card snapshots**, and **Custom files**.
|
||||
|
||||
---
|
||||
|
||||
## SIM RFM Tab
|
||||
## Remote APDU tab
|
||||
|
||||
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
|
||||
|
||||
CLA = `A0` (GSM 11.11 / ISO 7816-4).
|
||||
|
||||
### Commands
|
||||
#### Commands
|
||||
|
||||
| Command | INS | Description |
|
||||
|---|---|---|
|
||||
@@ -40,56 +58,48 @@ CLA = `A0` (GSM 11.11 / ISO 7816-4).
|
||||
| VERIFY PIN | 20 | Verify PIN1 or PIN2 |
|
||||
| CHANGE PIN | 24 | Change PIN1 or PIN2 |
|
||||
|
||||
### SELECT methods
|
||||
#### SELECT methods
|
||||
|
||||
| Method | P1 | P2 | Input |
|
||||
|---|---|---|---|
|
||||
| По FID | 00 | 00 | 2-byte FID (4 hex) |
|
||||
| По полному пути от MF | 08 | 00 | Full path hex from MF |
|
||||
| По DF name / AID | 04 | 00 | AID (application ID) |
|
||||
| ADF RFM цепочка | 00 | 00 | Comma-separated FIDs, each selected in turn |
|
||||
| By FID | 00 | 00 | 2-byte FID (4 hex) |
|
||||
| By full path from MF | 08 | 00 | Full path hex from MF |
|
||||
| By DF name / AID | 04 | 00 | AID (application ID) |
|
||||
| ADF RFM chain | 00 | 00 | Comma-separated FIDs, each selected in turn |
|
||||
|
||||
### Options
|
||||
#### Options
|
||||
|
||||
- **Начать с SELECT** — checkbox to prepend a SELECT command before the operation. When unchecked, the operation is sent standalone with CLA.
|
||||
- **Режим выборки (P2)** — for record commands: Absolute (04), Next (06), Previous (02).
|
||||
- **Размер записи** — pad/truncate data to the specified byte count.
|
||||
- **Переопределить P1/P2** — checkbox to enable manual override of P1/P2 bytes.
|
||||
- **Start with SELECT** — checkbox to prepend a SELECT command before the operation. When unchecked, the operation is sent standalone with CLA.
|
||||
- **Selection mode (P2)** — for record commands: Absolute (04), Next (06), Previous (02).
|
||||
- **Record size** — pad/truncate data to the specified byte count.
|
||||
- **Allow P1/P2 editing** — checkbox to enable manual override of P1/P2 bytes.
|
||||
|
||||
### Conversion sidebar
|
||||
|
||||
A conversion panel is embedded in the right-hand column, supporting IMSI, MSISDN, ICCID, SPN, PLMN, and Nibble swap conversions.
|
||||
|
||||
### References
|
||||
#### References
|
||||
|
||||
- ISO/IEC 7816-4: Organization, security and commands for interchange
|
||||
- ETSI TS 102 226: Remote APDU structure for UICC based applications
|
||||
- GSM 11.11: SIM-ME Interface
|
||||
|
||||
---
|
||||
|
||||
## USIM RFM Tab
|
||||
### USIM RFM
|
||||
|
||||
CLA = `00` (ETSI TS 102 221). Same commands as SIM, but SELECT uses P1=09, P2=0C (by FID from current directory).
|
||||
|
||||
### References
|
||||
#### References
|
||||
|
||||
- ETSI TS 102 221: UICC-Terminal Interface; Physical and Logical Characteristics
|
||||
- ETSI TS 102 226: Remote APDU structure for UICC based applications
|
||||
|
||||
---
|
||||
|
||||
## BER-TLV Tab
|
||||
### Expanded Script
|
||||
|
||||
Builds Expanded Remote Application data format per ETSI TS 102 226 §5.2.1.
|
||||
|
||||
### Format
|
||||
#### Format
|
||||
|
||||
Two encoding variants:
|
||||
- **Definite (AA)**: `AA` + length + Command TLVs
|
||||
- **Indefinite (AE)**: `AE` + `80` + Command TLVs + `00 00`
|
||||
|
||||
### Command TLVs
|
||||
#### Command TLVs
|
||||
|
||||
| Type | Tag | Description |
|
||||
|---|---|---|
|
||||
@@ -98,7 +108,7 @@ Two encoding variants:
|
||||
| Error Action | 82 | Proactive command on error |
|
||||
| Script Chaining | 83 | Chaining data for multi-packet scripts |
|
||||
|
||||
### Immediate Action builder
|
||||
#### Immediate Action builder
|
||||
|
||||
When the type is set to Immediate Action, the tool provides a structured builder for:
|
||||
|
||||
@@ -108,19 +118,17 @@ When the type is set to Immediate Action, the tool provides a structured builder
|
||||
|
||||
Error Action supports the same builder (DISPLAY TEXT, PLAY TONE).
|
||||
|
||||
### References
|
||||
#### References
|
||||
|
||||
- ETSI TS 102 226 V13.0.0 §5.2.1: Expanded Remote Application data format
|
||||
- ETSI TS 102 223: Card Application Toolkit (CAT) — proactive command structure
|
||||
- ETSI TS 101 220: BER-TLV tag assignments
|
||||
|
||||
---
|
||||
|
||||
## RAM Tab
|
||||
### RAM/GP
|
||||
|
||||
CLA = `80` (GlobalPlatform Card Specification v2.3.1). Remote Application Management commands for card content management.
|
||||
|
||||
### Commands
|
||||
#### GP Commands Reference
|
||||
|
||||
| Command | INS | P1 | Description |
|
||||
|---|---|---|---|
|
||||
@@ -138,7 +146,7 @@ CLA = `80` (GlobalPlatform Card Specification v2.3.1). Remote Application Manage
|
||||
| EXTERNAL AUTHENTICATE | 82 | 00 | SCP host authentication |
|
||||
| INTERNAL AUTHENTICATE | 88 | 00 | Card challenge-response |
|
||||
|
||||
### INSTALL [for install] — Privilege Builder
|
||||
#### INSTALL [for install] — Privilege Builder
|
||||
|
||||
Tag `C7` in the INSTALL data field. Built from 3 privilege bytes (GP spec Tables 11-7, 11-8, 11-9):
|
||||
|
||||
@@ -169,7 +177,7 @@ Tag `C7` in the INSTALL data field. Built from 3 privilege bytes (GP spec Tables
|
||||
|---|---|
|
||||
| b8 | Receipt Generation |
|
||||
|
||||
### INSTALL [for install] — SIM/UICC Toolkit Parameters
|
||||
#### INSTALL [for install] — SIM/UICC Toolkit Parameters
|
||||
|
||||
Optional TLV objects appended to the INSTALL data field:
|
||||
|
||||
@@ -186,7 +194,7 @@ Optional TLV objects appended to the INSTALL data field:
|
||||
| 16 | RC/DS/CC + MAC + Cipher |
|
||||
| 19 | RC/DS/CC + MAC + Cipher + DS |
|
||||
|
||||
### GET STATUS P1 values
|
||||
#### GET STATUS P1 values
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
@@ -195,7 +203,7 @@ Optional TLV objects appended to the INSTALL data field:
|
||||
| 20 | Executable Load Files |
|
||||
| 10 | ELF and their Executable Modules |
|
||||
|
||||
### GET STATUS P2 values
|
||||
#### GET STATUS P2 values
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
@@ -204,7 +212,7 @@ Optional TLV objects appended to the INSTALL data field:
|
||||
| 00 | First/all, old format (deprecated) |
|
||||
| 02 | Next, old format (deprecated) |
|
||||
|
||||
### GET DATA tag values
|
||||
#### GET DATA tag values
|
||||
|
||||
| Tag | Data Object |
|
||||
|---|---|
|
||||
@@ -222,14 +230,14 @@ Optional TLV objects appended to the INSTALL data field:
|
||||
| 7F21 | Certificate (SD public key) |
|
||||
| 5031 | Certificate info (EF.OD) |
|
||||
|
||||
### DELETE P1 values
|
||||
#### DELETE P1 values
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| 00 | By AID |
|
||||
| 80 | Delete associated objects |
|
||||
|
||||
### STORE DATA P1 values
|
||||
#### STORE DATA P1 values
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
@@ -238,7 +246,7 @@ Optional TLV objects appended to the INSTALL data field:
|
||||
| 80 | Last block, encrypted |
|
||||
| C0 | More blocks, encrypted |
|
||||
|
||||
### SET STATUS parameters
|
||||
#### SET STATUS parameters
|
||||
|
||||
**P1 (Status Type)**:
|
||||
| Value | Target |
|
||||
@@ -253,18 +261,101 @@ Optional TLV objects appended to the INSTALL data field:
|
||||
| 00 | Unlock (return to previous state) |
|
||||
| 80 | Lock (LOCKED state) |
|
||||
|
||||
### References
|
||||
#### References
|
||||
|
||||
- GlobalPlatform Card Specification v2.3.1 (GPC_Spec_v2.3.1): Commands, Privileges, TLV structures
|
||||
- ETSI TS 102 226 V13.0.0 §8.2.1.3.2: SIM/UICC Toolkit parameters, MSL, TAR, Access Domain
|
||||
|
||||
### Conversion (SIM/USIM sidebars)
|
||||
|
||||
Value encoding conversions embedded in the SIM RFM and USIM RFM tabs.
|
||||
|
||||
#### IMSI → EF.IMSI
|
||||
|
||||
Per TS 31.102 §4.2.3. Encodes a 15-digit IMSI into the 9-byte EF.IMSI format:
|
||||
- Byte 0: number of subsequent bytes (8)
|
||||
- Odd/even indicator nibble in the last byte
|
||||
- BCD digits, swapped nibble pairs per identity
|
||||
|
||||
Input: 15 decimal digits. Output: 18 hex characters.
|
||||
|
||||
#### MSISDN → BCD
|
||||
|
||||
Strips leading `+`, pads odd length with `f`, swaps nibble pairs.
|
||||
|
||||
#### ICCID → hex
|
||||
|
||||
Swaps nibble pairs of the ICCID string.
|
||||
|
||||
#### Provider Name → SPN
|
||||
|
||||
Per 3GPP TS 31.102 §4.2.5 (EF_SPN). Three encoding paths:
|
||||
|
||||
1. **GSM 7-bit packed** (all chars in GSM 7-bit default alphabet): prefix `01`, DCS byte (spare bits), packed septets, 0xFF padding to 16 bytes.
|
||||
2. **UCS2 non-BMP** (emoji / chars > U+FFFF): prefix `00`, DCS `80`, UTF-16BE, 0xFF padding to 16 bytes.
|
||||
3. **UCS2 BMP non-GSM7** (Cyrillic, etc.): prefix `00`, DCS `81`, base byte, per-char offsets, 0xFF padding to 16 bytes.
|
||||
|
||||
GSM 7-bit alphabet per 3GPP TS 23.038. Full extension table supported.
|
||||
|
||||
#### PLMN → EF_PLMNsel / PLMNwAcT
|
||||
|
||||
Per TS 31.102 §4.2.3. 3-byte BCD encoding for PLMN, plus optional 2-byte Access Technology selector.
|
||||
|
||||
#### Nibble swap
|
||||
|
||||
Swaps nibble pairs of an even-length hex string.
|
||||
|
||||
#### References
|
||||
|
||||
- 3GPP TS 31.102: Characteristics of the USIM Application
|
||||
- 3GPP TS 23.038: Alphabets and language information
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## Secured Packet Tab
|
||||
|
||||
Assembles secured packets per ETSI TS 102 225.
|
||||
### Response parser
|
||||
|
||||
### Packet structure
|
||||
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.
|
||||
|
||||
### Secured Packet
|
||||
|
||||
Builds SCP80 secured packets per ETSI TS 102 225.
|
||||
|
||||
#### Packet structure
|
||||
|
||||
| Field | Size | Description |
|
||||
|---|---|---|
|
||||
@@ -281,22 +372,27 @@ Assembles secured packets per ETSI TS 102 225.
|
||||
| RC/CC/DS | 8 | Cryptographic Checksum / MAC |
|
||||
| Secured Data | variable | Padded APDU (encrypted if required) |
|
||||
|
||||
### SPI1 (Security Level)
|
||||
#### SPI1 (Security Level)
|
||||
|
||||
| Value | Security | Ciphering | Counter |
|
||||
SPI1 bit layout (TS 102 225 §5.1.1): `b8–b6` padding, `b5–b4` counter, `b3` ciphering, `b2–b1` RC/CC/DS.
|
||||
|
||||
| Value | Security | Ciphering | Counter (b5 b4) |
|
||||
|---|---|---|---|
|
||||
| 00 | None | No | None |
|
||||
| 01 | RC | No | None |
|
||||
| 02 | CC/MAC | No | None |
|
||||
| 06 | CC/MAC | Yes | None |
|
||||
| 12 | CC/MAC | No | Available |
|
||||
| 16 | CC/MAC | Yes | Available |
|
||||
| 22 | CC/MAC | No | Check higher |
|
||||
| 26 | CC/MAC | Yes | Check higher |
|
||||
| 32 | CC/MAC | No | Check +1 |
|
||||
| 36 | CC/MAC | Yes | Check +1 |
|
||||
| 00 | None | No | 00 none |
|
||||
| 01 | RC | No | 00 none |
|
||||
| 02 | CC/MAC | No | 00 none |
|
||||
| 06 | CC/MAC | Yes | 00 none |
|
||||
| 0A | CC/MAC | No | 01 available |
|
||||
| 0E | CC/MAC | Yes | 01 available |
|
||||
| 12 | CC/MAC | No | 10 higher |
|
||||
| 16 | CC/MAC | Yes | 10 higher |
|
||||
| 1A | CC/MAC | No | 11 +1 |
|
||||
| 1E | CC/MAC | Yes | 11 +1 |
|
||||
|
||||
### SPI2 (PoR settings)
|
||||
> **AES requires `b5 b4 = 10` (higher) or `11` (+1)** per TS 102 225 §5.1.2 and §5.1.3.1.
|
||||
> The 3DES values `00/01/02/06` (no counter) remain valid for 3DES only.
|
||||
|
||||
#### SPI2 (PoR settings)
|
||||
|
||||
| Value | Mode | Security | Cipher |
|
||||
|---|---|---|---|
|
||||
@@ -309,100 +405,152 @@ Assembles secured packets per ETSI TS 102 225.
|
||||
| 02 | PoR on error | None | No |
|
||||
| 06 | PoR on error | RC | No |
|
||||
|
||||
### Crypto
|
||||
#### Crypto
|
||||
|
||||
- **3DES-CBC** encryption (zero ICV), supporting 8, 16, and 24 byte keys
|
||||
- **Retail MAC** (ISO 9797-1 MAC algorithm 3) for cryptographic checksum
|
||||
- **3DES-CBC** encryption (zero ICV), supporting 8, 16, and 24 byte keys — deprecated since Rel-18, still supported for backwards compatibility
|
||||
- **AES-CBC** encryption (zero ICV, zero-padded to 16), supporting 16, 24, and 32 byte keys (TS 102 225 §5.1.2, KIc `x2`)
|
||||
- **Retail MAC** (ISO 9797-1 MAC algorithm 3) for the DES/3DES cryptographic checksum
|
||||
- **AES-CMAC** (NIST SP 800-38B, truncated to 8 octets) for the AES cryptographic checksum (TS 102 225 §5.1.3.1, KID `x2`)
|
||||
- Padding byte configurable (`00` per TS 102 225 default, or `FF`)
|
||||
|
||||
### References
|
||||
#### PoR (Proof of Reception)
|
||||
|
||||
- ETSI TS 102 225 V13.0.0: Secured packet structure for UICC based applications
|
||||
PoR confirms the card received and executed the secured packet. Two modes:
|
||||
|
||||
| SPI2 (bit 5) | Mode | Description |
|
||||
|---|---|---|
|
||||
| `0x00` | Delivery PoR | PoR is returned in the ENVELOPE response SW+data |
|
||||
| `0x20` | Submit PoR | PoR is sent back as an SMS-SUBMIT via a proactive FETCH command |
|
||||
|
||||
Delivery PoR (SPI2 `01`) is simpler — the card returns the PoR directly in the ENVELOPE response. Submit PoR (SPI2 `21`) is used when the card cannot respond inline (e.g. during ELF operations where the ENVELOPE response space is limited).
|
||||
|
||||
#### References
|
||||
|
||||
- ETSI TS 102 225 V18.1.0: Secured packet structure for UICC based applications
|
||||
- ETSI TS 102 226: Remote APDU structure for UICC based applications
|
||||
- ISO 9797-1: MAC algorithms
|
||||
- NIST SP 800-38B: CMAC
|
||||
|
||||
### Cards
|
||||
|
||||
Stores saved card configurations (presets). Each preset stores the cryptographic keys, SPI settings, TAR, and replay counter needed for SCP80 operations.
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| SPI1 / SPI2 | Security level and PoR settings |
|
||||
| KIc / KID key | Encryption and MAC key hex |
|
||||
| KIc / KID index | Key version number |
|
||||
| TAR | Toolkit Application Reference (3 bytes) |
|
||||
| Counter (CNTR) | 10-digit hex replay counter, auto-incremented after each successful SCP80 send |
|
||||
|
||||
**Add a card:** fill in the name, SPI1/SPI2, KIc/KID keys and indices, TAR, and click **Add**. The card appears in the list and becomes available in the RAM tab's **Card preset** dropdown.
|
||||
|
||||
**Edit a card:** click a card in the list, modify fields, click **Save**.
|
||||
|
||||
**Delete a card:** select a card, click **Delete**. Removes the preset from `localStorage`.
|
||||
|
||||
**Counter:** the 10-digit hex counter (CNTR) is auto-incremented after each successful SCP80 send (both manual Secured Packet sends and RAM operations). The updated counter is saved back to the preset automatically.
|
||||
|
||||
### RAM
|
||||
|
||||
All RAM operations are delivered as SCP80 secured packets (ETSI TS 102 225) via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES) for secure transport.
|
||||
|
||||
Select a saved card configuration from the **Card preset** dropdown. If no preset is selected, the RAM tab warns and refuses to execute.
|
||||
|
||||
The RAM subtab offers two operations selected from the **Operation** dropdown:
|
||||
|
||||
| Operation | Description |
|
||||
|---|---|
|
||||
| **Explore Card (all GP data)** | 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 **Delete** buttons. |
|
||||
| **Install Package (.cap file)** | Sends a `.cap` file to the card via the server: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. |
|
||||
|
||||
#### Explorer View
|
||||
|
||||
After "Explore Card" runs, the explorer view displays:
|
||||
|
||||
- **ISD** — AID, lifecycle, privileges (no delete; the ISD cannot be removed)
|
||||
- **Applications** — AID, lifecycle, privileges, associated ELF/SD. Each has a **Delete** button (GP `DELETE` by AID).
|
||||
- **Executable Load Files** — AID, lifecycle, version, module AIDs. Each has **Delete** (ELF only) and **Delete All** (cascade: ELF + modules + installed Applications, P2=0x80) buttons.
|
||||
|
||||
Delete confirms via a browser prompt before sending the GP `DELETE` command via SCP80. The explorer auto-refreshes after a successful deletion.
|
||||
|
||||
---
|
||||
|
||||
## Conversion (SIM/USIM sidebars)
|
||||
|
||||
Value encoding conversions embedded in the SIM RFM and USIM RFM tabs.
|
||||
|
||||
### IMSI → EF.IMSI
|
||||
|
||||
Per TS 31.102 §4.2.3. Encodes a 15-digit IMSI into the 9-byte EF.IMSI format:
|
||||
- Byte 0: number of subsequent bytes (8)
|
||||
- Odd/even indicator nibble in the last byte
|
||||
- BCD digits, swapped nibble pairs per identity
|
||||
|
||||
Input: 15 decimal digits. Output: 18 hex characters.
|
||||
|
||||
### MSISDN → BCD
|
||||
|
||||
Strips leading `+`, pads odd length with `f`, swaps nibble pairs.
|
||||
|
||||
### ICCID → hex
|
||||
|
||||
Swaps nibble pairs of the ICCID string.
|
||||
|
||||
### Provider Name → SPN
|
||||
|
||||
Per 3GPP TS 31.102 §4.2.5 (EF_SPN). Three encoding paths:
|
||||
|
||||
1. **GSM 7-bit packed** (all chars in GSM 7-bit default alphabet): prefix `01`, DCS byte (spare bits), packed septets, 0xFF padding to 16 bytes.
|
||||
2. **UCS2 non-BMP** (emoji / chars > U+FFFF): prefix `00`, DCS `80`, UTF-16BE, 0xFF padding to 16 bytes.
|
||||
3. **UCS2 BMP non-GSM7** (Cyrillic, etc.): prefix `00`, DCS `81`, base byte, per-char offsets, 0xFF padding to 16 bytes.
|
||||
|
||||
GSM 7-bit alphabet per 3GPP TS 23.038. Full extension table supported.
|
||||
|
||||
### PLMN → EF_PLMNsel / PLMNwAcT
|
||||
|
||||
Per TS 31.102 §4.2.3. 3-byte BCD encoding for PLMN, plus optional 2-byte Access Technology selector.
|
||||
|
||||
### Nibble swap
|
||||
|
||||
Swaps nibble pairs of an even-length hex string.
|
||||
|
||||
### References
|
||||
|
||||
- 3GPP TS 31.102: Characteristics of the USIM Application
|
||||
- 3GPP TS 23.038: Alphabets and language information
|
||||
- ETSI TS 102 225: Secured packet structure for (U)SIM toolkit
|
||||
- pySim: enc_imsi() implementation
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Card Reader (pySim integration)
|
||||
|
||||
Connects to a local [pysim-otaman-server](https://github.com/anttro/pysim-otaman-server) for live card operations.
|
||||
Connects to the bundled [`pysim-otaman-server`](pysim_otaman_server/) for live card operations.
|
||||
|
||||
> **Browser restriction:** when the PWA is served from a public HTTPS host, reaching the local server (`http://127.0.0.1:8080`) requires two things: the server must send `Access-Control-Allow-Private-Network: true` (pysim-otaman-server ≥ 1.6.1 does this automatically), and the browser must be allowed to access the local network — in Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site (or accept the permission prompt). Without the browser permission, the request to `127.0.0.1` is blocked before any preflight is sent.
|
||||
|
||||
### File Browser
|
||||
|
||||
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:
|
||||
**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:
|
||||
|
||||
- **No-data events** (User Activity, Idle Screen, etc.) — single-click confirmation
|
||||
- **Location Status** — dropdown for Normal / Limited / No service
|
||||
@@ -413,13 +561,9 @@ interaction:
|
||||
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, and PROVIDE LOCAL INFORMATION.
|
||||
|
||||
**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):
|
||||
**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 |
|
||||
|------|--------------|
|
||||
@@ -434,13 +578,7 @@ inline decode/encode forms (▶ toggle):
|
||||
| 0A | Battery charge (%) |
|
||||
| 0E | Multiple Access Technologies (comma-list) |
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## PWA
|
||||
|
||||
@@ -451,6 +589,72 @@ OTAMan is a Progressive Web App and can be installed for offline use. Use the **
|
||||
|
||||
## 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/*`.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Python 3.8+** with `pip`, and **Git**
|
||||
- **Smart card reader** (PC/SC or serial/FTDI) — PC/SC is preferable (`pcsc-lite` + `ccid` on Linux)
|
||||
- **Windows** — use Python 3.10–3.13 (3.13 recommended): `pyscard` ships precompiled wheels for these versions. On 3.9 / 3.14 it builds from source (needs MSVC C++ Build Tools). The SMPP bridge (`smpp.twisted3`) is intentionally skipped on Windows.
|
||||
|
||||
### Scripts
|
||||
|
||||
| Script | What it does |
|
||||
|--------|-------------|
|
||||
| `setup.sh` / `setup.bat` | Creates `.venv/`, installs pysim and the server. Run once after cloning. |
|
||||
| `start.sh` / `start.bat` | Starts the server from the venv (serves the PWA + API on `:8080`). |
|
||||
|
||||
`start.sh` auto-detects the reader (PC/SC if `pcscd` is running, else `/dev/ttyUSB0`); `start.bat` always uses `-p 0` (PC/SC is built into Windows). If no reader is found the server still starts ("Reader: none") — initialize the card later via the **Equip** button.
|
||||
|
||||
### Manual installation
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS (Windows: .venv\Scripts\activate)
|
||||
pip install git+https://github.com/osmocom/pysim.git
|
||||
pip install -e . # editable — serves frontend/ from the source tree
|
||||
pysim-otaman-server --http-port 8080
|
||||
```
|
||||
|
||||
### CLI options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--http-host` | Bind address (default: `127.0.0.1`) |
|
||||
| `--http-port` | TCP port (default: `8080`) |
|
||||
| `--web-dir` | Directory with PWA static files (default: `<repo>/frontend`) |
|
||||
| `-p` / `--pcsc-device` | PC/SC reader slot number |
|
||||
| `-d` / `--device` | Serial device path |
|
||||
| `--no-card-init` | Skip card init to preserve the CAT session (no file manager) |
|
||||
| `--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; `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
|
||||
|
||||
- **"Failed to establish context: Access denied"** — `pcscd` isn't running or the user lacks permission: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
|
||||
- **"device file /dev/ttyUSB0 does not exist"** — no serial reader; connect a USB reader or pass `-d` explicitly. The server still starts without a reader.
|
||||
|
||||
### API reference
|
||||
|
||||
See [docs/api.md](docs/api.md) for the full endpoint reference.
|
||||
|
||||
## Theme
|
||||
|
||||
Dark theme is supported. The app follows the OS preference on first visit, and a manual toggle button (🌙/☀️) at the top-right corner persists the choice in `localStorage`.
|
||||
|
||||
## Localisation
|
||||
|
||||
@@ -1,31 +1,49 @@
|
||||
# OTAMan — APDU Helper & Secured Packet Builder, SIM OTA в PWA
|
||||
# OTAMan — SIM OTA toolkit: PWA + локальный сервер карт
|
||||
|
||||
Автономный offline-инструмент на HTML/JS для создания APDU-команд для SIM, USIM и GlobalPlatform RAM, сборки защищённых пакетов по ETSI TS 102 225 и построения BER-TLV скриптов по ETSI TS 102 226.
|
||||
OTAMan — автономный offline-PWA (HTML/JS) для создания APDU-команд (SIM, USIM, GlobalPlatform RAM), сборки защищённых пакетов SCP80 по ETSI TS 102 225 и построения Expanded Remote Application data format APDU по ETSI TS 102 226. В комплекте — [`pysim-otaman-server`](pysim_otaman_server/) — локальный HTTP-сервер поверх pySim для работы с картой: файловый менеджер, сырые APDU, меню SIM Toolkit и доставка OTA.
|
||||
|
||||
Откройте `index.html` в любом современном браузере. Сервер не требуется.
|
||||
**Демо:** [otaman.atroshin.ru](https://otaman.atroshin.ru) — только PWA, для экспериментов. Для функций картридера установите сервер (ниже).
|
||||
|
||||
**Демо:** [otaman.atroshin.ru](https://otaman.atroshin.ru)
|
||||
## Быстрый старт
|
||||
|
||||
**Только PWA (клиентские функции):** откройте `frontend/index.html` в любом браузере или раздайте `frontend/` любым статическим сервером. Python не нужен.
|
||||
|
||||
**Полная установка (PWA + сервер карт):**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
./setup.sh # или setup.bat в Windows — создаёт .venv, ставит pysim + сервер
|
||||
./start.sh # или start.bat — запускает сервер (он же раздаёт PWA)
|
||||
```
|
||||
|
||||
Затем откройте http://127.0.0.1:8080 — интерфейс и API на одном origin, поэтому CORS и разрешения браузера не нужны.
|
||||
|
||||
## Сборка
|
||||
|
||||
Для стилей используется Tailwind CSS. После клонирования пересоберите CSS:
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Интерфейс
|
||||
|
||||
Шесть вкладок, каждая с формой и кнопкой «Generate APDU».
|
||||
Пять вкладок: **Remote APDU**, **SCP80**, **Profiler**, **Card reader** и **Phone simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
|
||||
|
||||
---
|
||||
|
||||
## Вкладка SIM RFM
|
||||
## Вкладка Remote APDU
|
||||
|
||||
Построение команд APDU (C-APDU). Семь подвкладок для разных поколений карт, наборов команд и инструментов разбора: **SIM RFM**, **USIM RFM**, **Expanded Script**, **RAM/GP**, **HTTP OTA**, **Разбор C-APDU** и **«Парсер ответов»**.
|
||||
|
||||
### SIM RFM
|
||||
|
||||
CLA = `A0` (GSM 11.11 / ISO 7816-4).
|
||||
|
||||
### Команды
|
||||
#### Команды
|
||||
|
||||
| Команда | INS | Описание |
|
||||
|---|---|---|
|
||||
@@ -40,7 +58,7 @@ CLA = `A0` (GSM 11.11 / ISO 7816-4).
|
||||
| VERIFY PIN | 20 | Проверка PIN1 или PIN2 |
|
||||
| CHANGE PIN | 24 | Смена PIN1 или PIN2 |
|
||||
|
||||
### Методы SELECT
|
||||
#### Методы SELECT
|
||||
|
||||
| Метод | P1 | P2 | Ввод |
|
||||
|---|---|---|---|
|
||||
@@ -49,46 +67,38 @@ CLA = `A0` (GSM 11.11 / ISO 7816-4).
|
||||
| By DF name / AID | 04 | 00 | AID |
|
||||
| ADF RFM chain | 00 | 00 | FID через запятую |
|
||||
|
||||
### Опции
|
||||
#### Опции
|
||||
|
||||
- **Start with SELECT** — добавить SELECT перед командой.
|
||||
- **Selection mode (P2)** — для record-команд: Absolute (04), Next (06), Previous (02).
|
||||
- **Record size** — дополнить/обрезать данные до указанного размера.
|
||||
- **Allow P1/P2 editing** — ручное редактирование P1/P2.
|
||||
|
||||
### Боковая панель конвертации
|
||||
|
||||
Поддерживает IMSI, MSISDN, ICCID, SPN, PLMN, Nibble swap.
|
||||
|
||||
### Ссылки
|
||||
#### Ссылки
|
||||
|
||||
- ISO/IEC 7816-4: Organization, security and commands for interchange
|
||||
- ETSI TS 102 226: Remote APDU structure for UICC based applications
|
||||
- GSM 11.11: SIM-ME Interface
|
||||
|
||||
---
|
||||
|
||||
## Вкладка USIM RFM
|
||||
### USIM RFM
|
||||
|
||||
CLA = `00` (ETSI TS 102 221). Те же команды, что и SIM, но SELECT использует P1=09, P2=0C.
|
||||
|
||||
### Ссылки
|
||||
#### Ссылки
|
||||
|
||||
- ETSI TS 102 221: UICC-Terminal Interface
|
||||
- ETSI TS 102 226: Remote APDU structure
|
||||
|
||||
---
|
||||
|
||||
## Вкладка BER-TLV
|
||||
### Expanded Script
|
||||
|
||||
Построение Expanded Remote Application data format по ETSI TS 102 226 §5.2.1.
|
||||
|
||||
### Формат
|
||||
#### Формат
|
||||
|
||||
- **Definite (AA)**: `AA` + длина + Command TLV
|
||||
- **Indefinite (AE)**: `AE` + `80` + Command TLV + `00 00`
|
||||
|
||||
### Command TLV
|
||||
#### Command TLV
|
||||
|
||||
| Тип | Тег | Описание |
|
||||
|---|---|---|
|
||||
@@ -97,25 +107,23 @@ CLA = `00` (ETSI TS 102 221). Те же команды, что и SIM, но SELE
|
||||
| Error Action | 82 | Proactive-команда при ошибке |
|
||||
| Script Chaining | 83 | Данные для многопакетных скриптов |
|
||||
|
||||
### Сборщик Immediate Action
|
||||
#### Сборщик Immediate Action
|
||||
|
||||
- **Action indicator**: `81` / `82`
|
||||
- **Proactive command**: REFRESH, DISPLAY TEXT, PLAY TONE
|
||||
- **Custom hex**: ручной ввод
|
||||
|
||||
### Ссылки
|
||||
#### Ссылки
|
||||
|
||||
- ETSI TS 102 226 §5.2.1
|
||||
- ETSI TS 102 223: Card Application Toolkit
|
||||
- ETSI TS 101 220: BER-TLV tag assignments
|
||||
|
||||
---
|
||||
### RAM/GP
|
||||
|
||||
## Вкладка RAM
|
||||
CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление содержимым карты через SCP80.
|
||||
|
||||
CLA = `80` (GlobalPlatform v2.3.1). Команды удалённого управления приложениями.
|
||||
|
||||
### Команды
|
||||
#### Справочник GP-команд
|
||||
|
||||
| Команда | INS | P1 | Описание |
|
||||
|---|---|---|---|
|
||||
@@ -133,7 +141,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Команды удалённого упра
|
||||
| EXTERNAL AUTHENTICATE | 82 | 00 | Аутентификация SCP |
|
||||
| INTERNAL AUTHENTICATE | 88 | 00 | Challenge-response |
|
||||
|
||||
### Привилегии (INSTALL)
|
||||
#### Привилегии (INSTALL [for install])
|
||||
|
||||
Три байта привилегий по GP Spec Tables 11-7, 11-8, 11-9.
|
||||
|
||||
@@ -164,7 +172,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Команды удалённого упра
|
||||
|---|---|
|
||||
| b8 | Receipt Generation |
|
||||
|
||||
### Параметры SIM/UICC Toolkit
|
||||
#### Параметры SIM/UICC Toolkit
|
||||
|
||||
- **Tag `CA`** (SIM Toolkit): Priority, Timers, Text Length, Menu Entries, Positions, Channels, MSL, TAR, Access Domain
|
||||
- **Tag `80`** (UICC Toolkit, внутри `EA`): те же поля без Access Domain
|
||||
@@ -179,7 +187,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Команды удалённого упра
|
||||
| 16 | RC/DS/CC + MAC + Cipher |
|
||||
| 19 | RC/DS/CC + MAC + Cipher + DS |
|
||||
|
||||
### GET STATUS P1
|
||||
#### GET STATUS P1
|
||||
|
||||
| Значение | Описание |
|
||||
|---|---|
|
||||
@@ -188,7 +196,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Команды удалённого упра
|
||||
| 20 | Executable Load Files |
|
||||
| 10 | ELF и модули |
|
||||
|
||||
### GET STATUS P2
|
||||
#### GET STATUS P2
|
||||
|
||||
| Значение | Описание |
|
||||
|---|---|
|
||||
@@ -197,7 +205,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Команды удалённого упра
|
||||
| 00 | Первые/все, старый формат (deprecated) |
|
||||
| 02 | Следующие, старый формат (deprecated) |
|
||||
|
||||
### GET DATA теги
|
||||
#### GET DATA теги
|
||||
|
||||
| Тег | Объект данных |
|
||||
|---|---|
|
||||
@@ -215,14 +223,14 @@ CLA = `80` (GlobalPlatform v2.3.1). Команды удалённого упра
|
||||
| 7F21 | Certificate (SD public key) |
|
||||
| 5031 | Certificate info (EF.OD) |
|
||||
|
||||
### DELETE P1
|
||||
#### DELETE P1
|
||||
|
||||
| Значение | Описание |
|
||||
|---|---|
|
||||
| 00 | Только AID |
|
||||
| 80 | AID и связанные объекты |
|
||||
|
||||
### STORE DATA P1
|
||||
#### STORE DATA P1
|
||||
|
||||
| Значение | Описание |
|
||||
|---|---|
|
||||
@@ -231,24 +239,97 @@ CLA = `80` (GlobalPlatform v2.3.1). Команды удалённого упра
|
||||
| 80 | Последний блок, с шифрованием |
|
||||
| C0 | Ещё блоки, с шифрованием |
|
||||
|
||||
### SET STATUS
|
||||
#### SET STATUS
|
||||
|
||||
**P1:** 80 = ISD, 40 = Приложение или SSD, 60 = SD и его приложения
|
||||
|
||||
**P2:** 00 = Разблокировать, 80 = Заблокировать (LOCKED)
|
||||
|
||||
### Ссылки
|
||||
#### Ссылки
|
||||
|
||||
- GlobalPlatform Card Specification v2.3.1
|
||||
- ETSI TS 102 226 §8.2.1.3.2: Параметры SIM/UICC Toolkit
|
||||
|
||||
### Конвертация (боковые панели SIM/USIM)
|
||||
|
||||
#### IMSI → EF.IMSI
|
||||
|
||||
15-значный IMSI → 9 байт EF.IMSI.
|
||||
|
||||
#### MSISDN → BCD
|
||||
|
||||
Удаление `+`, добавление `f`, обмен полубайтов.
|
||||
|
||||
#### ICCID → hex
|
||||
|
||||
Обмен полубайтов строки ICCID.
|
||||
|
||||
#### Provider Name → SPN
|
||||
|
||||
По 3GPP TS 31.102 §4.2.5. Три варианта кодирования:
|
||||
1. GSM 7-bit packed
|
||||
2. UCS2 non-BMP
|
||||
3. UCS2 BMP non-GSM7
|
||||
|
||||
#### PLMN → EF_PLMNsel / PLMNwAcT
|
||||
|
||||
3-байтное BCD-кодирование + опциональный Access Technology.
|
||||
|
||||
#### Nibble swap
|
||||
|
||||
Обмен полубайтов hex-строки.
|
||||
|
||||
#### Ссылки
|
||||
|
||||
- 3GPP TS 31.102
|
||||
- 3GPP TS 23.038
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
## Вкладка Secured Packet
|
||||
|
||||
Сборка защищённых пакетов по ETSI TS 102 225.
|
||||
### Парсер ответов
|
||||
|
||||
### Структура пакета
|
||||
Декодирование ответа команды: выберите отправленную команду, введите 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.
|
||||
|
||||
### Secured Packet
|
||||
|
||||
Сборка защищённых пакетов SCP80 по ETSI TS 102 225.
|
||||
|
||||
#### Структура пакета
|
||||
|
||||
| Поле | Размер | Описание |
|
||||
|---|---|---|
|
||||
@@ -265,22 +346,27 @@ CLA = `80` (GlobalPlatform v2.3.1). Команды удалённого упра
|
||||
| RC/CC/DS | 8 | Контрольная сумма / MAC |
|
||||
| Secured Data | переменная | APDU (с шифрованием при необходимости) |
|
||||
|
||||
### SPI1 (Уровень безопасности)
|
||||
#### SPI1 (Уровень безопасности)
|
||||
|
||||
| Значение | Безопасность | Шифрование | Счётчик |
|
||||
Битовое поле SPI1 (TS 102 225 §5.1.1): `b8–b6` — паддинг, `b5–b4` — счётчик, `b3` — шифрование, `b2–b1` — RC/CC/DS.
|
||||
|
||||
| Значение | Безопасность | Шифрование | Счётчик (b5 b4) |
|
||||
|---|---|---|---|
|
||||
| 00 | Нет | Нет | Нет |
|
||||
| 01 | RC | Нет | Нет |
|
||||
| 02 | CC/MAC | Нет | Нет |
|
||||
| 06 | CC/MAC | Да | Нет |
|
||||
| 12 | CC/MAC | Нет | Available |
|
||||
| 16 | CC/MAC | Да | Available |
|
||||
| 22 | CC/MAC | Нет | Check higher |
|
||||
| 26 | CC/MAC | Да | Check higher |
|
||||
| 32 | CC/MAC | Нет | Check +1 |
|
||||
| 36 | CC/MAC | Да | Check +1 |
|
||||
| 00 | Нет | Нет | 00 нет |
|
||||
| 01 | RC | Нет | 00 нет |
|
||||
| 02 | CC/MAC | Нет | 00 нет |
|
||||
| 06 | CC/MAC | Да | 00 нет |
|
||||
| 0A | CC/MAC | Нет | 01 available |
|
||||
| 0E | CC/MAC | Да | 01 available |
|
||||
| 12 | CC/MAC | Нет | 10 higher |
|
||||
| 16 | CC/MAC | Да | 10 higher |
|
||||
| 1A | CC/MAC | Нет | 11 +1 |
|
||||
| 1E | CC/MAC | Да | 11 +1 |
|
||||
|
||||
### SPI2 (PoR)
|
||||
> **AES требует `b5 b4 = 10` (higher) или `11` (+1)** согласно TS 102 225 §5.1.2 и §5.1.3.1.
|
||||
> Значения `00/01/02/06` (без счётчика) допустимы только для 3DES.
|
||||
|
||||
#### SPI2 (PoR)
|
||||
|
||||
| Значение | Режим |
|
||||
|---|---|
|
||||
@@ -293,58 +379,177 @@ CLA = `80` (GlobalPlatform v2.3.1). Команды удалённого упра
|
||||
| 02 | PoR on error, no security |
|
||||
| 06 | PoR on error, RC |
|
||||
|
||||
### Крипто
|
||||
#### Крипто
|
||||
|
||||
- **3DES-CBC** шифрование, ключи 8/16/24 байт
|
||||
- **Retail MAC** (ISO 9797-1 MAC algorithm 3)
|
||||
- **3DES-CBC** шифрование, ключи 8/16/24 байт — устарело с Rel-18, но поддерживается для обратной совместимости
|
||||
- **AES-CBC** шифрование (нулевой ICV, дополнение нулями до 16), ключи 16/24/32 байта (TS 102 225 §5.1.2, KIc `x2`)
|
||||
- **Retail MAC** (ISO 9797-1 MAC algorithm 3) для DES/3DES
|
||||
- **AES-CMAC** (NIST SP 800-38B, усечённый до 8 октетов) для AES (TS 102 225 §5.1.3.1, KID `x2`)
|
||||
- Padding byte: `00` (по умолчанию) или `FF`
|
||||
|
||||
### Ссылки
|
||||
#### PoR (Proof of Reception)
|
||||
|
||||
- ETSI TS 102 225 V13.0.0
|
||||
PoR подтверждает, что карта получила и выполнила защищённый пакет. Два режима:
|
||||
|
||||
| SPI2 (бит 5) | Режим | Описание |
|
||||
|---|---|---|
|
||||
| `0x00` | Delivery PoR | PoR возвращается в ответе ENVELOPE (SW+данные) |
|
||||
| `0x20` | Submit PoR | PoR отправляется обратно как SMS-SUBMIT через прокоманду FETCH |
|
||||
|
||||
Delivery PoR (SPI2 `01`) проще — карта возвращает PoR напрямую в ответе ENVELOPE. Submit PoR (SPI2 `21`) используется, когда карта не может ответить inline (ограничено пространство ответа ENVELOPE).
|
||||
|
||||
#### Ссылки
|
||||
|
||||
- ETSI TS 102 225 V18.1.0
|
||||
- ETSI TS 102 226
|
||||
- ISO 9797-1
|
||||
- NIST SP 800-38B (CMAC)
|
||||
|
||||
### Cards
|
||||
|
||||
Пилл **Cards** управляет сохранёнными конфигурациями карт (пресеты). Каждый пресет хранит криптографические ключи, настройки TAR и счётчик повторов для SCP80-операций.
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| SPI1 / SPI2 | Уровень безопасности и настройки PoR |
|
||||
| Ключ KIc / KID | Hex ключи шифрования и MAC |
|
||||
| Индекс KIc / KID | Номер версии ключа |
|
||||
| TAR | Toolkit Application Reference (3 байта) |
|
||||
| Счётчик (CNTR) | 10-значный hex счётчик повторов, автоматически увеличивается после каждой успешной отправки SCP80 |
|
||||
|
||||
**Добавить карту:** заполните имя, SPI1/SPI2, ключи KIc/KID и их индексы, TAR, нажмите **Add**. Карта появится в списке и станет доступна в выпаданом списке **Card preset** на RAM-вкладке.
|
||||
|
||||
**Редактировать карту:** выберите карту в списке, измените поля, нажмите **Save**.
|
||||
|
||||
**Удалить карту:** выберите карту, нажмите **Delete**. Удаляет пресет из `localStorage`.
|
||||
|
||||
**Счётчик:** 10-значный hex-счётчик (CNTR) автоматически увеличивается после каждой успешной отправки SCP80 (ручные отправки Secured Packet и RAM-операции). Обновлённый счётчик автоматически сохраняется обратно в пресет.
|
||||
|
||||
### RAM
|
||||
|
||||
Все операции RAM отправляются как защищённые пакеты SCP80 (ETSI TS 102 225) через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES) для безопасной транспортировки.
|
||||
|
||||
Выберите сохранённую конфигурацию карты из выпадающего списка **Card preset**. Если пресет не выбран, RAM-вкладка предупреждает и отказывается выполнять.
|
||||
|
||||
В RAM-подвкладке доступны две операции через выпадающий список **Operation**:
|
||||
|
||||
| Операция | Описание |
|
||||
|---|---|
|
||||
| **Explore Card (all GP data)** | Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками **Delete** для каждого элемента. |
|
||||
| **Install Package (.cap file)** | Отправка `.cap` файла на карту через сервер: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. |
|
||||
|
||||
#### Обзор карты (Explorer View)
|
||||
|
||||
После выполнения "Explore Card" отображается:
|
||||
|
||||
- **ISD** — AID, жизненный цикл, привилегии (без удаления; ISD нельзя удалить)
|
||||
- **Приложения** — AID, жизненный цикл, привилегии, связанный ELF/SD. Каждое имеет кнопку **Delete** (GP `DELETE` по AID).
|
||||
- **Executable Load Files** — AID, жизненный цикл, версии, AID модулей. Каждый имеет **Delete** (только ELF) и **Delete All** (каскадное: ELF + модули + установленные приложения, P2=0x80).
|
||||
|
||||
Удаление подтверждается через диалог браузера перед отправкой команды GP `DELETE` через SCP80. Обзор автоматически обновляется после успешного удаления.
|
||||
|
||||
---
|
||||
|
||||
## Конвертация (боковые панели SIM/USIM)
|
||||
## Card Reader (интеграция с pySim)
|
||||
|
||||
### IMSI → EF.IMSI
|
||||
Подключение к встроенному [`pysim-otaman-server`](pysim_otaman_server/) для работы с картой.
|
||||
|
||||
15-значный IMSI → 9 байт EF.IMSI.
|
||||
> **Ограничение браузера:** если PWA раздаётся с публичного HTTPS-хоста, для доступа к локальному серверу (`http://127.0.0.1:8080`) нужны два условия: сервер должен отправлять `Access-Control-Allow-Private-Network: true` (pysim-otaman-server ≥ 1.6.1 делает это автоматически), и браузеру должно быть разрешено обращаться к локальной сети — в Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → разрешить сайт (или подтвердить запрос). Без разрешения браузера запрос к `127.0.0.1` блокируется ещё до отправки preflight.
|
||||
|
||||
### MSISDN → BCD
|
||||
### Файловый менеджер
|
||||
|
||||
Удаление `+`, добавление `f`, обмен полубайтов.
|
||||
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). Клик для чтения содержимого.
|
||||
|
||||
### ICCID → hex
|
||||
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы над деревом, выбор сохраняется в `localStorage`)
|
||||
- **Read** — чтение файла (автоопределение transparent/record)
|
||||
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
|
||||
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
|
||||
- При выборе файла над содержимым показываются FID, тип файла, размер / структура записей и декодированный FCI
|
||||
- Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
||||
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым
|
||||
|
||||
Обмен полубайтов строки ICCID.
|
||||
### Подсказки команд
|
||||
|
||||
### Provider Name → SPN
|
||||
|
||||
По 3GPP TS 31.102 §4.2.5. Три варианта кодирования:
|
||||
1. GSM 7-bit packed
|
||||
2. UCS2 non-BMP
|
||||
3. UCS2 BMP non-GSM7
|
||||
|
||||
### PLMN → EF_PLMNsel / PLMNwAcT
|
||||
|
||||
3-байтное BCD-кодирование + опциональный Access Technology.
|
||||
|
||||
### Nibble swap
|
||||
|
||||
Обмен полубайтов hex-строки.
|
||||
|
||||
### Ссылки
|
||||
|
||||
- 3GPP TS 31.102
|
||||
- 3GPP TS 23.038
|
||||
- ETSI TS 102 225
|
||||
- pySim: enc_imsi()
|
||||
Введите имя команды в **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** в списке профайлера
|
||||
2. Введите путь (например, `3F00/6F46`) и псевдоним (например, `EF.SPN`)
|
||||
3. Нажмите **Add** — файл появится в дереве курсивом (непроверенный)
|
||||
4. Кнопка **Edit** загружает запись в форму (кнопка становится **Save**, появляется **Cancel**), **Delete** удаляет запись без подтверждения
|
||||
5. Кликните для проверки существования — при успехе работает как обычный файл
|
||||
|
||||
Пользовательские файлы сохраняются в `localStorage`. Экспорт/импорт в JSON для обмена.
|
||||
|
||||
## Симулятор телефона
|
||||
|
||||
Вкладка **«Симулятор телефона»** обеспечивает взаимодействие с 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
|
||||
- **Network Rejection** — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля локации (MCC, MNC, LAC, RAC, TAC), доступные технологии, 53-позиционный выпадающий список причин отказа (EMM, GMM, 5GMM, LU)
|
||||
|
||||
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор.
|
||||
|
||||
**Конфигурация TR: словарь PLI** — редактируемые hex-значения для всех 22 квалификаторов PROVIDE LOCAL INFORMATION (TS 102 223 + TS 131 111). 10 квалификаторов имеют встроенные формы декодирования/кодирования:
|
||||
|
||||
| Код | Декодированные поля |
|
||||
|------|--------------|
|
||||
| 00 | MCC, MNC, LAC/TAC |
|
||||
| 01 | IMEI (15 цифр) |
|
||||
| 03 | Дата, время, TZ |
|
||||
| 04 | Язык (2-символьный код) |
|
||||
| 05 | ME Status, Timing Advance |
|
||||
| 06 | Access Technology (выпадающий список) |
|
||||
| 08 | IMEISV (16 цифр) |
|
||||
| 09 | Search Mode (Auto/Manual) |
|
||||
| 0A | Battery charge (%) |
|
||||
| 0E | Multiple Access Technologies (список через запятую) |
|
||||
|
||||
Значения сохраняются на сервере до перезапуска. Apply → hex обновляется; Save → POST на сервер.
|
||||
|
||||
## PWA
|
||||
|
||||
OTAMan — Progressive Web App. Можно установить для offline-использования через кнопку **INSTALL PWA** или через браузер.
|
||||
@@ -358,7 +563,7 @@ OTAMan — Progressive Web App. Можно установить для offline-
|
||||
|
||||
## Локализация
|
||||
|
||||
Интерфейс на английском с поддержкой русского языка. Язык определяется из `navigator.language`. Кнопка переключения (EN/RU) в заголовке сохраняет выбор в `localStorage`.
|
||||
Интерфейс на английском с поддержкой русского языка. Язык определяется из `navigator.language`. Кнопка переключения (EN/RU) в заголовке сохраняет выбор в `localStorage`. При переключении языка динамические представления (списки профилей, отчёты проверок, снимки, карты, proactive) перерисовываются.
|
||||
|
||||
## Совместимость версий
|
||||
|
||||
@@ -372,29 +577,58 @@ PWA проверяет версию сервера при подключении
|
||||
|
||||
---
|
||||
|
||||
## Card Reader (интеграция с pySim)
|
||||
## Сервер (pysim-otaman-server)
|
||||
|
||||
Подключение к локальному [pysim-otaman-server](https://github.com/anttro/pysim-otaman-server) для работы с картой.
|
||||
Встроенный Python-сервер оборачивает [pySim](https://osmocom.org/projects/pysim/wiki) и раздаёт как PWA (из `frontend/`), так и JSON API по `/api/*`.
|
||||
|
||||
### Файловый менеджер
|
||||
### Требования
|
||||
|
||||
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). Клик для чтения содержимого.
|
||||
- **Python 3.8+** с `pip`, и **Git**
|
||||
- **Смарт-картридер** (PC/SC или serial/FTDI) — предпочтителен PC/SC (`pcsc-lite` + `ccid` на Linux)
|
||||
- **Windows** — используйте Python 3.10–3.13 (рекомендуется 3.13): у `pyscard` есть готовые wheel. На 3.9 / 3.14 он собирается из исходников (нужны MSVC C++ Build Tools). SMPP-мост (`smpp.twisted3`) в Windows сознательно не ставится.
|
||||
|
||||
- **Read** — чтение файла (автоопределение transparent/record)
|
||||
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
|
||||
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
|
||||
### Скрипты
|
||||
|
||||
### Пользовательские файлы
|
||||
| Скрипт | Назначение |
|
||||
|--------|-------------|
|
||||
| `setup.sh` / `setup.bat` | Создаёт `.venv/`, ставит pysim и сервер. Запускать один раз после клонирования. |
|
||||
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). |
|
||||
|
||||
Файлы, отсутствующие в модели pysim, можно добавить вручную:
|
||||
`start.sh` автоопределяет ридер (PC/SC при работающем `pcscd`, иначе `/dev/ttyUSB0`); `start.bat` всегда использует `-p 0`. Без ридера сервер всё равно стартует («Reader: none») — карту можно инициализировать позже кнопкой **Equip**.
|
||||
|
||||
1. Перейдите на вкладку **Custom files**
|
||||
2. Введите путь (например, `3F00/6F46`) и псевдоним (например, `EF.SPN`)
|
||||
3. Нажмите **Add** — файл появится в дереве курсивом (непроверенный)
|
||||
4. Кликните для проверки существования — при успехе работает как обычный файл
|
||||
### Ручная установка
|
||||
|
||||
Пользовательские файлы сохраняются в `localStorage`. Экспорт/импорт в JSON для обмена.
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS (Windows: .venv\Scripts\activate)
|
||||
pip install git+https://github.com/osmocom/pysim.git
|
||||
pip install -e . # editable — раздаёт frontend/ из исходного дерева
|
||||
pysim-otaman-server --http-port 8080
|
||||
```
|
||||
|
||||
### Подсказки команд
|
||||
### Параметры CLI
|
||||
|
||||
Введите имя команды в **pySim command line**. Подсказки по использованию появляются через 300 мс. Автодополнение команд — над полем ввода.
|
||||
| Параметр | Описание |
|
||||
|----------|-------------|
|
||||
| `--http-host` | Адрес привязки (по умолчанию `127.0.0.1`) |
|
||||
| `--http-port` | Порт (по умолчанию `8080`) |
|
||||
| `--web-dir` | Каталог со статикой PWA (по умолчанию `<repo>/frontend`) |
|
||||
| `-p` / `--pcsc-device` | Номер слота PC/SC |
|
||||
| `-d` / `--device` | Путь к serial-устройству |
|
||||
| `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) |
|
||||
| `--apdu-trace` | Лог APDU-трафика в stderr |
|
||||
| `--log-requests` | Лог запросов/ответов в stderr |
|
||||
| `--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 с отметками времени |
|
||||
|
||||
### Устранение неполадок
|
||||
|
||||
- **"Failed to establish context: Access denied"** — `pcscd` не запущен или нет прав: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
|
||||
- **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера.
|
||||
|
||||
### Справочник API
|
||||
|
||||
Полный справочник endpoints: [docs/api.md](docs/api.md).
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
# pysim-otaman-server — HTTP API reference
|
||||
|
||||
The server exposes a JSON HTTP API under `/api/*`. All responses carry
|
||||
`Access-Control-Allow-Origin: *` (plus `Access-Control-Allow-Private-Network: true`
|
||||
on the preflight), so the API is reachable from a separately-hosted PWA.
|
||||
|
||||
## Version compatibility
|
||||
|
||||
| Server | PWA (OTAMan) | Status |
|
||||
|--------|-------------|--------|
|
||||
| 1.x.x | 1.x.x | ✅ Compatible |
|
||||
| 0.x.x | 1.x.x | ❌ Outdated — update server |
|
||||
| 2.x.x+ | 1.x.x | ⚠️ Server newer — update PWA |
|
||||
|
||||
The server reports its version via `GET /api/version`. The PWA checks this on
|
||||
connect and warns if versions are incompatible.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/api/version` | GET | Server version string |
|
||||
| `/api/status` | GET | Card reader + card info + current selection |
|
||||
| `/api/command` | POST | pySim command (equip, status, tree, etc.) |
|
||||
| `/api/commands` | GET | List available pySim commands |
|
||||
| `/api/tree` | POST | File tree browser for given FID/name |
|
||||
| `/api/select` | POST | Select a file by name or FID |
|
||||
| `/api/read` | POST | Read file content |
|
||||
| `/api/write` | POST | Write raw hex data to a file |
|
||||
| `/api/apdu` | POST | Raw APDU send |
|
||||
| `/api/help` | POST | pySim help for a given command |
|
||||
| `/api/send-ota` | POST | SCP80 OTA secured packet delivery |
|
||||
| `/api/ram-install` | POST | Install a Java Card `.cap` file via SCP80 (INSTALL[for load] → LOAD ×N → INSTALL[for install]) |
|
||||
| `/api/sp-verify` | POST | Verify secured packet against pySim reference |
|
||||
| `/api/menu` | GET | Current STK menu (title + items + active) |
|
||||
| `/api/menu-select` | POST | ENVELOPE(Menu Selection) with item_id |
|
||||
| `/api/menu-respond` | POST | TERMINAL RESPONSE for paused STK command |
|
||||
| `/api/stk-status` | GET | STK session state (active/pending/type) |
|
||||
| `/api/events` | GET | Event list from SET UP EVENT LIST |
|
||||
| `/api/event-send` | POST | Send ENVELOPE(Event Download) |
|
||||
| `/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/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 |
|
||||
|
||||
## Endpoint details
|
||||
|
||||
### `GET /api/version`
|
||||
|
||||
Returns server version for compatibility checking.
|
||||
|
||||
**Example response:**
|
||||
```json
|
||||
{"version": "2.1.2"}
|
||||
```
|
||||
|
||||
### `GET /api/status`
|
||||
|
||||
Card reader, card type, current selection, and card state.
|
||||
|
||||
### `GET /api/commands`
|
||||
|
||||
List all available shell commands for the current card profile.
|
||||
|
||||
### `POST /api/command`
|
||||
|
||||
Execute any pysim-shell command.
|
||||
|
||||
```json
|
||||
{"cmd": "select MF"}
|
||||
```
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{"output": "..."}
|
||||
```
|
||||
|
||||
### `POST /api/apdu`
|
||||
|
||||
Send a raw APDU to the card.
|
||||
|
||||
```json
|
||||
{"apdu": "00A4040000..."}
|
||||
```
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{"response": "...", "sw": "9000"}
|
||||
```
|
||||
|
||||
### `POST /api/help`
|
||||
|
||||
Get structured help for a shell command.
|
||||
|
||||
```json
|
||||
{"cmd": "apdu"}
|
||||
```
|
||||
|
||||
Returns:
|
||||
```json
|
||||
{"usage": "apdu [-h] [--expect-sw EXPECT_SW] [--raw] APDU", "description": "...", "args": [{"name": "APDU", "type": "positional", "help": "..."}]}
|
||||
```
|
||||
|
||||
### `POST /api/send-ota`
|
||||
|
||||
Send an OTA command (SCP80) to the card via SMS-PP-DOWNLOAD ENVELOPE.
|
||||
The secured packet is delivered in an SMS-DELIVER TPDU wrapped in an ENVELOPE command.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"sp": "00201516011515b00000...",
|
||||
"spi1": "16",
|
||||
"spi2": "01",
|
||||
"kic": "15",
|
||||
"kid": "15",
|
||||
"tar": "b00000",
|
||||
"cntr": "0000000001",
|
||||
"kicKey": "D6FCC023...",
|
||||
"kidKey": "1B07E7E0..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response (delivery PoR):**
|
||||
```json
|
||||
{"success": true, "sw": "9000", "response_data": "027100000e0a...",
|
||||
"por": {"response_status": "por_ok", "tar": "B00000", "pcntr": 0,
|
||||
"decoded": {"number_of_commands": 1, "last_status_word": "6e00",
|
||||
"last_response_data": ""}}}
|
||||
```
|
||||
|
||||
**Response (submit PoR):** PoR is extracted from the SMS-SUBMIT TPDU
|
||||
fetched via a proactive command (FETCH). The response contains the
|
||||
same `por` structure if decoding succeeds.
|
||||
|
||||
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. The `.cap` archive (a ZIP of nested components) is parsed server-side in `_cap_parse`; no external tooling is required.
|
||||
|
||||
**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.
|
||||
|
||||
### `POST /api/sp-verify`
|
||||
|
||||
Cross-check a secured packet against pySim's `OtaDialectSms.encode_cmd`
|
||||
reference. Returns the JS-generated packet, pySim reference, a match flag,
|
||||
and the decoded SPI fields.
|
||||
|
||||
```json
|
||||
{"spi1": "16", "spi2": "01", "kic": "15", "kid": "15", "tar": "b00000",
|
||||
"cntr": "0000000001", "apdu": "00a40000023f00",
|
||||
"kicKey": "D6FCC023...", "kidKey": "1B07E7E0..."}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"js_sp": "...", "py_sp": "...", "match": true,
|
||||
"diffs": [], "spi": {"counter": "counter_must_be_higher", ...}}
|
||||
```
|
||||
|
||||
### `GET /api/menu`
|
||||
|
||||
Returns the SIM Toolkit SETUP MENU captured from the card's TERMINAL PROFILE
|
||||
response at startup. Empty `{"items": []}` if the card didn't send a menu.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"command_number": 1, "items": [{"id": 128, "text": "Настройки/Settings"}],
|
||||
"title": "Alfa Mobile", "active": false}
|
||||
```
|
||||
|
||||
### `POST /api/menu-select`
|
||||
|
||||
Sends an `ENVELOPE(MENU SELECTION)` with the selected item ID, then handles
|
||||
the card's proactive response (DISPLAY TEXT or SELECT ITEM).
|
||||
|
||||
```json
|
||||
{"item_id": 128}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"type": "display_text", "text": "Hello", "sw": "9122"}
|
||||
```
|
||||
or
|
||||
```json
|
||||
{"type": "select_item", "items": [{"id": 1, "text": "Sub-menu"}], "sw": "9122"}
|
||||
```
|
||||
|
||||
### `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`. 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}
|
||||
```
|
||||
|
||||
| `result` | TERMINAL RESPONSE code | Meaning |
|
||||
|---|---|---|
|
||||
| `ok` | `0x00` | Command performed successfully |
|
||||
| `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`
|
||||
|
||||
Returns the current STK session state.
|
||||
```json
|
||||
{"active": true, "pending": true, "pending_type": "select_item"}
|
||||
```
|
||||
|
||||
### `POST /api/read`
|
||||
|
||||
Read file content. Auto-detects transparent vs record files.
|
||||
|
||||
```json
|
||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"], "mode": "raw"}
|
||||
```
|
||||
|
||||
Returns transparent data:
|
||||
```json
|
||||
{"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_path": ["MF"]}
|
||||
```
|
||||
|
||||
For record files:
|
||||
```json
|
||||
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_path": ["MF", "7F10"]}
|
||||
```
|
||||
|
||||
Returns:
|
||||
```json
|
||||
{"success": true, "sw": "9000"}
|
||||
```
|
||||
|
||||
### `POST /api/select`
|
||||
|
||||
Select a file by name or FID, with optional parent selection.
|
||||
|
||||
```json
|
||||
{"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",
|
||||
"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.
|
||||
|
||||
```json
|
||||
{"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": "..."}
|
||||
```
|
||||
|
||||
### `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:
|
||||
|
||||
```json
|
||||
{"menu": true, "events": [4, 5]}
|
||||
```
|
||||
|
||||
### `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.
|
||||
@@ -0,0 +1,807 @@
|
||||
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.aesjs = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({"aes-js":[function(require,module,exports){
|
||||
/*! MIT License. Copyright 2015-2018 Richard Moore <me@ricmoo.com>. See LICENSE.txt. */
|
||||
(function(root) {
|
||||
"use strict";
|
||||
|
||||
function checkInt(value) {
|
||||
return (parseInt(value) === value);
|
||||
}
|
||||
|
||||
function checkInts(arrayish) {
|
||||
if (!checkInt(arrayish.length)) { return false; }
|
||||
|
||||
for (var i = 0; i < arrayish.length; i++) {
|
||||
if (!checkInt(arrayish[i]) || arrayish[i] < 0 || arrayish[i] > 255) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function coerceArray(arg, copy) {
|
||||
|
||||
// ArrayBuffer view
|
||||
if (arg.buffer && arg.name === 'Uint8Array') {
|
||||
|
||||
if (copy) {
|
||||
if (arg.slice) {
|
||||
arg = arg.slice();
|
||||
} else {
|
||||
arg = Array.prototype.slice.call(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return arg;
|
||||
}
|
||||
|
||||
// It's an array; check it is a valid representation of a byte
|
||||
if (Array.isArray(arg)) {
|
||||
if (!checkInts(arg)) {
|
||||
throw new Error('Array contains invalid value: ' + arg);
|
||||
}
|
||||
|
||||
return new Uint8Array(arg);
|
||||
}
|
||||
|
||||
// Something else, but behaves like an array (maybe a Buffer? Arguments?)
|
||||
if (checkInt(arg.length) && checkInts(arg)) {
|
||||
return new Uint8Array(arg);
|
||||
}
|
||||
|
||||
throw new Error('unsupported array-like object');
|
||||
}
|
||||
|
||||
function createArray(length) {
|
||||
return new Uint8Array(length);
|
||||
}
|
||||
|
||||
function copyArray(sourceArray, targetArray, targetStart, sourceStart, sourceEnd) {
|
||||
if (sourceStart != null || sourceEnd != null) {
|
||||
if (sourceArray.slice) {
|
||||
sourceArray = sourceArray.slice(sourceStart, sourceEnd);
|
||||
} else {
|
||||
sourceArray = Array.prototype.slice.call(sourceArray, sourceStart, sourceEnd);
|
||||
}
|
||||
}
|
||||
targetArray.set(sourceArray, targetStart);
|
||||
}
|
||||
|
||||
|
||||
|
||||
var convertUtf8 = (function() {
|
||||
function toBytes(text) {
|
||||
var result = [], i = 0;
|
||||
text = encodeURI(text);
|
||||
while (i < text.length) {
|
||||
var c = text.charCodeAt(i++);
|
||||
|
||||
// if it is a % sign, encode the following 2 bytes as a hex value
|
||||
if (c === 37) {
|
||||
result.push(parseInt(text.substr(i, 2), 16))
|
||||
i += 2;
|
||||
|
||||
// otherwise, just the actual byte
|
||||
} else {
|
||||
result.push(c)
|
||||
}
|
||||
}
|
||||
|
||||
return coerceArray(result);
|
||||
}
|
||||
|
||||
function fromBytes(bytes) {
|
||||
var result = [], i = 0;
|
||||
|
||||
while (i < bytes.length) {
|
||||
var c = bytes[i];
|
||||
|
||||
if (c < 128) {
|
||||
result.push(String.fromCharCode(c));
|
||||
i++;
|
||||
} else if (c > 191 && c < 224) {
|
||||
result.push(String.fromCharCode(((c & 0x1f) << 6) | (bytes[i + 1] & 0x3f)));
|
||||
i += 2;
|
||||
} else {
|
||||
result.push(String.fromCharCode(((c & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f)));
|
||||
i += 3;
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
return {
|
||||
toBytes: toBytes,
|
||||
fromBytes: fromBytes,
|
||||
}
|
||||
})();
|
||||
|
||||
var convertHex = (function() {
|
||||
function toBytes(text) {
|
||||
var result = [];
|
||||
for (var i = 0; i < text.length; i += 2) {
|
||||
result.push(parseInt(text.substr(i, 2), 16));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// http://ixti.net/development/javascript/2011/11/11/base64-encodedecode-of-utf8-in-browser-with-js.html
|
||||
var Hex = '0123456789abcdef';
|
||||
|
||||
function fromBytes(bytes) {
|
||||
var result = [];
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
var v = bytes[i];
|
||||
result.push(Hex[(v & 0xf0) >> 4] + Hex[v & 0x0f]);
|
||||
}
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
return {
|
||||
toBytes: toBytes,
|
||||
fromBytes: fromBytes,
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
// Number of rounds by keysize
|
||||
var numberOfRounds = {16: 10, 24: 12, 32: 14}
|
||||
|
||||
// Round constant words
|
||||
var rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91];
|
||||
|
||||
// S-box and Inverse S-box (S is for Substitution)
|
||||
var S = [0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16];
|
||||
var Si =[0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb, 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb, 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e, 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25, 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92, 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84, 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06, 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b, 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73, 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e, 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b, 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4, 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f, 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef, 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61, 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d];
|
||||
|
||||
// Transformations for encryption
|
||||
var T1 = [0xc66363a5, 0xf87c7c84, 0xee777799, 0xf67b7b8d, 0xfff2f20d, 0xd66b6bbd, 0xde6f6fb1, 0x91c5c554, 0x60303050, 0x02010103, 0xce6767a9, 0x562b2b7d, 0xe7fefe19, 0xb5d7d762, 0x4dababe6, 0xec76769a, 0x8fcaca45, 0x1f82829d, 0x89c9c940, 0xfa7d7d87, 0xeffafa15, 0xb25959eb, 0x8e4747c9, 0xfbf0f00b, 0x41adadec, 0xb3d4d467, 0x5fa2a2fd, 0x45afafea, 0x239c9cbf, 0x53a4a4f7, 0xe4727296, 0x9bc0c05b, 0x75b7b7c2, 0xe1fdfd1c, 0x3d9393ae, 0x4c26266a, 0x6c36365a, 0x7e3f3f41, 0xf5f7f702, 0x83cccc4f, 0x6834345c, 0x51a5a5f4, 0xd1e5e534, 0xf9f1f108, 0xe2717193, 0xabd8d873, 0x62313153, 0x2a15153f, 0x0804040c, 0x95c7c752, 0x46232365, 0x9dc3c35e, 0x30181828, 0x379696a1, 0x0a05050f, 0x2f9a9ab5, 0x0e070709, 0x24121236, 0x1b80809b, 0xdfe2e23d, 0xcdebeb26, 0x4e272769, 0x7fb2b2cd, 0xea75759f, 0x1209091b, 0x1d83839e, 0x582c2c74, 0x341a1a2e, 0x361b1b2d, 0xdc6e6eb2, 0xb45a5aee, 0x5ba0a0fb, 0xa45252f6, 0x763b3b4d, 0xb7d6d661, 0x7db3b3ce, 0x5229297b, 0xdde3e33e, 0x5e2f2f71, 0x13848497, 0xa65353f5, 0xb9d1d168, 0x00000000, 0xc1eded2c, 0x40202060, 0xe3fcfc1f, 0x79b1b1c8, 0xb65b5bed, 0xd46a6abe, 0x8dcbcb46, 0x67bebed9, 0x7239394b, 0x944a4ade, 0x984c4cd4, 0xb05858e8, 0x85cfcf4a, 0xbbd0d06b, 0xc5efef2a, 0x4faaaae5, 0xedfbfb16, 0x864343c5, 0x9a4d4dd7, 0x66333355, 0x11858594, 0x8a4545cf, 0xe9f9f910, 0x04020206, 0xfe7f7f81, 0xa05050f0, 0x783c3c44, 0x259f9fba, 0x4ba8a8e3, 0xa25151f3, 0x5da3a3fe, 0x804040c0, 0x058f8f8a, 0x3f9292ad, 0x219d9dbc, 0x70383848, 0xf1f5f504, 0x63bcbcdf, 0x77b6b6c1, 0xafdada75, 0x42212163, 0x20101030, 0xe5ffff1a, 0xfdf3f30e, 0xbfd2d26d, 0x81cdcd4c, 0x180c0c14, 0x26131335, 0xc3ecec2f, 0xbe5f5fe1, 0x359797a2, 0x884444cc, 0x2e171739, 0x93c4c457, 0x55a7a7f2, 0xfc7e7e82, 0x7a3d3d47, 0xc86464ac, 0xba5d5de7, 0x3219192b, 0xe6737395, 0xc06060a0, 0x19818198, 0x9e4f4fd1, 0xa3dcdc7f, 0x44222266, 0x542a2a7e, 0x3b9090ab, 0x0b888883, 0x8c4646ca, 0xc7eeee29, 0x6bb8b8d3, 0x2814143c, 0xa7dede79, 0xbc5e5ee2, 0x160b0b1d, 0xaddbdb76, 0xdbe0e03b, 0x64323256, 0x743a3a4e, 0x140a0a1e, 0x924949db, 0x0c06060a, 0x4824246c, 0xb85c5ce4, 0x9fc2c25d, 0xbdd3d36e, 0x43acacef, 0xc46262a6, 0x399191a8, 0x319595a4, 0xd3e4e437, 0xf279798b, 0xd5e7e732, 0x8bc8c843, 0x6e373759, 0xda6d6db7, 0x018d8d8c, 0xb1d5d564, 0x9c4e4ed2, 0x49a9a9e0, 0xd86c6cb4, 0xac5656fa, 0xf3f4f407, 0xcfeaea25, 0xca6565af, 0xf47a7a8e, 0x47aeaee9, 0x10080818, 0x6fbabad5, 0xf0787888, 0x4a25256f, 0x5c2e2e72, 0x381c1c24, 0x57a6a6f1, 0x73b4b4c7, 0x97c6c651, 0xcbe8e823, 0xa1dddd7c, 0xe874749c, 0x3e1f1f21, 0x964b4bdd, 0x61bdbddc, 0x0d8b8b86, 0x0f8a8a85, 0xe0707090, 0x7c3e3e42, 0x71b5b5c4, 0xcc6666aa, 0x904848d8, 0x06030305, 0xf7f6f601, 0x1c0e0e12, 0xc26161a3, 0x6a35355f, 0xae5757f9, 0x69b9b9d0, 0x17868691, 0x99c1c158, 0x3a1d1d27, 0x279e9eb9, 0xd9e1e138, 0xebf8f813, 0x2b9898b3, 0x22111133, 0xd26969bb, 0xa9d9d970, 0x078e8e89, 0x339494a7, 0x2d9b9bb6, 0x3c1e1e22, 0x15878792, 0xc9e9e920, 0x87cece49, 0xaa5555ff, 0x50282878, 0xa5dfdf7a, 0x038c8c8f, 0x59a1a1f8, 0x09898980, 0x1a0d0d17, 0x65bfbfda, 0xd7e6e631, 0x844242c6, 0xd06868b8, 0x824141c3, 0x299999b0, 0x5a2d2d77, 0x1e0f0f11, 0x7bb0b0cb, 0xa85454fc, 0x6dbbbbd6, 0x2c16163a];
|
||||
var T2 = [0xa5c66363, 0x84f87c7c, 0x99ee7777, 0x8df67b7b, 0x0dfff2f2, 0xbdd66b6b, 0xb1de6f6f, 0x5491c5c5, 0x50603030, 0x03020101, 0xa9ce6767, 0x7d562b2b, 0x19e7fefe, 0x62b5d7d7, 0xe64dabab, 0x9aec7676, 0x458fcaca, 0x9d1f8282, 0x4089c9c9, 0x87fa7d7d, 0x15effafa, 0xebb25959, 0xc98e4747, 0x0bfbf0f0, 0xec41adad, 0x67b3d4d4, 0xfd5fa2a2, 0xea45afaf, 0xbf239c9c, 0xf753a4a4, 0x96e47272, 0x5b9bc0c0, 0xc275b7b7, 0x1ce1fdfd, 0xae3d9393, 0x6a4c2626, 0x5a6c3636, 0x417e3f3f, 0x02f5f7f7, 0x4f83cccc, 0x5c683434, 0xf451a5a5, 0x34d1e5e5, 0x08f9f1f1, 0x93e27171, 0x73abd8d8, 0x53623131, 0x3f2a1515, 0x0c080404, 0x5295c7c7, 0x65462323, 0x5e9dc3c3, 0x28301818, 0xa1379696, 0x0f0a0505, 0xb52f9a9a, 0x090e0707, 0x36241212, 0x9b1b8080, 0x3ddfe2e2, 0x26cdebeb, 0x694e2727, 0xcd7fb2b2, 0x9fea7575, 0x1b120909, 0x9e1d8383, 0x74582c2c, 0x2e341a1a, 0x2d361b1b, 0xb2dc6e6e, 0xeeb45a5a, 0xfb5ba0a0, 0xf6a45252, 0x4d763b3b, 0x61b7d6d6, 0xce7db3b3, 0x7b522929, 0x3edde3e3, 0x715e2f2f, 0x97138484, 0xf5a65353, 0x68b9d1d1, 0x00000000, 0x2cc1eded, 0x60402020, 0x1fe3fcfc, 0xc879b1b1, 0xedb65b5b, 0xbed46a6a, 0x468dcbcb, 0xd967bebe, 0x4b723939, 0xde944a4a, 0xd4984c4c, 0xe8b05858, 0x4a85cfcf, 0x6bbbd0d0, 0x2ac5efef, 0xe54faaaa, 0x16edfbfb, 0xc5864343, 0xd79a4d4d, 0x55663333, 0x94118585, 0xcf8a4545, 0x10e9f9f9, 0x06040202, 0x81fe7f7f, 0xf0a05050, 0x44783c3c, 0xba259f9f, 0xe34ba8a8, 0xf3a25151, 0xfe5da3a3, 0xc0804040, 0x8a058f8f, 0xad3f9292, 0xbc219d9d, 0x48703838, 0x04f1f5f5, 0xdf63bcbc, 0xc177b6b6, 0x75afdada, 0x63422121, 0x30201010, 0x1ae5ffff, 0x0efdf3f3, 0x6dbfd2d2, 0x4c81cdcd, 0x14180c0c, 0x35261313, 0x2fc3ecec, 0xe1be5f5f, 0xa2359797, 0xcc884444, 0x392e1717, 0x5793c4c4, 0xf255a7a7, 0x82fc7e7e, 0x477a3d3d, 0xacc86464, 0xe7ba5d5d, 0x2b321919, 0x95e67373, 0xa0c06060, 0x98198181, 0xd19e4f4f, 0x7fa3dcdc, 0x66442222, 0x7e542a2a, 0xab3b9090, 0x830b8888, 0xca8c4646, 0x29c7eeee, 0xd36bb8b8, 0x3c281414, 0x79a7dede, 0xe2bc5e5e, 0x1d160b0b, 0x76addbdb, 0x3bdbe0e0, 0x56643232, 0x4e743a3a, 0x1e140a0a, 0xdb924949, 0x0a0c0606, 0x6c482424, 0xe4b85c5c, 0x5d9fc2c2, 0x6ebdd3d3, 0xef43acac, 0xa6c46262, 0xa8399191, 0xa4319595, 0x37d3e4e4, 0x8bf27979, 0x32d5e7e7, 0x438bc8c8, 0x596e3737, 0xb7da6d6d, 0x8c018d8d, 0x64b1d5d5, 0xd29c4e4e, 0xe049a9a9, 0xb4d86c6c, 0xfaac5656, 0x07f3f4f4, 0x25cfeaea, 0xafca6565, 0x8ef47a7a, 0xe947aeae, 0x18100808, 0xd56fbaba, 0x88f07878, 0x6f4a2525, 0x725c2e2e, 0x24381c1c, 0xf157a6a6, 0xc773b4b4, 0x5197c6c6, 0x23cbe8e8, 0x7ca1dddd, 0x9ce87474, 0x213e1f1f, 0xdd964b4b, 0xdc61bdbd, 0x860d8b8b, 0x850f8a8a, 0x90e07070, 0x427c3e3e, 0xc471b5b5, 0xaacc6666, 0xd8904848, 0x05060303, 0x01f7f6f6, 0x121c0e0e, 0xa3c26161, 0x5f6a3535, 0xf9ae5757, 0xd069b9b9, 0x91178686, 0x5899c1c1, 0x273a1d1d, 0xb9279e9e, 0x38d9e1e1, 0x13ebf8f8, 0xb32b9898, 0x33221111, 0xbbd26969, 0x70a9d9d9, 0x89078e8e, 0xa7339494, 0xb62d9b9b, 0x223c1e1e, 0x92158787, 0x20c9e9e9, 0x4987cece, 0xffaa5555, 0x78502828, 0x7aa5dfdf, 0x8f038c8c, 0xf859a1a1, 0x80098989, 0x171a0d0d, 0xda65bfbf, 0x31d7e6e6, 0xc6844242, 0xb8d06868, 0xc3824141, 0xb0299999, 0x775a2d2d, 0x111e0f0f, 0xcb7bb0b0, 0xfca85454, 0xd66dbbbb, 0x3a2c1616];
|
||||
var T3 = [0x63a5c663, 0x7c84f87c, 0x7799ee77, 0x7b8df67b, 0xf20dfff2, 0x6bbdd66b, 0x6fb1de6f, 0xc55491c5, 0x30506030, 0x01030201, 0x67a9ce67, 0x2b7d562b, 0xfe19e7fe, 0xd762b5d7, 0xabe64dab, 0x769aec76, 0xca458fca, 0x829d1f82, 0xc94089c9, 0x7d87fa7d, 0xfa15effa, 0x59ebb259, 0x47c98e47, 0xf00bfbf0, 0xadec41ad, 0xd467b3d4, 0xa2fd5fa2, 0xafea45af, 0x9cbf239c, 0xa4f753a4, 0x7296e472, 0xc05b9bc0, 0xb7c275b7, 0xfd1ce1fd, 0x93ae3d93, 0x266a4c26, 0x365a6c36, 0x3f417e3f, 0xf702f5f7, 0xcc4f83cc, 0x345c6834, 0xa5f451a5, 0xe534d1e5, 0xf108f9f1, 0x7193e271, 0xd873abd8, 0x31536231, 0x153f2a15, 0x040c0804, 0xc75295c7, 0x23654623, 0xc35e9dc3, 0x18283018, 0x96a13796, 0x050f0a05, 0x9ab52f9a, 0x07090e07, 0x12362412, 0x809b1b80, 0xe23ddfe2, 0xeb26cdeb, 0x27694e27, 0xb2cd7fb2, 0x759fea75, 0x091b1209, 0x839e1d83, 0x2c74582c, 0x1a2e341a, 0x1b2d361b, 0x6eb2dc6e, 0x5aeeb45a, 0xa0fb5ba0, 0x52f6a452, 0x3b4d763b, 0xd661b7d6, 0xb3ce7db3, 0x297b5229, 0xe33edde3, 0x2f715e2f, 0x84971384, 0x53f5a653, 0xd168b9d1, 0x00000000, 0xed2cc1ed, 0x20604020, 0xfc1fe3fc, 0xb1c879b1, 0x5bedb65b, 0x6abed46a, 0xcb468dcb, 0xbed967be, 0x394b7239, 0x4ade944a, 0x4cd4984c, 0x58e8b058, 0xcf4a85cf, 0xd06bbbd0, 0xef2ac5ef, 0xaae54faa, 0xfb16edfb, 0x43c58643, 0x4dd79a4d, 0x33556633, 0x85941185, 0x45cf8a45, 0xf910e9f9, 0x02060402, 0x7f81fe7f, 0x50f0a050, 0x3c44783c, 0x9fba259f, 0xa8e34ba8, 0x51f3a251, 0xa3fe5da3, 0x40c08040, 0x8f8a058f, 0x92ad3f92, 0x9dbc219d, 0x38487038, 0xf504f1f5, 0xbcdf63bc, 0xb6c177b6, 0xda75afda, 0x21634221, 0x10302010, 0xff1ae5ff, 0xf30efdf3, 0xd26dbfd2, 0xcd4c81cd, 0x0c14180c, 0x13352613, 0xec2fc3ec, 0x5fe1be5f, 0x97a23597, 0x44cc8844, 0x17392e17, 0xc45793c4, 0xa7f255a7, 0x7e82fc7e, 0x3d477a3d, 0x64acc864, 0x5de7ba5d, 0x192b3219, 0x7395e673, 0x60a0c060, 0x81981981, 0x4fd19e4f, 0xdc7fa3dc, 0x22664422, 0x2a7e542a, 0x90ab3b90, 0x88830b88, 0x46ca8c46, 0xee29c7ee, 0xb8d36bb8, 0x143c2814, 0xde79a7de, 0x5ee2bc5e, 0x0b1d160b, 0xdb76addb, 0xe03bdbe0, 0x32566432, 0x3a4e743a, 0x0a1e140a, 0x49db9249, 0x060a0c06, 0x246c4824, 0x5ce4b85c, 0xc25d9fc2, 0xd36ebdd3, 0xacef43ac, 0x62a6c462, 0x91a83991, 0x95a43195, 0xe437d3e4, 0x798bf279, 0xe732d5e7, 0xc8438bc8, 0x37596e37, 0x6db7da6d, 0x8d8c018d, 0xd564b1d5, 0x4ed29c4e, 0xa9e049a9, 0x6cb4d86c, 0x56faac56, 0xf407f3f4, 0xea25cfea, 0x65afca65, 0x7a8ef47a, 0xaee947ae, 0x08181008, 0xbad56fba, 0x7888f078, 0x256f4a25, 0x2e725c2e, 0x1c24381c, 0xa6f157a6, 0xb4c773b4, 0xc65197c6, 0xe823cbe8, 0xdd7ca1dd, 0x749ce874, 0x1f213e1f, 0x4bdd964b, 0xbddc61bd, 0x8b860d8b, 0x8a850f8a, 0x7090e070, 0x3e427c3e, 0xb5c471b5, 0x66aacc66, 0x48d89048, 0x03050603, 0xf601f7f6, 0x0e121c0e, 0x61a3c261, 0x355f6a35, 0x57f9ae57, 0xb9d069b9, 0x86911786, 0xc15899c1, 0x1d273a1d, 0x9eb9279e, 0xe138d9e1, 0xf813ebf8, 0x98b32b98, 0x11332211, 0x69bbd269, 0xd970a9d9, 0x8e89078e, 0x94a73394, 0x9bb62d9b, 0x1e223c1e, 0x87921587, 0xe920c9e9, 0xce4987ce, 0x55ffaa55, 0x28785028, 0xdf7aa5df, 0x8c8f038c, 0xa1f859a1, 0x89800989, 0x0d171a0d, 0xbfda65bf, 0xe631d7e6, 0x42c68442, 0x68b8d068, 0x41c38241, 0x99b02999, 0x2d775a2d, 0x0f111e0f, 0xb0cb7bb0, 0x54fca854, 0xbbd66dbb, 0x163a2c16];
|
||||
var T4 = [0x6363a5c6, 0x7c7c84f8, 0x777799ee, 0x7b7b8df6, 0xf2f20dff, 0x6b6bbdd6, 0x6f6fb1de, 0xc5c55491, 0x30305060, 0x01010302, 0x6767a9ce, 0x2b2b7d56, 0xfefe19e7, 0xd7d762b5, 0xababe64d, 0x76769aec, 0xcaca458f, 0x82829d1f, 0xc9c94089, 0x7d7d87fa, 0xfafa15ef, 0x5959ebb2, 0x4747c98e, 0xf0f00bfb, 0xadadec41, 0xd4d467b3, 0xa2a2fd5f, 0xafafea45, 0x9c9cbf23, 0xa4a4f753, 0x727296e4, 0xc0c05b9b, 0xb7b7c275, 0xfdfd1ce1, 0x9393ae3d, 0x26266a4c, 0x36365a6c, 0x3f3f417e, 0xf7f702f5, 0xcccc4f83, 0x34345c68, 0xa5a5f451, 0xe5e534d1, 0xf1f108f9, 0x717193e2, 0xd8d873ab, 0x31315362, 0x15153f2a, 0x04040c08, 0xc7c75295, 0x23236546, 0xc3c35e9d, 0x18182830, 0x9696a137, 0x05050f0a, 0x9a9ab52f, 0x0707090e, 0x12123624, 0x80809b1b, 0xe2e23ddf, 0xebeb26cd, 0x2727694e, 0xb2b2cd7f, 0x75759fea, 0x09091b12, 0x83839e1d, 0x2c2c7458, 0x1a1a2e34, 0x1b1b2d36, 0x6e6eb2dc, 0x5a5aeeb4, 0xa0a0fb5b, 0x5252f6a4, 0x3b3b4d76, 0xd6d661b7, 0xb3b3ce7d, 0x29297b52, 0xe3e33edd, 0x2f2f715e, 0x84849713, 0x5353f5a6, 0xd1d168b9, 0x00000000, 0xeded2cc1, 0x20206040, 0xfcfc1fe3, 0xb1b1c879, 0x5b5bedb6, 0x6a6abed4, 0xcbcb468d, 0xbebed967, 0x39394b72, 0x4a4ade94, 0x4c4cd498, 0x5858e8b0, 0xcfcf4a85, 0xd0d06bbb, 0xefef2ac5, 0xaaaae54f, 0xfbfb16ed, 0x4343c586, 0x4d4dd79a, 0x33335566, 0x85859411, 0x4545cf8a, 0xf9f910e9, 0x02020604, 0x7f7f81fe, 0x5050f0a0, 0x3c3c4478, 0x9f9fba25, 0xa8a8e34b, 0x5151f3a2, 0xa3a3fe5d, 0x4040c080, 0x8f8f8a05, 0x9292ad3f, 0x9d9dbc21, 0x38384870, 0xf5f504f1, 0xbcbcdf63, 0xb6b6c177, 0xdada75af, 0x21216342, 0x10103020, 0xffff1ae5, 0xf3f30efd, 0xd2d26dbf, 0xcdcd4c81, 0x0c0c1418, 0x13133526, 0xecec2fc3, 0x5f5fe1be, 0x9797a235, 0x4444cc88, 0x1717392e, 0xc4c45793, 0xa7a7f255, 0x7e7e82fc, 0x3d3d477a, 0x6464acc8, 0x5d5de7ba, 0x19192b32, 0x737395e6, 0x6060a0c0, 0x81819819, 0x4f4fd19e, 0xdcdc7fa3, 0x22226644, 0x2a2a7e54, 0x9090ab3b, 0x8888830b, 0x4646ca8c, 0xeeee29c7, 0xb8b8d36b, 0x14143c28, 0xdede79a7, 0x5e5ee2bc, 0x0b0b1d16, 0xdbdb76ad, 0xe0e03bdb, 0x32325664, 0x3a3a4e74, 0x0a0a1e14, 0x4949db92, 0x06060a0c, 0x24246c48, 0x5c5ce4b8, 0xc2c25d9f, 0xd3d36ebd, 0xacacef43, 0x6262a6c4, 0x9191a839, 0x9595a431, 0xe4e437d3, 0x79798bf2, 0xe7e732d5, 0xc8c8438b, 0x3737596e, 0x6d6db7da, 0x8d8d8c01, 0xd5d564b1, 0x4e4ed29c, 0xa9a9e049, 0x6c6cb4d8, 0x5656faac, 0xf4f407f3, 0xeaea25cf, 0x6565afca, 0x7a7a8ef4, 0xaeaee947, 0x08081810, 0xbabad56f, 0x787888f0, 0x25256f4a, 0x2e2e725c, 0x1c1c2438, 0xa6a6f157, 0xb4b4c773, 0xc6c65197, 0xe8e823cb, 0xdddd7ca1, 0x74749ce8, 0x1f1f213e, 0x4b4bdd96, 0xbdbddc61, 0x8b8b860d, 0x8a8a850f, 0x707090e0, 0x3e3e427c, 0xb5b5c471, 0x6666aacc, 0x4848d890, 0x03030506, 0xf6f601f7, 0x0e0e121c, 0x6161a3c2, 0x35355f6a, 0x5757f9ae, 0xb9b9d069, 0x86869117, 0xc1c15899, 0x1d1d273a, 0x9e9eb927, 0xe1e138d9, 0xf8f813eb, 0x9898b32b, 0x11113322, 0x6969bbd2, 0xd9d970a9, 0x8e8e8907, 0x9494a733, 0x9b9bb62d, 0x1e1e223c, 0x87879215, 0xe9e920c9, 0xcece4987, 0x5555ffaa, 0x28287850, 0xdfdf7aa5, 0x8c8c8f03, 0xa1a1f859, 0x89898009, 0x0d0d171a, 0xbfbfda65, 0xe6e631d7, 0x4242c684, 0x6868b8d0, 0x4141c382, 0x9999b029, 0x2d2d775a, 0x0f0f111e, 0xb0b0cb7b, 0x5454fca8, 0xbbbbd66d, 0x16163a2c];
|
||||
|
||||
// Transformations for decryption
|
||||
var T5 = [0x51f4a750, 0x7e416553, 0x1a17a4c3, 0x3a275e96, 0x3bab6bcb, 0x1f9d45f1, 0xacfa58ab, 0x4be30393, 0x2030fa55, 0xad766df6, 0x88cc7691, 0xf5024c25, 0x4fe5d7fc, 0xc52acbd7, 0x26354480, 0xb562a38f, 0xdeb15a49, 0x25ba1b67, 0x45ea0e98, 0x5dfec0e1, 0xc32f7502, 0x814cf012, 0x8d4697a3, 0x6bd3f9c6, 0x038f5fe7, 0x15929c95, 0xbf6d7aeb, 0x955259da, 0xd4be832d, 0x587421d3, 0x49e06929, 0x8ec9c844, 0x75c2896a, 0xf48e7978, 0x99583e6b, 0x27b971dd, 0xbee14fb6, 0xf088ad17, 0xc920ac66, 0x7dce3ab4, 0x63df4a18, 0xe51a3182, 0x97513360, 0x62537f45, 0xb16477e0, 0xbb6bae84, 0xfe81a01c, 0xf9082b94, 0x70486858, 0x8f45fd19, 0x94de6c87, 0x527bf8b7, 0xab73d323, 0x724b02e2, 0xe31f8f57, 0x6655ab2a, 0xb2eb2807, 0x2fb5c203, 0x86c57b9a, 0xd33708a5, 0x302887f2, 0x23bfa5b2, 0x02036aba, 0xed16825c, 0x8acf1c2b, 0xa779b492, 0xf307f2f0, 0x4e69e2a1, 0x65daf4cd, 0x0605bed5, 0xd134621f, 0xc4a6fe8a, 0x342e539d, 0xa2f355a0, 0x058ae132, 0xa4f6eb75, 0x0b83ec39, 0x4060efaa, 0x5e719f06, 0xbd6e1051, 0x3e218af9, 0x96dd063d, 0xdd3e05ae, 0x4de6bd46, 0x91548db5, 0x71c45d05, 0x0406d46f, 0x605015ff, 0x1998fb24, 0xd6bde997, 0x894043cc, 0x67d99e77, 0xb0e842bd, 0x07898b88, 0xe7195b38, 0x79c8eedb, 0xa17c0a47, 0x7c420fe9, 0xf8841ec9, 0x00000000, 0x09808683, 0x322bed48, 0x1e1170ac, 0x6c5a724e, 0xfd0efffb, 0x0f853856, 0x3daed51e, 0x362d3927, 0x0a0fd964, 0x685ca621, 0x9b5b54d1, 0x24362e3a, 0x0c0a67b1, 0x9357e70f, 0xb4ee96d2, 0x1b9b919e, 0x80c0c54f, 0x61dc20a2, 0x5a774b69, 0x1c121a16, 0xe293ba0a, 0xc0a02ae5, 0x3c22e043, 0x121b171d, 0x0e090d0b, 0xf28bc7ad, 0x2db6a8b9, 0x141ea9c8, 0x57f11985, 0xaf75074c, 0xee99ddbb, 0xa37f60fd, 0xf701269f, 0x5c72f5bc, 0x44663bc5, 0x5bfb7e34, 0x8b432976, 0xcb23c6dc, 0xb6edfc68, 0xb8e4f163, 0xd731dcca, 0x42638510, 0x13972240, 0x84c61120, 0x854a247d, 0xd2bb3df8, 0xaef93211, 0xc729a16d, 0x1d9e2f4b, 0xdcb230f3, 0x0d8652ec, 0x77c1e3d0, 0x2bb3166c, 0xa970b999, 0x119448fa, 0x47e96422, 0xa8fc8cc4, 0xa0f03f1a, 0x567d2cd8, 0x223390ef, 0x87494ec7, 0xd938d1c1, 0x8ccaa2fe, 0x98d40b36, 0xa6f581cf, 0xa57ade28, 0xdab78e26, 0x3fadbfa4, 0x2c3a9de4, 0x5078920d, 0x6a5fcc9b, 0x547e4662, 0xf68d13c2, 0x90d8b8e8, 0x2e39f75e, 0x82c3aff5, 0x9f5d80be, 0x69d0937c, 0x6fd52da9, 0xcf2512b3, 0xc8ac993b, 0x10187da7, 0xe89c636e, 0xdb3bbb7b, 0xcd267809, 0x6e5918f4, 0xec9ab701, 0x834f9aa8, 0xe6956e65, 0xaaffe67e, 0x21bccf08, 0xef15e8e6, 0xbae79bd9, 0x4a6f36ce, 0xea9f09d4, 0x29b07cd6, 0x31a4b2af, 0x2a3f2331, 0xc6a59430, 0x35a266c0, 0x744ebc37, 0xfc82caa6, 0xe090d0b0, 0x33a7d815, 0xf104984a, 0x41ecdaf7, 0x7fcd500e, 0x1791f62f, 0x764dd68d, 0x43efb04d, 0xccaa4d54, 0xe49604df, 0x9ed1b5e3, 0x4c6a881b, 0xc12c1fb8, 0x4665517f, 0x9d5eea04, 0x018c355d, 0xfa877473, 0xfb0b412e, 0xb3671d5a, 0x92dbd252, 0xe9105633, 0x6dd64713, 0x9ad7618c, 0x37a10c7a, 0x59f8148e, 0xeb133c89, 0xcea927ee, 0xb761c935, 0xe11ce5ed, 0x7a47b13c, 0x9cd2df59, 0x55f2733f, 0x1814ce79, 0x73c737bf, 0x53f7cdea, 0x5ffdaa5b, 0xdf3d6f14, 0x7844db86, 0xcaaff381, 0xb968c43e, 0x3824342c, 0xc2a3405f, 0x161dc372, 0xbce2250c, 0x283c498b, 0xff0d9541, 0x39a80171, 0x080cb3de, 0xd8b4e49c, 0x6456c190, 0x7bcb8461, 0xd532b670, 0x486c5c74, 0xd0b85742];
|
||||
var T6 = [0x5051f4a7, 0x537e4165, 0xc31a17a4, 0x963a275e, 0xcb3bab6b, 0xf11f9d45, 0xabacfa58, 0x934be303, 0x552030fa, 0xf6ad766d, 0x9188cc76, 0x25f5024c, 0xfc4fe5d7, 0xd7c52acb, 0x80263544, 0x8fb562a3, 0x49deb15a, 0x6725ba1b, 0x9845ea0e, 0xe15dfec0, 0x02c32f75, 0x12814cf0, 0xa38d4697, 0xc66bd3f9, 0xe7038f5f, 0x9515929c, 0xebbf6d7a, 0xda955259, 0x2dd4be83, 0xd3587421, 0x2949e069, 0x448ec9c8, 0x6a75c289, 0x78f48e79, 0x6b99583e, 0xdd27b971, 0xb6bee14f, 0x17f088ad, 0x66c920ac, 0xb47dce3a, 0x1863df4a, 0x82e51a31, 0x60975133, 0x4562537f, 0xe0b16477, 0x84bb6bae, 0x1cfe81a0, 0x94f9082b, 0x58704868, 0x198f45fd, 0x8794de6c, 0xb7527bf8, 0x23ab73d3, 0xe2724b02, 0x57e31f8f, 0x2a6655ab, 0x07b2eb28, 0x032fb5c2, 0x9a86c57b, 0xa5d33708, 0xf2302887, 0xb223bfa5, 0xba02036a, 0x5ced1682, 0x2b8acf1c, 0x92a779b4, 0xf0f307f2, 0xa14e69e2, 0xcd65daf4, 0xd50605be, 0x1fd13462, 0x8ac4a6fe, 0x9d342e53, 0xa0a2f355, 0x32058ae1, 0x75a4f6eb, 0x390b83ec, 0xaa4060ef, 0x065e719f, 0x51bd6e10, 0xf93e218a, 0x3d96dd06, 0xaedd3e05, 0x464de6bd, 0xb591548d, 0x0571c45d, 0x6f0406d4, 0xff605015, 0x241998fb, 0x97d6bde9, 0xcc894043, 0x7767d99e, 0xbdb0e842, 0x8807898b, 0x38e7195b, 0xdb79c8ee, 0x47a17c0a, 0xe97c420f, 0xc9f8841e, 0x00000000, 0x83098086, 0x48322bed, 0xac1e1170, 0x4e6c5a72, 0xfbfd0eff, 0x560f8538, 0x1e3daed5, 0x27362d39, 0x640a0fd9, 0x21685ca6, 0xd19b5b54, 0x3a24362e, 0xb10c0a67, 0x0f9357e7, 0xd2b4ee96, 0x9e1b9b91, 0x4f80c0c5, 0xa261dc20, 0x695a774b, 0x161c121a, 0x0ae293ba, 0xe5c0a02a, 0x433c22e0, 0x1d121b17, 0x0b0e090d, 0xadf28bc7, 0xb92db6a8, 0xc8141ea9, 0x8557f119, 0x4caf7507, 0xbbee99dd, 0xfda37f60, 0x9ff70126, 0xbc5c72f5, 0xc544663b, 0x345bfb7e, 0x768b4329, 0xdccb23c6, 0x68b6edfc, 0x63b8e4f1, 0xcad731dc, 0x10426385, 0x40139722, 0x2084c611, 0x7d854a24, 0xf8d2bb3d, 0x11aef932, 0x6dc729a1, 0x4b1d9e2f, 0xf3dcb230, 0xec0d8652, 0xd077c1e3, 0x6c2bb316, 0x99a970b9, 0xfa119448, 0x2247e964, 0xc4a8fc8c, 0x1aa0f03f, 0xd8567d2c, 0xef223390, 0xc787494e, 0xc1d938d1, 0xfe8ccaa2, 0x3698d40b, 0xcfa6f581, 0x28a57ade, 0x26dab78e, 0xa43fadbf, 0xe42c3a9d, 0x0d507892, 0x9b6a5fcc, 0x62547e46, 0xc2f68d13, 0xe890d8b8, 0x5e2e39f7, 0xf582c3af, 0xbe9f5d80, 0x7c69d093, 0xa96fd52d, 0xb3cf2512, 0x3bc8ac99, 0xa710187d, 0x6ee89c63, 0x7bdb3bbb, 0x09cd2678, 0xf46e5918, 0x01ec9ab7, 0xa8834f9a, 0x65e6956e, 0x7eaaffe6, 0x0821bccf, 0xe6ef15e8, 0xd9bae79b, 0xce4a6f36, 0xd4ea9f09, 0xd629b07c, 0xaf31a4b2, 0x312a3f23, 0x30c6a594, 0xc035a266, 0x37744ebc, 0xa6fc82ca, 0xb0e090d0, 0x1533a7d8, 0x4af10498, 0xf741ecda, 0x0e7fcd50, 0x2f1791f6, 0x8d764dd6, 0x4d43efb0, 0x54ccaa4d, 0xdfe49604, 0xe39ed1b5, 0x1b4c6a88, 0xb8c12c1f, 0x7f466551, 0x049d5eea, 0x5d018c35, 0x73fa8774, 0x2efb0b41, 0x5ab3671d, 0x5292dbd2, 0x33e91056, 0x136dd647, 0x8c9ad761, 0x7a37a10c, 0x8e59f814, 0x89eb133c, 0xeecea927, 0x35b761c9, 0xede11ce5, 0x3c7a47b1, 0x599cd2df, 0x3f55f273, 0x791814ce, 0xbf73c737, 0xea53f7cd, 0x5b5ffdaa, 0x14df3d6f, 0x867844db, 0x81caaff3, 0x3eb968c4, 0x2c382434, 0x5fc2a340, 0x72161dc3, 0x0cbce225, 0x8b283c49, 0x41ff0d95, 0x7139a801, 0xde080cb3, 0x9cd8b4e4, 0x906456c1, 0x617bcb84, 0x70d532b6, 0x74486c5c, 0x42d0b857];
|
||||
var T7 = [0xa75051f4, 0x65537e41, 0xa4c31a17, 0x5e963a27, 0x6bcb3bab, 0x45f11f9d, 0x58abacfa, 0x03934be3, 0xfa552030, 0x6df6ad76, 0x769188cc, 0x4c25f502, 0xd7fc4fe5, 0xcbd7c52a, 0x44802635, 0xa38fb562, 0x5a49deb1, 0x1b6725ba, 0x0e9845ea, 0xc0e15dfe, 0x7502c32f, 0xf012814c, 0x97a38d46, 0xf9c66bd3, 0x5fe7038f, 0x9c951592, 0x7aebbf6d, 0x59da9552, 0x832dd4be, 0x21d35874, 0x692949e0, 0xc8448ec9, 0x896a75c2, 0x7978f48e, 0x3e6b9958, 0x71dd27b9, 0x4fb6bee1, 0xad17f088, 0xac66c920, 0x3ab47dce, 0x4a1863df, 0x3182e51a, 0x33609751, 0x7f456253, 0x77e0b164, 0xae84bb6b, 0xa01cfe81, 0x2b94f908, 0x68587048, 0xfd198f45, 0x6c8794de, 0xf8b7527b, 0xd323ab73, 0x02e2724b, 0x8f57e31f, 0xab2a6655, 0x2807b2eb, 0xc2032fb5, 0x7b9a86c5, 0x08a5d337, 0x87f23028, 0xa5b223bf, 0x6aba0203, 0x825ced16, 0x1c2b8acf, 0xb492a779, 0xf2f0f307, 0xe2a14e69, 0xf4cd65da, 0xbed50605, 0x621fd134, 0xfe8ac4a6, 0x539d342e, 0x55a0a2f3, 0xe132058a, 0xeb75a4f6, 0xec390b83, 0xefaa4060, 0x9f065e71, 0x1051bd6e, 0x8af93e21, 0x063d96dd, 0x05aedd3e, 0xbd464de6, 0x8db59154, 0x5d0571c4, 0xd46f0406, 0x15ff6050, 0xfb241998, 0xe997d6bd, 0x43cc8940, 0x9e7767d9, 0x42bdb0e8, 0x8b880789, 0x5b38e719, 0xeedb79c8, 0x0a47a17c, 0x0fe97c42, 0x1ec9f884, 0x00000000, 0x86830980, 0xed48322b, 0x70ac1e11, 0x724e6c5a, 0xfffbfd0e, 0x38560f85, 0xd51e3dae, 0x3927362d, 0xd9640a0f, 0xa621685c, 0x54d19b5b, 0x2e3a2436, 0x67b10c0a, 0xe70f9357, 0x96d2b4ee, 0x919e1b9b, 0xc54f80c0, 0x20a261dc, 0x4b695a77, 0x1a161c12, 0xba0ae293, 0x2ae5c0a0, 0xe0433c22, 0x171d121b, 0x0d0b0e09, 0xc7adf28b, 0xa8b92db6, 0xa9c8141e, 0x198557f1, 0x074caf75, 0xddbbee99, 0x60fda37f, 0x269ff701, 0xf5bc5c72, 0x3bc54466, 0x7e345bfb, 0x29768b43, 0xc6dccb23, 0xfc68b6ed, 0xf163b8e4, 0xdccad731, 0x85104263, 0x22401397, 0x112084c6, 0x247d854a, 0x3df8d2bb, 0x3211aef9, 0xa16dc729, 0x2f4b1d9e, 0x30f3dcb2, 0x52ec0d86, 0xe3d077c1, 0x166c2bb3, 0xb999a970, 0x48fa1194, 0x642247e9, 0x8cc4a8fc, 0x3f1aa0f0, 0x2cd8567d, 0x90ef2233, 0x4ec78749, 0xd1c1d938, 0xa2fe8cca, 0x0b3698d4, 0x81cfa6f5, 0xde28a57a, 0x8e26dab7, 0xbfa43fad, 0x9de42c3a, 0x920d5078, 0xcc9b6a5f, 0x4662547e, 0x13c2f68d, 0xb8e890d8, 0xf75e2e39, 0xaff582c3, 0x80be9f5d, 0x937c69d0, 0x2da96fd5, 0x12b3cf25, 0x993bc8ac, 0x7da71018, 0x636ee89c, 0xbb7bdb3b, 0x7809cd26, 0x18f46e59, 0xb701ec9a, 0x9aa8834f, 0x6e65e695, 0xe67eaaff, 0xcf0821bc, 0xe8e6ef15, 0x9bd9bae7, 0x36ce4a6f, 0x09d4ea9f, 0x7cd629b0, 0xb2af31a4, 0x23312a3f, 0x9430c6a5, 0x66c035a2, 0xbc37744e, 0xcaa6fc82, 0xd0b0e090, 0xd81533a7, 0x984af104, 0xdaf741ec, 0x500e7fcd, 0xf62f1791, 0xd68d764d, 0xb04d43ef, 0x4d54ccaa, 0x04dfe496, 0xb5e39ed1, 0x881b4c6a, 0x1fb8c12c, 0x517f4665, 0xea049d5e, 0x355d018c, 0x7473fa87, 0x412efb0b, 0x1d5ab367, 0xd25292db, 0x5633e910, 0x47136dd6, 0x618c9ad7, 0x0c7a37a1, 0x148e59f8, 0x3c89eb13, 0x27eecea9, 0xc935b761, 0xe5ede11c, 0xb13c7a47, 0xdf599cd2, 0x733f55f2, 0xce791814, 0x37bf73c7, 0xcdea53f7, 0xaa5b5ffd, 0x6f14df3d, 0xdb867844, 0xf381caaf, 0xc43eb968, 0x342c3824, 0x405fc2a3, 0xc372161d, 0x250cbce2, 0x498b283c, 0x9541ff0d, 0x017139a8, 0xb3de080c, 0xe49cd8b4, 0xc1906456, 0x84617bcb, 0xb670d532, 0x5c74486c, 0x5742d0b8];
|
||||
var T8 = [0xf4a75051, 0x4165537e, 0x17a4c31a, 0x275e963a, 0xab6bcb3b, 0x9d45f11f, 0xfa58abac, 0xe303934b, 0x30fa5520, 0x766df6ad, 0xcc769188, 0x024c25f5, 0xe5d7fc4f, 0x2acbd7c5, 0x35448026, 0x62a38fb5, 0xb15a49de, 0xba1b6725, 0xea0e9845, 0xfec0e15d, 0x2f7502c3, 0x4cf01281, 0x4697a38d, 0xd3f9c66b, 0x8f5fe703, 0x929c9515, 0x6d7aebbf, 0x5259da95, 0xbe832dd4, 0x7421d358, 0xe0692949, 0xc9c8448e, 0xc2896a75, 0x8e7978f4, 0x583e6b99, 0xb971dd27, 0xe14fb6be, 0x88ad17f0, 0x20ac66c9, 0xce3ab47d, 0xdf4a1863, 0x1a3182e5, 0x51336097, 0x537f4562, 0x6477e0b1, 0x6bae84bb, 0x81a01cfe, 0x082b94f9, 0x48685870, 0x45fd198f, 0xde6c8794, 0x7bf8b752, 0x73d323ab, 0x4b02e272, 0x1f8f57e3, 0x55ab2a66, 0xeb2807b2, 0xb5c2032f, 0xc57b9a86, 0x3708a5d3, 0x2887f230, 0xbfa5b223, 0x036aba02, 0x16825ced, 0xcf1c2b8a, 0x79b492a7, 0x07f2f0f3, 0x69e2a14e, 0xdaf4cd65, 0x05bed506, 0x34621fd1, 0xa6fe8ac4, 0x2e539d34, 0xf355a0a2, 0x8ae13205, 0xf6eb75a4, 0x83ec390b, 0x60efaa40, 0x719f065e, 0x6e1051bd, 0x218af93e, 0xdd063d96, 0x3e05aedd, 0xe6bd464d, 0x548db591, 0xc45d0571, 0x06d46f04, 0x5015ff60, 0x98fb2419, 0xbde997d6, 0x4043cc89, 0xd99e7767, 0xe842bdb0, 0x898b8807, 0x195b38e7, 0xc8eedb79, 0x7c0a47a1, 0x420fe97c, 0x841ec9f8, 0x00000000, 0x80868309, 0x2bed4832, 0x1170ac1e, 0x5a724e6c, 0x0efffbfd, 0x8538560f, 0xaed51e3d, 0x2d392736, 0x0fd9640a, 0x5ca62168, 0x5b54d19b, 0x362e3a24, 0x0a67b10c, 0x57e70f93, 0xee96d2b4, 0x9b919e1b, 0xc0c54f80, 0xdc20a261, 0x774b695a, 0x121a161c, 0x93ba0ae2, 0xa02ae5c0, 0x22e0433c, 0x1b171d12, 0x090d0b0e, 0x8bc7adf2, 0xb6a8b92d, 0x1ea9c814, 0xf1198557, 0x75074caf, 0x99ddbbee, 0x7f60fda3, 0x01269ff7, 0x72f5bc5c, 0x663bc544, 0xfb7e345b, 0x4329768b, 0x23c6dccb, 0xedfc68b6, 0xe4f163b8, 0x31dccad7, 0x63851042, 0x97224013, 0xc6112084, 0x4a247d85, 0xbb3df8d2, 0xf93211ae, 0x29a16dc7, 0x9e2f4b1d, 0xb230f3dc, 0x8652ec0d, 0xc1e3d077, 0xb3166c2b, 0x70b999a9, 0x9448fa11, 0xe9642247, 0xfc8cc4a8, 0xf03f1aa0, 0x7d2cd856, 0x3390ef22, 0x494ec787, 0x38d1c1d9, 0xcaa2fe8c, 0xd40b3698, 0xf581cfa6, 0x7ade28a5, 0xb78e26da, 0xadbfa43f, 0x3a9de42c, 0x78920d50, 0x5fcc9b6a, 0x7e466254, 0x8d13c2f6, 0xd8b8e890, 0x39f75e2e, 0xc3aff582, 0x5d80be9f, 0xd0937c69, 0xd52da96f, 0x2512b3cf, 0xac993bc8, 0x187da710, 0x9c636ee8, 0x3bbb7bdb, 0x267809cd, 0x5918f46e, 0x9ab701ec, 0x4f9aa883, 0x956e65e6, 0xffe67eaa, 0xbccf0821, 0x15e8e6ef, 0xe79bd9ba, 0x6f36ce4a, 0x9f09d4ea, 0xb07cd629, 0xa4b2af31, 0x3f23312a, 0xa59430c6, 0xa266c035, 0x4ebc3774, 0x82caa6fc, 0x90d0b0e0, 0xa7d81533, 0x04984af1, 0xecdaf741, 0xcd500e7f, 0x91f62f17, 0x4dd68d76, 0xefb04d43, 0xaa4d54cc, 0x9604dfe4, 0xd1b5e39e, 0x6a881b4c, 0x2c1fb8c1, 0x65517f46, 0x5eea049d, 0x8c355d01, 0x877473fa, 0x0b412efb, 0x671d5ab3, 0xdbd25292, 0x105633e9, 0xd647136d, 0xd7618c9a, 0xa10c7a37, 0xf8148e59, 0x133c89eb, 0xa927eece, 0x61c935b7, 0x1ce5ede1, 0x47b13c7a, 0xd2df599c, 0xf2733f55, 0x14ce7918, 0xc737bf73, 0xf7cdea53, 0xfdaa5b5f, 0x3d6f14df, 0x44db8678, 0xaff381ca, 0x68c43eb9, 0x24342c38, 0xa3405fc2, 0x1dc37216, 0xe2250cbc, 0x3c498b28, 0x0d9541ff, 0xa8017139, 0x0cb3de08, 0xb4e49cd8, 0x56c19064, 0xcb84617b, 0x32b670d5, 0x6c5c7448, 0xb85742d0];
|
||||
|
||||
// Transformations for decryption key expansion
|
||||
var U1 = [0x00000000, 0x0e090d0b, 0x1c121a16, 0x121b171d, 0x3824342c, 0x362d3927, 0x24362e3a, 0x2a3f2331, 0x70486858, 0x7e416553, 0x6c5a724e, 0x62537f45, 0x486c5c74, 0x4665517f, 0x547e4662, 0x5a774b69, 0xe090d0b0, 0xee99ddbb, 0xfc82caa6, 0xf28bc7ad, 0xd8b4e49c, 0xd6bde997, 0xc4a6fe8a, 0xcaaff381, 0x90d8b8e8, 0x9ed1b5e3, 0x8ccaa2fe, 0x82c3aff5, 0xa8fc8cc4, 0xa6f581cf, 0xb4ee96d2, 0xbae79bd9, 0xdb3bbb7b, 0xd532b670, 0xc729a16d, 0xc920ac66, 0xe31f8f57, 0xed16825c, 0xff0d9541, 0xf104984a, 0xab73d323, 0xa57ade28, 0xb761c935, 0xb968c43e, 0x9357e70f, 0x9d5eea04, 0x8f45fd19, 0x814cf012, 0x3bab6bcb, 0x35a266c0, 0x27b971dd, 0x29b07cd6, 0x038f5fe7, 0x0d8652ec, 0x1f9d45f1, 0x119448fa, 0x4be30393, 0x45ea0e98, 0x57f11985, 0x59f8148e, 0x73c737bf, 0x7dce3ab4, 0x6fd52da9, 0x61dc20a2, 0xad766df6, 0xa37f60fd, 0xb16477e0, 0xbf6d7aeb, 0x955259da, 0x9b5b54d1, 0x894043cc, 0x87494ec7, 0xdd3e05ae, 0xd33708a5, 0xc12c1fb8, 0xcf2512b3, 0xe51a3182, 0xeb133c89, 0xf9082b94, 0xf701269f, 0x4de6bd46, 0x43efb04d, 0x51f4a750, 0x5ffdaa5b, 0x75c2896a, 0x7bcb8461, 0x69d0937c, 0x67d99e77, 0x3daed51e, 0x33a7d815, 0x21bccf08, 0x2fb5c203, 0x058ae132, 0x0b83ec39, 0x1998fb24, 0x1791f62f, 0x764dd68d, 0x7844db86, 0x6a5fcc9b, 0x6456c190, 0x4e69e2a1, 0x4060efaa, 0x527bf8b7, 0x5c72f5bc, 0x0605bed5, 0x080cb3de, 0x1a17a4c3, 0x141ea9c8, 0x3e218af9, 0x302887f2, 0x223390ef, 0x2c3a9de4, 0x96dd063d, 0x98d40b36, 0x8acf1c2b, 0x84c61120, 0xaef93211, 0xa0f03f1a, 0xb2eb2807, 0xbce2250c, 0xe6956e65, 0xe89c636e, 0xfa877473, 0xf48e7978, 0xdeb15a49, 0xd0b85742, 0xc2a3405f, 0xccaa4d54, 0x41ecdaf7, 0x4fe5d7fc, 0x5dfec0e1, 0x53f7cdea, 0x79c8eedb, 0x77c1e3d0, 0x65daf4cd, 0x6bd3f9c6, 0x31a4b2af, 0x3fadbfa4, 0x2db6a8b9, 0x23bfa5b2, 0x09808683, 0x07898b88, 0x15929c95, 0x1b9b919e, 0xa17c0a47, 0xaf75074c, 0xbd6e1051, 0xb3671d5a, 0x99583e6b, 0x97513360, 0x854a247d, 0x8b432976, 0xd134621f, 0xdf3d6f14, 0xcd267809, 0xc32f7502, 0xe9105633, 0xe7195b38, 0xf5024c25, 0xfb0b412e, 0x9ad7618c, 0x94de6c87, 0x86c57b9a, 0x88cc7691, 0xa2f355a0, 0xacfa58ab, 0xbee14fb6, 0xb0e842bd, 0xea9f09d4, 0xe49604df, 0xf68d13c2, 0xf8841ec9, 0xd2bb3df8, 0xdcb230f3, 0xcea927ee, 0xc0a02ae5, 0x7a47b13c, 0x744ebc37, 0x6655ab2a, 0x685ca621, 0x42638510, 0x4c6a881b, 0x5e719f06, 0x5078920d, 0x0a0fd964, 0x0406d46f, 0x161dc372, 0x1814ce79, 0x322bed48, 0x3c22e043, 0x2e39f75e, 0x2030fa55, 0xec9ab701, 0xe293ba0a, 0xf088ad17, 0xfe81a01c, 0xd4be832d, 0xdab78e26, 0xc8ac993b, 0xc6a59430, 0x9cd2df59, 0x92dbd252, 0x80c0c54f, 0x8ec9c844, 0xa4f6eb75, 0xaaffe67e, 0xb8e4f163, 0xb6edfc68, 0x0c0a67b1, 0x02036aba, 0x10187da7, 0x1e1170ac, 0x342e539d, 0x3a275e96, 0x283c498b, 0x26354480, 0x7c420fe9, 0x724b02e2, 0x605015ff, 0x6e5918f4, 0x44663bc5, 0x4a6f36ce, 0x587421d3, 0x567d2cd8, 0x37a10c7a, 0x39a80171, 0x2bb3166c, 0x25ba1b67, 0x0f853856, 0x018c355d, 0x13972240, 0x1d9e2f4b, 0x47e96422, 0x49e06929, 0x5bfb7e34, 0x55f2733f, 0x7fcd500e, 0x71c45d05, 0x63df4a18, 0x6dd64713, 0xd731dcca, 0xd938d1c1, 0xcb23c6dc, 0xc52acbd7, 0xef15e8e6, 0xe11ce5ed, 0xf307f2f0, 0xfd0efffb, 0xa779b492, 0xa970b999, 0xbb6bae84, 0xb562a38f, 0x9f5d80be, 0x91548db5, 0x834f9aa8, 0x8d4697a3];
|
||||
var U2 = [0x00000000, 0x0b0e090d, 0x161c121a, 0x1d121b17, 0x2c382434, 0x27362d39, 0x3a24362e, 0x312a3f23, 0x58704868, 0x537e4165, 0x4e6c5a72, 0x4562537f, 0x74486c5c, 0x7f466551, 0x62547e46, 0x695a774b, 0xb0e090d0, 0xbbee99dd, 0xa6fc82ca, 0xadf28bc7, 0x9cd8b4e4, 0x97d6bde9, 0x8ac4a6fe, 0x81caaff3, 0xe890d8b8, 0xe39ed1b5, 0xfe8ccaa2, 0xf582c3af, 0xc4a8fc8c, 0xcfa6f581, 0xd2b4ee96, 0xd9bae79b, 0x7bdb3bbb, 0x70d532b6, 0x6dc729a1, 0x66c920ac, 0x57e31f8f, 0x5ced1682, 0x41ff0d95, 0x4af10498, 0x23ab73d3, 0x28a57ade, 0x35b761c9, 0x3eb968c4, 0x0f9357e7, 0x049d5eea, 0x198f45fd, 0x12814cf0, 0xcb3bab6b, 0xc035a266, 0xdd27b971, 0xd629b07c, 0xe7038f5f, 0xec0d8652, 0xf11f9d45, 0xfa119448, 0x934be303, 0x9845ea0e, 0x8557f119, 0x8e59f814, 0xbf73c737, 0xb47dce3a, 0xa96fd52d, 0xa261dc20, 0xf6ad766d, 0xfda37f60, 0xe0b16477, 0xebbf6d7a, 0xda955259, 0xd19b5b54, 0xcc894043, 0xc787494e, 0xaedd3e05, 0xa5d33708, 0xb8c12c1f, 0xb3cf2512, 0x82e51a31, 0x89eb133c, 0x94f9082b, 0x9ff70126, 0x464de6bd, 0x4d43efb0, 0x5051f4a7, 0x5b5ffdaa, 0x6a75c289, 0x617bcb84, 0x7c69d093, 0x7767d99e, 0x1e3daed5, 0x1533a7d8, 0x0821bccf, 0x032fb5c2, 0x32058ae1, 0x390b83ec, 0x241998fb, 0x2f1791f6, 0x8d764dd6, 0x867844db, 0x9b6a5fcc, 0x906456c1, 0xa14e69e2, 0xaa4060ef, 0xb7527bf8, 0xbc5c72f5, 0xd50605be, 0xde080cb3, 0xc31a17a4, 0xc8141ea9, 0xf93e218a, 0xf2302887, 0xef223390, 0xe42c3a9d, 0x3d96dd06, 0x3698d40b, 0x2b8acf1c, 0x2084c611, 0x11aef932, 0x1aa0f03f, 0x07b2eb28, 0x0cbce225, 0x65e6956e, 0x6ee89c63, 0x73fa8774, 0x78f48e79, 0x49deb15a, 0x42d0b857, 0x5fc2a340, 0x54ccaa4d, 0xf741ecda, 0xfc4fe5d7, 0xe15dfec0, 0xea53f7cd, 0xdb79c8ee, 0xd077c1e3, 0xcd65daf4, 0xc66bd3f9, 0xaf31a4b2, 0xa43fadbf, 0xb92db6a8, 0xb223bfa5, 0x83098086, 0x8807898b, 0x9515929c, 0x9e1b9b91, 0x47a17c0a, 0x4caf7507, 0x51bd6e10, 0x5ab3671d, 0x6b99583e, 0x60975133, 0x7d854a24, 0x768b4329, 0x1fd13462, 0x14df3d6f, 0x09cd2678, 0x02c32f75, 0x33e91056, 0x38e7195b, 0x25f5024c, 0x2efb0b41, 0x8c9ad761, 0x8794de6c, 0x9a86c57b, 0x9188cc76, 0xa0a2f355, 0xabacfa58, 0xb6bee14f, 0xbdb0e842, 0xd4ea9f09, 0xdfe49604, 0xc2f68d13, 0xc9f8841e, 0xf8d2bb3d, 0xf3dcb230, 0xeecea927, 0xe5c0a02a, 0x3c7a47b1, 0x37744ebc, 0x2a6655ab, 0x21685ca6, 0x10426385, 0x1b4c6a88, 0x065e719f, 0x0d507892, 0x640a0fd9, 0x6f0406d4, 0x72161dc3, 0x791814ce, 0x48322bed, 0x433c22e0, 0x5e2e39f7, 0x552030fa, 0x01ec9ab7, 0x0ae293ba, 0x17f088ad, 0x1cfe81a0, 0x2dd4be83, 0x26dab78e, 0x3bc8ac99, 0x30c6a594, 0x599cd2df, 0x5292dbd2, 0x4f80c0c5, 0x448ec9c8, 0x75a4f6eb, 0x7eaaffe6, 0x63b8e4f1, 0x68b6edfc, 0xb10c0a67, 0xba02036a, 0xa710187d, 0xac1e1170, 0x9d342e53, 0x963a275e, 0x8b283c49, 0x80263544, 0xe97c420f, 0xe2724b02, 0xff605015, 0xf46e5918, 0xc544663b, 0xce4a6f36, 0xd3587421, 0xd8567d2c, 0x7a37a10c, 0x7139a801, 0x6c2bb316, 0x6725ba1b, 0x560f8538, 0x5d018c35, 0x40139722, 0x4b1d9e2f, 0x2247e964, 0x2949e069, 0x345bfb7e, 0x3f55f273, 0x0e7fcd50, 0x0571c45d, 0x1863df4a, 0x136dd647, 0xcad731dc, 0xc1d938d1, 0xdccb23c6, 0xd7c52acb, 0xe6ef15e8, 0xede11ce5, 0xf0f307f2, 0xfbfd0eff, 0x92a779b4, 0x99a970b9, 0x84bb6bae, 0x8fb562a3, 0xbe9f5d80, 0xb591548d, 0xa8834f9a, 0xa38d4697];
|
||||
var U3 = [0x00000000, 0x0d0b0e09, 0x1a161c12, 0x171d121b, 0x342c3824, 0x3927362d, 0x2e3a2436, 0x23312a3f, 0x68587048, 0x65537e41, 0x724e6c5a, 0x7f456253, 0x5c74486c, 0x517f4665, 0x4662547e, 0x4b695a77, 0xd0b0e090, 0xddbbee99, 0xcaa6fc82, 0xc7adf28b, 0xe49cd8b4, 0xe997d6bd, 0xfe8ac4a6, 0xf381caaf, 0xb8e890d8, 0xb5e39ed1, 0xa2fe8cca, 0xaff582c3, 0x8cc4a8fc, 0x81cfa6f5, 0x96d2b4ee, 0x9bd9bae7, 0xbb7bdb3b, 0xb670d532, 0xa16dc729, 0xac66c920, 0x8f57e31f, 0x825ced16, 0x9541ff0d, 0x984af104, 0xd323ab73, 0xde28a57a, 0xc935b761, 0xc43eb968, 0xe70f9357, 0xea049d5e, 0xfd198f45, 0xf012814c, 0x6bcb3bab, 0x66c035a2, 0x71dd27b9, 0x7cd629b0, 0x5fe7038f, 0x52ec0d86, 0x45f11f9d, 0x48fa1194, 0x03934be3, 0x0e9845ea, 0x198557f1, 0x148e59f8, 0x37bf73c7, 0x3ab47dce, 0x2da96fd5, 0x20a261dc, 0x6df6ad76, 0x60fda37f, 0x77e0b164, 0x7aebbf6d, 0x59da9552, 0x54d19b5b, 0x43cc8940, 0x4ec78749, 0x05aedd3e, 0x08a5d337, 0x1fb8c12c, 0x12b3cf25, 0x3182e51a, 0x3c89eb13, 0x2b94f908, 0x269ff701, 0xbd464de6, 0xb04d43ef, 0xa75051f4, 0xaa5b5ffd, 0x896a75c2, 0x84617bcb, 0x937c69d0, 0x9e7767d9, 0xd51e3dae, 0xd81533a7, 0xcf0821bc, 0xc2032fb5, 0xe132058a, 0xec390b83, 0xfb241998, 0xf62f1791, 0xd68d764d, 0xdb867844, 0xcc9b6a5f, 0xc1906456, 0xe2a14e69, 0xefaa4060, 0xf8b7527b, 0xf5bc5c72, 0xbed50605, 0xb3de080c, 0xa4c31a17, 0xa9c8141e, 0x8af93e21, 0x87f23028, 0x90ef2233, 0x9de42c3a, 0x063d96dd, 0x0b3698d4, 0x1c2b8acf, 0x112084c6, 0x3211aef9, 0x3f1aa0f0, 0x2807b2eb, 0x250cbce2, 0x6e65e695, 0x636ee89c, 0x7473fa87, 0x7978f48e, 0x5a49deb1, 0x5742d0b8, 0x405fc2a3, 0x4d54ccaa, 0xdaf741ec, 0xd7fc4fe5, 0xc0e15dfe, 0xcdea53f7, 0xeedb79c8, 0xe3d077c1, 0xf4cd65da, 0xf9c66bd3, 0xb2af31a4, 0xbfa43fad, 0xa8b92db6, 0xa5b223bf, 0x86830980, 0x8b880789, 0x9c951592, 0x919e1b9b, 0x0a47a17c, 0x074caf75, 0x1051bd6e, 0x1d5ab367, 0x3e6b9958, 0x33609751, 0x247d854a, 0x29768b43, 0x621fd134, 0x6f14df3d, 0x7809cd26, 0x7502c32f, 0x5633e910, 0x5b38e719, 0x4c25f502, 0x412efb0b, 0x618c9ad7, 0x6c8794de, 0x7b9a86c5, 0x769188cc, 0x55a0a2f3, 0x58abacfa, 0x4fb6bee1, 0x42bdb0e8, 0x09d4ea9f, 0x04dfe496, 0x13c2f68d, 0x1ec9f884, 0x3df8d2bb, 0x30f3dcb2, 0x27eecea9, 0x2ae5c0a0, 0xb13c7a47, 0xbc37744e, 0xab2a6655, 0xa621685c, 0x85104263, 0x881b4c6a, 0x9f065e71, 0x920d5078, 0xd9640a0f, 0xd46f0406, 0xc372161d, 0xce791814, 0xed48322b, 0xe0433c22, 0xf75e2e39, 0xfa552030, 0xb701ec9a, 0xba0ae293, 0xad17f088, 0xa01cfe81, 0x832dd4be, 0x8e26dab7, 0x993bc8ac, 0x9430c6a5, 0xdf599cd2, 0xd25292db, 0xc54f80c0, 0xc8448ec9, 0xeb75a4f6, 0xe67eaaff, 0xf163b8e4, 0xfc68b6ed, 0x67b10c0a, 0x6aba0203, 0x7da71018, 0x70ac1e11, 0x539d342e, 0x5e963a27, 0x498b283c, 0x44802635, 0x0fe97c42, 0x02e2724b, 0x15ff6050, 0x18f46e59, 0x3bc54466, 0x36ce4a6f, 0x21d35874, 0x2cd8567d, 0x0c7a37a1, 0x017139a8, 0x166c2bb3, 0x1b6725ba, 0x38560f85, 0x355d018c, 0x22401397, 0x2f4b1d9e, 0x642247e9, 0x692949e0, 0x7e345bfb, 0x733f55f2, 0x500e7fcd, 0x5d0571c4, 0x4a1863df, 0x47136dd6, 0xdccad731, 0xd1c1d938, 0xc6dccb23, 0xcbd7c52a, 0xe8e6ef15, 0xe5ede11c, 0xf2f0f307, 0xfffbfd0e, 0xb492a779, 0xb999a970, 0xae84bb6b, 0xa38fb562, 0x80be9f5d, 0x8db59154, 0x9aa8834f, 0x97a38d46];
|
||||
var U4 = [0x00000000, 0x090d0b0e, 0x121a161c, 0x1b171d12, 0x24342c38, 0x2d392736, 0x362e3a24, 0x3f23312a, 0x48685870, 0x4165537e, 0x5a724e6c, 0x537f4562, 0x6c5c7448, 0x65517f46, 0x7e466254, 0x774b695a, 0x90d0b0e0, 0x99ddbbee, 0x82caa6fc, 0x8bc7adf2, 0xb4e49cd8, 0xbde997d6, 0xa6fe8ac4, 0xaff381ca, 0xd8b8e890, 0xd1b5e39e, 0xcaa2fe8c, 0xc3aff582, 0xfc8cc4a8, 0xf581cfa6, 0xee96d2b4, 0xe79bd9ba, 0x3bbb7bdb, 0x32b670d5, 0x29a16dc7, 0x20ac66c9, 0x1f8f57e3, 0x16825ced, 0x0d9541ff, 0x04984af1, 0x73d323ab, 0x7ade28a5, 0x61c935b7, 0x68c43eb9, 0x57e70f93, 0x5eea049d, 0x45fd198f, 0x4cf01281, 0xab6bcb3b, 0xa266c035, 0xb971dd27, 0xb07cd629, 0x8f5fe703, 0x8652ec0d, 0x9d45f11f, 0x9448fa11, 0xe303934b, 0xea0e9845, 0xf1198557, 0xf8148e59, 0xc737bf73, 0xce3ab47d, 0xd52da96f, 0xdc20a261, 0x766df6ad, 0x7f60fda3, 0x6477e0b1, 0x6d7aebbf, 0x5259da95, 0x5b54d19b, 0x4043cc89, 0x494ec787, 0x3e05aedd, 0x3708a5d3, 0x2c1fb8c1, 0x2512b3cf, 0x1a3182e5, 0x133c89eb, 0x082b94f9, 0x01269ff7, 0xe6bd464d, 0xefb04d43, 0xf4a75051, 0xfdaa5b5f, 0xc2896a75, 0xcb84617b, 0xd0937c69, 0xd99e7767, 0xaed51e3d, 0xa7d81533, 0xbccf0821, 0xb5c2032f, 0x8ae13205, 0x83ec390b, 0x98fb2419, 0x91f62f17, 0x4dd68d76, 0x44db8678, 0x5fcc9b6a, 0x56c19064, 0x69e2a14e, 0x60efaa40, 0x7bf8b752, 0x72f5bc5c, 0x05bed506, 0x0cb3de08, 0x17a4c31a, 0x1ea9c814, 0x218af93e, 0x2887f230, 0x3390ef22, 0x3a9de42c, 0xdd063d96, 0xd40b3698, 0xcf1c2b8a, 0xc6112084, 0xf93211ae, 0xf03f1aa0, 0xeb2807b2, 0xe2250cbc, 0x956e65e6, 0x9c636ee8, 0x877473fa, 0x8e7978f4, 0xb15a49de, 0xb85742d0, 0xa3405fc2, 0xaa4d54cc, 0xecdaf741, 0xe5d7fc4f, 0xfec0e15d, 0xf7cdea53, 0xc8eedb79, 0xc1e3d077, 0xdaf4cd65, 0xd3f9c66b, 0xa4b2af31, 0xadbfa43f, 0xb6a8b92d, 0xbfa5b223, 0x80868309, 0x898b8807, 0x929c9515, 0x9b919e1b, 0x7c0a47a1, 0x75074caf, 0x6e1051bd, 0x671d5ab3, 0x583e6b99, 0x51336097, 0x4a247d85, 0x4329768b, 0x34621fd1, 0x3d6f14df, 0x267809cd, 0x2f7502c3, 0x105633e9, 0x195b38e7, 0x024c25f5, 0x0b412efb, 0xd7618c9a, 0xde6c8794, 0xc57b9a86, 0xcc769188, 0xf355a0a2, 0xfa58abac, 0xe14fb6be, 0xe842bdb0, 0x9f09d4ea, 0x9604dfe4, 0x8d13c2f6, 0x841ec9f8, 0xbb3df8d2, 0xb230f3dc, 0xa927eece, 0xa02ae5c0, 0x47b13c7a, 0x4ebc3774, 0x55ab2a66, 0x5ca62168, 0x63851042, 0x6a881b4c, 0x719f065e, 0x78920d50, 0x0fd9640a, 0x06d46f04, 0x1dc37216, 0x14ce7918, 0x2bed4832, 0x22e0433c, 0x39f75e2e, 0x30fa5520, 0x9ab701ec, 0x93ba0ae2, 0x88ad17f0, 0x81a01cfe, 0xbe832dd4, 0xb78e26da, 0xac993bc8, 0xa59430c6, 0xd2df599c, 0xdbd25292, 0xc0c54f80, 0xc9c8448e, 0xf6eb75a4, 0xffe67eaa, 0xe4f163b8, 0xedfc68b6, 0x0a67b10c, 0x036aba02, 0x187da710, 0x1170ac1e, 0x2e539d34, 0x275e963a, 0x3c498b28, 0x35448026, 0x420fe97c, 0x4b02e272, 0x5015ff60, 0x5918f46e, 0x663bc544, 0x6f36ce4a, 0x7421d358, 0x7d2cd856, 0xa10c7a37, 0xa8017139, 0xb3166c2b, 0xba1b6725, 0x8538560f, 0x8c355d01, 0x97224013, 0x9e2f4b1d, 0xe9642247, 0xe0692949, 0xfb7e345b, 0xf2733f55, 0xcd500e7f, 0xc45d0571, 0xdf4a1863, 0xd647136d, 0x31dccad7, 0x38d1c1d9, 0x23c6dccb, 0x2acbd7c5, 0x15e8e6ef, 0x1ce5ede1, 0x07f2f0f3, 0x0efffbfd, 0x79b492a7, 0x70b999a9, 0x6bae84bb, 0x62a38fb5, 0x5d80be9f, 0x548db591, 0x4f9aa883, 0x4697a38d];
|
||||
|
||||
function convertToInt32(bytes) {
|
||||
var result = [];
|
||||
for (var i = 0; i < bytes.length; i += 4) {
|
||||
result.push(
|
||||
(bytes[i ] << 24) |
|
||||
(bytes[i + 1] << 16) |
|
||||
(bytes[i + 2] << 8) |
|
||||
bytes[i + 3]
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
var AES = function(key) {
|
||||
if (!(this instanceof AES)) {
|
||||
throw Error('AES must be instanitated with `new`');
|
||||
}
|
||||
|
||||
Object.defineProperty(this, 'key', {
|
||||
value: coerceArray(key, true)
|
||||
});
|
||||
|
||||
this._prepare();
|
||||
}
|
||||
|
||||
|
||||
AES.prototype._prepare = function() {
|
||||
|
||||
var rounds = numberOfRounds[this.key.length];
|
||||
if (rounds == null) {
|
||||
throw new Error('invalid key size (must be 16, 24 or 32 bytes)');
|
||||
}
|
||||
|
||||
// encryption round keys
|
||||
this._Ke = [];
|
||||
|
||||
// decryption round keys
|
||||
this._Kd = [];
|
||||
|
||||
for (var i = 0; i <= rounds; i++) {
|
||||
this._Ke.push([0, 0, 0, 0]);
|
||||
this._Kd.push([0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
var roundKeyCount = (rounds + 1) * 4;
|
||||
var KC = this.key.length / 4;
|
||||
|
||||
// convert the key into ints
|
||||
var tk = convertToInt32(this.key);
|
||||
|
||||
// copy values into round key arrays
|
||||
var index;
|
||||
for (var i = 0; i < KC; i++) {
|
||||
index = i >> 2;
|
||||
this._Ke[index][i % 4] = tk[i];
|
||||
this._Kd[rounds - index][i % 4] = tk[i];
|
||||
}
|
||||
|
||||
// key expansion (fips-197 section 5.2)
|
||||
var rconpointer = 0;
|
||||
var t = KC, tt;
|
||||
while (t < roundKeyCount) {
|
||||
tt = tk[KC - 1];
|
||||
tk[0] ^= ((S[(tt >> 16) & 0xFF] << 24) ^
|
||||
(S[(tt >> 8) & 0xFF] << 16) ^
|
||||
(S[ tt & 0xFF] << 8) ^
|
||||
S[(tt >> 24) & 0xFF] ^
|
||||
(rcon[rconpointer] << 24));
|
||||
rconpointer += 1;
|
||||
|
||||
// key expansion (for non-256 bit)
|
||||
if (KC != 8) {
|
||||
for (var i = 1; i < KC; i++) {
|
||||
tk[i] ^= tk[i - 1];
|
||||
}
|
||||
|
||||
// key expansion for 256-bit keys is "slightly different" (fips-197)
|
||||
} else {
|
||||
for (var i = 1; i < (KC / 2); i++) {
|
||||
tk[i] ^= tk[i - 1];
|
||||
}
|
||||
tt = tk[(KC / 2) - 1];
|
||||
|
||||
tk[KC / 2] ^= (S[ tt & 0xFF] ^
|
||||
(S[(tt >> 8) & 0xFF] << 8) ^
|
||||
(S[(tt >> 16) & 0xFF] << 16) ^
|
||||
(S[(tt >> 24) & 0xFF] << 24));
|
||||
|
||||
for (var i = (KC / 2) + 1; i < KC; i++) {
|
||||
tk[i] ^= tk[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// copy values into round key arrays
|
||||
var i = 0, r, c;
|
||||
while (i < KC && t < roundKeyCount) {
|
||||
r = t >> 2;
|
||||
c = t % 4;
|
||||
this._Ke[r][c] = tk[i];
|
||||
this._Kd[rounds - r][c] = tk[i++];
|
||||
t++;
|
||||
}
|
||||
}
|
||||
|
||||
// inverse-cipher-ify the decryption round key (fips-197 section 5.3)
|
||||
for (var r = 1; r < rounds; r++) {
|
||||
for (var c = 0; c < 4; c++) {
|
||||
tt = this._Kd[r][c];
|
||||
this._Kd[r][c] = (U1[(tt >> 24) & 0xFF] ^
|
||||
U2[(tt >> 16) & 0xFF] ^
|
||||
U3[(tt >> 8) & 0xFF] ^
|
||||
U4[ tt & 0xFF]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AES.prototype.encrypt = function(plaintext) {
|
||||
if (plaintext.length != 16) {
|
||||
throw new Error('invalid plaintext size (must be 16 bytes)');
|
||||
}
|
||||
|
||||
var rounds = this._Ke.length - 1;
|
||||
var a = [0, 0, 0, 0];
|
||||
|
||||
// convert plaintext to (ints ^ key)
|
||||
var t = convertToInt32(plaintext);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
t[i] ^= this._Ke[0][i];
|
||||
}
|
||||
|
||||
// apply round transforms
|
||||
for (var r = 1; r < rounds; r++) {
|
||||
for (var i = 0; i < 4; i++) {
|
||||
a[i] = (T1[(t[ i ] >> 24) & 0xff] ^
|
||||
T2[(t[(i + 1) % 4] >> 16) & 0xff] ^
|
||||
T3[(t[(i + 2) % 4] >> 8) & 0xff] ^
|
||||
T4[ t[(i + 3) % 4] & 0xff] ^
|
||||
this._Ke[r][i]);
|
||||
}
|
||||
t = a.slice();
|
||||
}
|
||||
|
||||
// the last round is special
|
||||
var result = createArray(16), tt;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
tt = this._Ke[rounds][i];
|
||||
result[4 * i ] = (S[(t[ i ] >> 24) & 0xff] ^ (tt >> 24)) & 0xff;
|
||||
result[4 * i + 1] = (S[(t[(i + 1) % 4] >> 16) & 0xff] ^ (tt >> 16)) & 0xff;
|
||||
result[4 * i + 2] = (S[(t[(i + 2) % 4] >> 8) & 0xff] ^ (tt >> 8)) & 0xff;
|
||||
result[4 * i + 3] = (S[ t[(i + 3) % 4] & 0xff] ^ tt ) & 0xff;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
AES.prototype.decrypt = function(ciphertext) {
|
||||
if (ciphertext.length != 16) {
|
||||
throw new Error('invalid ciphertext size (must be 16 bytes)');
|
||||
}
|
||||
|
||||
var rounds = this._Kd.length - 1;
|
||||
var a = [0, 0, 0, 0];
|
||||
|
||||
// convert plaintext to (ints ^ key)
|
||||
var t = convertToInt32(ciphertext);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
t[i] ^= this._Kd[0][i];
|
||||
}
|
||||
|
||||
// apply round transforms
|
||||
for (var r = 1; r < rounds; r++) {
|
||||
for (var i = 0; i < 4; i++) {
|
||||
a[i] = (T5[(t[ i ] >> 24) & 0xff] ^
|
||||
T6[(t[(i + 3) % 4] >> 16) & 0xff] ^
|
||||
T7[(t[(i + 2) % 4] >> 8) & 0xff] ^
|
||||
T8[ t[(i + 1) % 4] & 0xff] ^
|
||||
this._Kd[r][i]);
|
||||
}
|
||||
t = a.slice();
|
||||
}
|
||||
|
||||
// the last round is special
|
||||
var result = createArray(16), tt;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
tt = this._Kd[rounds][i];
|
||||
result[4 * i ] = (Si[(t[ i ] >> 24) & 0xff] ^ (tt >> 24)) & 0xff;
|
||||
result[4 * i + 1] = (Si[(t[(i + 3) % 4] >> 16) & 0xff] ^ (tt >> 16)) & 0xff;
|
||||
result[4 * i + 2] = (Si[(t[(i + 2) % 4] >> 8) & 0xff] ^ (tt >> 8)) & 0xff;
|
||||
result[4 * i + 3] = (Si[ t[(i + 1) % 4] & 0xff] ^ tt ) & 0xff;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mode Of Operation - Electonic Codebook (ECB)
|
||||
*/
|
||||
var ModeOfOperationECB = function(key) {
|
||||
if (!(this instanceof ModeOfOperationECB)) {
|
||||
throw Error('AES must be instanitated with `new`');
|
||||
}
|
||||
|
||||
this.description = "Electronic Code Block";
|
||||
this.name = "ecb";
|
||||
|
||||
this._aes = new AES(key);
|
||||
}
|
||||
|
||||
ModeOfOperationECB.prototype.encrypt = function(plaintext) {
|
||||
plaintext = coerceArray(plaintext);
|
||||
|
||||
if ((plaintext.length % 16) !== 0) {
|
||||
throw new Error('invalid plaintext size (must be multiple of 16 bytes)');
|
||||
}
|
||||
|
||||
var ciphertext = createArray(plaintext.length);
|
||||
var block = createArray(16);
|
||||
|
||||
for (var i = 0; i < plaintext.length; i += 16) {
|
||||
copyArray(plaintext, block, 0, i, i + 16);
|
||||
block = this._aes.encrypt(block);
|
||||
copyArray(block, ciphertext, i);
|
||||
}
|
||||
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
ModeOfOperationECB.prototype.decrypt = function(ciphertext) {
|
||||
ciphertext = coerceArray(ciphertext);
|
||||
|
||||
if ((ciphertext.length % 16) !== 0) {
|
||||
throw new Error('invalid ciphertext size (must be multiple of 16 bytes)');
|
||||
}
|
||||
|
||||
var plaintext = createArray(ciphertext.length);
|
||||
var block = createArray(16);
|
||||
|
||||
for (var i = 0; i < ciphertext.length; i += 16) {
|
||||
copyArray(ciphertext, block, 0, i, i + 16);
|
||||
block = this._aes.decrypt(block);
|
||||
copyArray(block, plaintext, i);
|
||||
}
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mode Of Operation - Cipher Block Chaining (CBC)
|
||||
*/
|
||||
var ModeOfOperationCBC = function(key, iv) {
|
||||
if (!(this instanceof ModeOfOperationCBC)) {
|
||||
throw Error('AES must be instanitated with `new`');
|
||||
}
|
||||
|
||||
this.description = "Cipher Block Chaining";
|
||||
this.name = "cbc";
|
||||
|
||||
if (!iv) {
|
||||
iv = createArray(16);
|
||||
|
||||
} else if (iv.length != 16) {
|
||||
throw new Error('invalid initialation vector size (must be 16 bytes)');
|
||||
}
|
||||
|
||||
this._lastCipherblock = coerceArray(iv, true);
|
||||
|
||||
this._aes = new AES(key);
|
||||
}
|
||||
|
||||
ModeOfOperationCBC.prototype.encrypt = function(plaintext) {
|
||||
plaintext = coerceArray(plaintext);
|
||||
|
||||
if ((plaintext.length % 16) !== 0) {
|
||||
throw new Error('invalid plaintext size (must be multiple of 16 bytes)');
|
||||
}
|
||||
|
||||
var ciphertext = createArray(plaintext.length);
|
||||
var block = createArray(16);
|
||||
|
||||
for (var i = 0; i < plaintext.length; i += 16) {
|
||||
copyArray(plaintext, block, 0, i, i + 16);
|
||||
|
||||
for (var j = 0; j < 16; j++) {
|
||||
block[j] ^= this._lastCipherblock[j];
|
||||
}
|
||||
|
||||
this._lastCipherblock = this._aes.encrypt(block);
|
||||
copyArray(this._lastCipherblock, ciphertext, i);
|
||||
}
|
||||
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
ModeOfOperationCBC.prototype.decrypt = function(ciphertext) {
|
||||
ciphertext = coerceArray(ciphertext);
|
||||
|
||||
if ((ciphertext.length % 16) !== 0) {
|
||||
throw new Error('invalid ciphertext size (must be multiple of 16 bytes)');
|
||||
}
|
||||
|
||||
var plaintext = createArray(ciphertext.length);
|
||||
var block = createArray(16);
|
||||
|
||||
for (var i = 0; i < ciphertext.length; i += 16) {
|
||||
copyArray(ciphertext, block, 0, i, i + 16);
|
||||
block = this._aes.decrypt(block);
|
||||
|
||||
for (var j = 0; j < 16; j++) {
|
||||
plaintext[i + j] = block[j] ^ this._lastCipherblock[j];
|
||||
}
|
||||
|
||||
copyArray(ciphertext, this._lastCipherblock, 0, i, i + 16);
|
||||
}
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mode Of Operation - Cipher Feedback (CFB)
|
||||
*/
|
||||
var ModeOfOperationCFB = function(key, iv, segmentSize) {
|
||||
if (!(this instanceof ModeOfOperationCFB)) {
|
||||
throw Error('AES must be instanitated with `new`');
|
||||
}
|
||||
|
||||
this.description = "Cipher Feedback";
|
||||
this.name = "cfb";
|
||||
|
||||
if (!iv) {
|
||||
iv = createArray(16);
|
||||
|
||||
} else if (iv.length != 16) {
|
||||
throw new Error('invalid initialation vector size (must be 16 size)');
|
||||
}
|
||||
|
||||
if (!segmentSize) { segmentSize = 1; }
|
||||
|
||||
this.segmentSize = segmentSize;
|
||||
|
||||
this._shiftRegister = coerceArray(iv, true);
|
||||
|
||||
this._aes = new AES(key);
|
||||
}
|
||||
|
||||
ModeOfOperationCFB.prototype.encrypt = function(plaintext) {
|
||||
if ((plaintext.length % this.segmentSize) != 0) {
|
||||
throw new Error('invalid plaintext size (must be segmentSize bytes)');
|
||||
}
|
||||
|
||||
var encrypted = coerceArray(plaintext, true);
|
||||
|
||||
var xorSegment;
|
||||
for (var i = 0; i < encrypted.length; i += this.segmentSize) {
|
||||
xorSegment = this._aes.encrypt(this._shiftRegister);
|
||||
for (var j = 0; j < this.segmentSize; j++) {
|
||||
encrypted[i + j] ^= xorSegment[j];
|
||||
}
|
||||
|
||||
// Shift the register
|
||||
copyArray(this._shiftRegister, this._shiftRegister, 0, this.segmentSize);
|
||||
copyArray(encrypted, this._shiftRegister, 16 - this.segmentSize, i, i + this.segmentSize);
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
ModeOfOperationCFB.prototype.decrypt = function(ciphertext) {
|
||||
if ((ciphertext.length % this.segmentSize) != 0) {
|
||||
throw new Error('invalid ciphertext size (must be segmentSize bytes)');
|
||||
}
|
||||
|
||||
var plaintext = coerceArray(ciphertext, true);
|
||||
|
||||
var xorSegment;
|
||||
for (var i = 0; i < plaintext.length; i += this.segmentSize) {
|
||||
xorSegment = this._aes.encrypt(this._shiftRegister);
|
||||
|
||||
for (var j = 0; j < this.segmentSize; j++) {
|
||||
plaintext[i + j] ^= xorSegment[j];
|
||||
}
|
||||
|
||||
// Shift the register
|
||||
copyArray(this._shiftRegister, this._shiftRegister, 0, this.segmentSize);
|
||||
copyArray(ciphertext, this._shiftRegister, 16 - this.segmentSize, i, i + this.segmentSize);
|
||||
}
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode Of Operation - Output Feedback (OFB)
|
||||
*/
|
||||
var ModeOfOperationOFB = function(key, iv) {
|
||||
if (!(this instanceof ModeOfOperationOFB)) {
|
||||
throw Error('AES must be instanitated with `new`');
|
||||
}
|
||||
|
||||
this.description = "Output Feedback";
|
||||
this.name = "ofb";
|
||||
|
||||
if (!iv) {
|
||||
iv = createArray(16);
|
||||
|
||||
} else if (iv.length != 16) {
|
||||
throw new Error('invalid initialation vector size (must be 16 bytes)');
|
||||
}
|
||||
|
||||
this._lastPrecipher = coerceArray(iv, true);
|
||||
this._lastPrecipherIndex = 16;
|
||||
|
||||
this._aes = new AES(key);
|
||||
}
|
||||
|
||||
ModeOfOperationOFB.prototype.encrypt = function(plaintext) {
|
||||
var encrypted = coerceArray(plaintext, true);
|
||||
|
||||
for (var i = 0; i < encrypted.length; i++) {
|
||||
if (this._lastPrecipherIndex === 16) {
|
||||
this._lastPrecipher = this._aes.encrypt(this._lastPrecipher);
|
||||
this._lastPrecipherIndex = 0;
|
||||
}
|
||||
encrypted[i] ^= this._lastPrecipher[this._lastPrecipherIndex++];
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
// Decryption is symetric
|
||||
ModeOfOperationOFB.prototype.decrypt = ModeOfOperationOFB.prototype.encrypt;
|
||||
|
||||
|
||||
/**
|
||||
* Counter object for CTR common mode of operation
|
||||
*/
|
||||
var Counter = function(initialValue) {
|
||||
if (!(this instanceof Counter)) {
|
||||
throw Error('Counter must be instanitated with `new`');
|
||||
}
|
||||
|
||||
// We allow 0, but anything false-ish uses the default 1
|
||||
if (initialValue !== 0 && !initialValue) { initialValue = 1; }
|
||||
|
||||
if (typeof(initialValue) === 'number') {
|
||||
this._counter = createArray(16);
|
||||
this.setValue(initialValue);
|
||||
|
||||
} else {
|
||||
this.setBytes(initialValue);
|
||||
}
|
||||
}
|
||||
|
||||
Counter.prototype.setValue = function(value) {
|
||||
if (typeof(value) !== 'number' || parseInt(value) != value) {
|
||||
throw new Error('invalid counter value (must be an integer)');
|
||||
}
|
||||
|
||||
// We cannot safely handle numbers beyond the safe range for integers
|
||||
if (value > Number.MAX_SAFE_INTEGER) {
|
||||
throw new Error('integer value out of safe range');
|
||||
}
|
||||
|
||||
for (var index = 15; index >= 0; --index) {
|
||||
this._counter[index] = value % 256;
|
||||
value = parseInt(value / 256);
|
||||
}
|
||||
}
|
||||
|
||||
Counter.prototype.setBytes = function(bytes) {
|
||||
bytes = coerceArray(bytes, true);
|
||||
|
||||
if (bytes.length != 16) {
|
||||
throw new Error('invalid counter bytes size (must be 16 bytes)');
|
||||
}
|
||||
|
||||
this._counter = bytes;
|
||||
};
|
||||
|
||||
Counter.prototype.increment = function() {
|
||||
for (var i = 15; i >= 0; i--) {
|
||||
if (this._counter[i] === 255) {
|
||||
this._counter[i] = 0;
|
||||
} else {
|
||||
this._counter[i]++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mode Of Operation - Counter (CTR)
|
||||
*/
|
||||
var ModeOfOperationCTR = function(key, counter) {
|
||||
if (!(this instanceof ModeOfOperationCTR)) {
|
||||
throw Error('AES must be instanitated with `new`');
|
||||
}
|
||||
|
||||
this.description = "Counter";
|
||||
this.name = "ctr";
|
||||
|
||||
if (!(counter instanceof Counter)) {
|
||||
counter = new Counter(counter)
|
||||
}
|
||||
|
||||
this._counter = counter;
|
||||
|
||||
this._remainingCounter = null;
|
||||
this._remainingCounterIndex = 16;
|
||||
|
||||
this._aes = new AES(key);
|
||||
}
|
||||
|
||||
ModeOfOperationCTR.prototype.encrypt = function(plaintext) {
|
||||
var encrypted = coerceArray(plaintext, true);
|
||||
|
||||
for (var i = 0; i < encrypted.length; i++) {
|
||||
if (this._remainingCounterIndex === 16) {
|
||||
this._remainingCounter = this._aes.encrypt(this._counter._counter);
|
||||
this._remainingCounterIndex = 0;
|
||||
this._counter.increment();
|
||||
}
|
||||
encrypted[i] ^= this._remainingCounter[this._remainingCounterIndex++];
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
// Decryption is symetric
|
||||
ModeOfOperationCTR.prototype.decrypt = ModeOfOperationCTR.prototype.encrypt;
|
||||
|
||||
|
||||
///////////////////////
|
||||
// Padding
|
||||
|
||||
// See:https://tools.ietf.org/html/rfc2315
|
||||
function pkcs7pad(data) {
|
||||
data = coerceArray(data, true);
|
||||
var padder = 16 - (data.length % 16);
|
||||
var result = createArray(data.length + padder);
|
||||
copyArray(data, result);
|
||||
for (var i = data.length; i < result.length; i++) {
|
||||
result[i] = padder;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pkcs7strip(data) {
|
||||
data = coerceArray(data, true);
|
||||
if (data.length < 16) { throw new Error('PKCS#7 invalid length'); }
|
||||
|
||||
var padder = data[data.length - 1];
|
||||
if (padder > 16) { throw new Error('PKCS#7 padding byte out of range'); }
|
||||
|
||||
var length = data.length - padder;
|
||||
for (var i = 0; i < padder; i++) {
|
||||
if (data[length + i] !== padder) {
|
||||
throw new Error('PKCS#7 invalid padding byte');
|
||||
}
|
||||
}
|
||||
|
||||
var result = createArray(length);
|
||||
copyArray(data, result, 0, 0, length);
|
||||
return result;
|
||||
}
|
||||
|
||||
///////////////////////
|
||||
// Exporting
|
||||
|
||||
|
||||
// The block cipher
|
||||
var aesjs = {
|
||||
AES: AES,
|
||||
Counter: Counter,
|
||||
|
||||
ModeOfOperation: {
|
||||
ecb: ModeOfOperationECB,
|
||||
cbc: ModeOfOperationCBC,
|
||||
cfb: ModeOfOperationCFB,
|
||||
ofb: ModeOfOperationOFB,
|
||||
ctr: ModeOfOperationCTR
|
||||
},
|
||||
|
||||
utils: {
|
||||
hex: convertHex,
|
||||
utf8: convertUtf8
|
||||
},
|
||||
|
||||
padding: {
|
||||
pkcs7: {
|
||||
pad: pkcs7pad,
|
||||
strip: pkcs7strip
|
||||
}
|
||||
},
|
||||
|
||||
_arrayTest: {
|
||||
coerceArray: coerceArray,
|
||||
createArray: createArray,
|
||||
copyArray: copyArray,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// node.js
|
||||
if (typeof exports !== 'undefined') {
|
||||
module.exports = aesjs
|
||||
|
||||
// RequireJS/AMD
|
||||
// http://www.requirejs.org/docs/api.html
|
||||
// https://github.com/amdjs/amdjs-api/wiki/AMD
|
||||
} else if (typeof(define) === 'function' && define.amd) {
|
||||
define([], function() { return aesjs; });
|
||||
|
||||
// Web Browsers
|
||||
} else {
|
||||
|
||||
// If there was an existing library at "aesjs" make sure it's still available
|
||||
if (root.aesjs) {
|
||||
aesjs._aesjs = root.aesjs;
|
||||
}
|
||||
|
||||
root.aesjs = aesjs;
|
||||
}
|
||||
|
||||
|
||||
})(this);
|
||||
|
||||
},{}]},{},[])("aes-js")
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="183"
|
||||
height="183"
|
||||
viewBox="-56.5 -44 183 183"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
sodipodi:docname="sim.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="1.3469697"
|
||||
inkscape:cx="113.2171"
|
||||
inkscape:cy="187.82902"
|
||||
inkscape:window-width="1707"
|
||||
inkscape:window-height="1007"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" /><style type="text/css">@media (prefers-color-scheme: dark) { path { fill: #ffffff; } }</style><defs
|
||||
id="defs1" /><g
|
||||
inkscape:label="Слой 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"><path
|
||||
fill="#000000"
|
||||
d="m -0.1304947,103.98892 c -2.832044,-0.76743 -5.441699,-3.02673 -6.734761,-5.830592 l -0.600371,-1.30184 V 47.511687 -1.833103 l 0.690582,-1.45521 c 1.069347,-2.25335 2.620862,-3.8111129 4.935149,-4.9550229 l 2.01929,-0.9981 24.2233257,-0.07338 c 15.628147,-0.04735 24.667267,0.02165 25.474467,0.194438 2.93081,0.627379 3.24328,0.889045 14.83492,12.422955 9.75955,9.7109599 11.15853,11.2053299 11.90625,12.7181399 l 0.84727,1.71423 v 39.56077 39.560781 l -0.69058,1.4552 c -1.06939,2.253442 -2.62086,3.811122 -4.93526,4.955032 l -2.01939,0.9981 -34.27739,0.0512 c -29.0101883,0.0433 -34.4919167,-0.007 -35.6735017,-0.32711 z M 69.807477,99.400158 c 1.54279,-0.70076 2.94311,-2.44775 3.28556,-4.09897 0.19262,-0.92877 0.25873,-13.13398 0.21014,-38.793661 -0.0617,-32.61006 -0.12207,-37.5513 -0.46776,-38.31279 -0.58495,-1.28854 -21.30517,-21.96092 -22.62188,-22.56965 -1.00491,-0.46458 -2.26697,-0.48928 -25.003117,-0.48928 -22.6963917,0 -24.0007447,0.0254 -25.0178957,0.48742 -1.533296,0.69645 -2.941608,2.44785 -3.296415,4.09949004 -0.20721,0.96458 -0.267588,14.95269996 -0.209699,48.58271996 l 0.0813,47.228131 0.571082,1.05833 c 0.314094,0.58209 0.909407,1.37919 1.322916,1.77134 1.695763,1.60819 -0.536882,1.51482 36.3382887,1.51979 32.21189,0.004 33.78281,-0.0175 34.80748,-0.48287 z M 15.146229,88.018118 c -2.69069,-0.54101 -5.08953,-2.61032 -6.0729303,-5.23871 -0.53439,-1.42827 -0.54303,-1.82376 -0.47185,-21.602461 l 0.0725,-20.14755 0.77957,-1.40728 c 0.9491803,-1.71346 2.1625903,-2.81514 4.1031503,-3.72534 l 1.46728,-0.68821 h 19.976048 19.97604 l 1.46728,0.68821 c 1.94057,0.9102 3.15397,2.01188 4.10315,3.72534 l 0.77957,1.40728 0.0725,20.14755 c 0.0712,19.778701 0.0625,20.174191 -0.47184,21.602461 -0.72128,1.92779 -2.23869,3.6008 -4.13783,4.56212 l -1.54825,0.78371 -19.57917,0.0405 c -10.768537,0.0223 -20.000388,-0.0442 -20.515228,-0.14766 z m 9.535021,-10.07934 v -5.82084 h -5.820831 -5.82084 v 4.60991 4.60991 l 0.71449,0.84912 c 1.16572,1.38538 1.8679,1.55091 6.6277,1.56237 l 4.299481,0.0104 z m 16.139577,0.01 v -5.811 l -5.82107,-0.076 -5.821067,-0.076 2.4e-4,5.88698 2.4e-4,5.88698 h 5.820827 5.82083 z m 13.96991,5.43925 c 0.39296,-0.20445 1.01213,-0.68997 1.37593,-1.07893 0.64718,-0.69193 0.66325,-0.80958 0.7443,-5.4491 l 0.0829,-4.7419 -5.04378,-0.0331 c -2.77409,-0.0182 -5.22238,-0.0331 -5.44066,-0.0331 -1.24803,0 -1.19063,-0.28384 -1.19063,5.88698 v 5.82083 h 4.37877 c 3.24522,0 4.56372,-0.0962 5.09322,-0.37174 z m 2.16968,-21.720971 v -5.95312 H 41.470267 25.98011 l -0.64943,-0.64943 -0.64943,-0.64944 v -7.42036 -7.42036 h -4.241831 c -4.6105,0 -5.44266,0.18168 -6.60609,1.44224 l -0.66145,0.71669 -0.0734,12.94345 -0.0734,12.94346 h 21.967688 21.96769 z m -16.13959,-16.13974 v -5.95313 h -5.82083 -5.820827 v 5.77674 c 0,3.17721 0.0794,5.85611 0.17639,5.95313 0.097,0.097 2.716387,0.17638 5.820827,0.17638 h 5.64444 z m 16.13959,1.36204 c 0,-4.33264 -0.0338,-4.64169 -0.60059,-5.4901 -1.07012,-1.60187 -1.89152,-1.82419 -6.7416,-1.82465 l -4.29948,-4.2e-4 v 5.77674 c 0,3.17721 0.0794,5.85611 0.17639,5.95313 0.097,0.097 2.71639,0.17638 5.82083,0.17638 h 5.64445 z"
|
||||
id="path1" /></g></svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,562 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OTAMan — Справка</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="bg-neutral-50 dark:bg-slate-900 text-gray-800 dark:text-slate-200">
|
||||
|
||||
<div class="flex">
|
||||
|
||||
<aside class="sticky top-0 h-screen overflow-y-auto w-72 shrink-0 border-r border-gray-300 dark:border-slate-700 px-4 py-6">
|
||||
<a href="index.html" class="block text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300 mb-4">← Назад к приложению</a>
|
||||
<nav id="toc" class="text-sm"></nav>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 min-w-0 px-6 py-8">
|
||||
<h1 class="text-2xl font-bold text-heading mb-6">OTAMan <span class="text-xs text-gray-400 dark:text-slate-500 ml-2">Документация</span></h1>
|
||||
<section class="mb-10">
|
||||
<h2 id="overview" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">1. Обзор</h2>
|
||||
<p class="mb-3">OTAMan — Progressive Web App (PWA) для построения APDU-команд, сборки защищённых пакетов SCP80, просмотра меню SIM Toolkit (STK) и симуляции реальной сетевой среды для тестирования SIM/USIM/UICC-карт через PC/SC-ридер.</p>
|
||||
<p class="mb-3">Приложение — один статический файл <code class="font-mono text-sm">index.html</code>. Все вычисления выполняются в браузере; доступ к карте — через локальный HTTP-сервер (<code class="font-mono text-sm">pysim-otaman-server</code>), оборачивающий библиотеку <a href="https://osmocom.org/projects/pysim" class="text-blue-600 dark:text-blue-400 hover:underline">pySim</a>.</p>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">Браузер (OTAMan PWA) → HTTP :8080 → pysim-otaman-server → pySim → PC/SC → ридер → UICC/SIM</pre>
|
||||
<p class="mb-3">Стандарты, на которые опирается приложение:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li>ETSI TS 102 221 — интерфейс UICC-терминал (CLA 00, файлы USIM)</li>
|
||||
<li>ETSI TS 102 222 — административные команды</li>
|
||||
<li>ETSI TS 102 223 — Card Application Toolkit (CAT, проактивные команды)</li>
|
||||
<li>ETSI TS 102 225 — структура защищённых пакетов для (U)SIM toolkit</li>
|
||||
<li>ETSI TS 102 226 — структура удалённых APDU для UICC-приложений</li>
|
||||
<li>ETSI TS 151 011 — интерфейс SIM-ME (CLA A0)</li>
|
||||
<li>3GPP TS 131 111 — USIM Application Toolkit (USAT)</li>
|
||||
<li>3GPP TS 31.102 — характеристики приложения USIM</li>
|
||||
<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>.</li>
|
||||
<li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li>
|
||||
<li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>Разбор C-APDU</strong>, <strong>«Парсер ответов»</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Карты</strong>, <strong>RAM</strong>), <strong>«Профайлер»</strong> (вкладки <strong>«Профили»</strong>, <strong>«Снимки карт»</strong>, <strong>«Пользовательские файлы»</strong>), <strong>«Картридер»</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>«Симулятор телефона»</strong>.</li>
|
||||
<li>Ссылка <strong>справка</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. Вкладка 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">+ 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>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">SELECT</td><td class="py-1 px-2 font-mono">A4</td><td class="py-1 px-2">Выбор EF/DF по FID, пути, AID или цепочке</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">UPDATE RECORD</td><td class="py-1 px-2 font-mono">DC</td><td class="py-1 px-2">Обновление записи</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">UPDATE BINARY</td><td class="py-1 px-2 font-mono">D6</td><td class="py-1 px-2">Обновление бинарных данных</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">READ RECORD</td><td class="py-1 px-2 font-mono">B2</td><td class="py-1 px-2">Чтение записи</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">READ BINARY</td><td class="py-1 px-2 font-mono">B0</td><td class="py-1 px-2">Чтение бинарных данных</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">ERASE BINARY</td><td class="py-1 px-2 font-mono">0E</td><td class="py-1 px-2">Стирание бинарных данных</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">ACTIVATE FILE</td><td class="py-1 px-2 font-mono">44</td><td class="py-1 px-2">Активация файла</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">DEACTIVATE FILE</td><td class="py-1 px-2 font-mono">04</td><td class="py-1 px-2">Деактивация файла</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">VERIFY PIN</td><td class="py-1 px-2 font-mono">20</td><td class="py-1 px-2">Проверка PIN1 или PIN2</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CHANGE PIN</td><td class="py-1 px-2 font-mono">24</td><td class="py-1 px-2">Смена PIN1 или PIN2</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">DISABLE PIN</td><td class="py-1 px-2 font-mono">26</td><td class="py-1 px-2">Отключение PIN</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">ENABLE PIN</td><td class="py-1 px-2 font-mono">28</td><td class="py-1 px-2">Включение PIN</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">UNBLOCK PIN</td><td class="py-1 px-2 font-mono">2C</td><td class="py-1 px-2">Разблокировка PIN с помощью PUK</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">GET RESPONSE</td><td class="py-1 px-2 font-mono">C0</td><td class="py-1 px-2">Получение данных, на которые указывает предшествующий <code class="font-mono text-sm">61XX</code>/<code class="font-mono text-sm">9FXX</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4 id="sim-select-methods" class="font-medium mb-1">Методы SELECT</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">P1</th><th class="text-left py-1 px-2">P2</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">По FID</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">FID (4 hex)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">По полному пути от MF</td><td class="py-1 px-2 font-mono">08</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Полный путь от MF</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">По DF name / AID</td><td class="py-1 px-2 font-mono">04</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">AID</td></tr>
|
||||
<tr><td class="py-1 px-2">Цепочка</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">FID через запятую; токен <code class="font-mono text-sm">C0</code> (или <code class="font-mono text-sm">C0:NN</code>) вставляет GET RESPONSE</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">Для record-команд режим P2: <strong>Absolute (04)</strong>, <strong>Next (02)</strong> или <strong>Previous (03)</strong>. Если за Case 4 командой сразу следует строка GET RESPONSE, сборщик цепочки автоматически убирает её байт Le (ETSI TS 102 226 §5.1.1). В правой колонке — панель конвертации (IMSI, MSISDN, ICCID, SPN, PLMN, nibble swap). См. <a href="#conversion" class="text-blue-600 dark:text-blue-400 hover:underline">§2.5</a>.</p>
|
||||
|
||||
<h3 id="usim-rfm" class="text-lg font-medium mb-2">2.2 USIM RFM</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">00</code> (ETSI TS 102 221). Тот же сборщик цепочки и набор команд, что и SIM. Отличия:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>SELECT</strong> по умолчанию запрашивает FCP (P2=<code class="font-mono text-sm">04</code>) и добавляет Le=<code class="font-mono text-sm">00</code>; флажок <strong>Silent (P2=0C)</strong> выбирает файл без запроса FCP (без Le, без данных ответа).</li>
|
||||
<li><strong>По пути</strong> предлагает выбор <strong>from MF</strong> (P1=<code class="font-mono text-sm">08</code>) или <strong>from current DF</strong> (P1=<code class="font-mono text-sm">09</code>).</li>
|
||||
<li>Каждый переход SELECT в цепочке запрашивает FCP, если не отмечен как silent.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="ber-tlv" class="text-lg font-medium mb-2">2.3 Expanded Script</h3>
|
||||
<p class="mb-2">Построение формата Expanded Remote Application data по ETSI TS 102 226 §5.2.1.</p>
|
||||
<h4 id="ber-format" class="font-medium mb-1">Формат</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Definite (AA)</strong>: <code class="font-mono text-sm">AA</code> + длина + Command TLV</li>
|
||||
<li><strong>Indefinite (AE)</strong>: <code class="font-mono text-sm">AE</code> + <code class="font-mono text-sm">80</code> + Command TLV + <code class="font-mono text-sm">00 00</code></li>
|
||||
</ul>
|
||||
<h4 id="ber-command-tlvs" class="font-medium mb-1">Command TLV</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><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">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><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>
|
||||
<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>
|
||||
<li><strong>Ссылка на запись EFRMA:</strong> <code class="font-mono text-sm">82 01 <ref></code> с номером записи <code class="font-mono text-sm">01</code>–<code class="font-mono text-sm">7F</code></li>
|
||||
<li><strong>Произвольный hex:</strong> произвольное значение TLV</li>
|
||||
</ul>
|
||||
|
||||
<h4 id="ber-script-chaining" class="font-medium mb-2 text-base">Script Chaining TLV (Tag 83)</h4>
|
||||
<p class="text-sm mb-2">Многопакетное выполнение скрипта с сохранением контекста:</p>
|
||||
<ul class="text-sm list-disc pl-5 mb-2">
|
||||
<li><strong>Флаги цепочки:</strong> <code class="font-mono text-sm">01</code> первый скрипт (удалять инфо о цепочке при сбросе), <code class="font-mono text-sm">11</code> первый скрипт (сохранять инфо о цепочке при сбросе, только RFM), <code class="font-mono text-sm">02</code> последующий скрипт (будут ещё), <code class="font-mono text-sm">03</code> последующий скрипт (последний)</li>
|
||||
<li><strong>Идентификатор скрипта:</strong> Корреляционный идентификатор между пакетами (1-4 байта hex, авто-инкремент подсказки)</li>
|
||||
<li><strong>Дополнительные данные:</strong> Расширенная информация цепочки (опционально hex)</li>
|
||||
<li><strong>Сохранение контекста:</strong> UICC сохраняет состояние безопасности/транзакции между пакетами</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>
|
||||
<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">P1</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 font-mono">INSTALL [for load]</td><td class="py-1 px-2 font-mono">E6</td><td class="py-1 px-2 font-mono">02</td><td class="py-1 px-2">Регистрация загружаемого файла</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INSTALL [for install]</td><td class="py-1 px-2 font-mono">E6</td><td class="py-1 px-2 font-mono">0C</td><td class="py-1 px-2">Установка приложения или SD</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INSTALL [make selectable]</td><td class="py-1 px-2 font-mono">E6</td><td class="py-1 px-2 font-mono">08</td><td class="py-1 px-2">Сделать приложение выбираемым</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INSTALL [registry update]</td><td class="py-1 px-2 font-mono">E6</td><td class="py-1 px-2 font-mono">40</td><td class="py-1 px-2">Обновление реестра</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INSTALL [extradition]</td><td class="py-1 px-2 font-mono">E6</td><td class="py-1 px-2 font-mono">10</td><td class="py-1 px-2">Перемещение между SD</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">LOAD</td><td class="py-1 px-2 font-mono">E8</td><td class="py-1 px-2 font-mono">80</td><td class="py-1 px-2">Загрузка блока кода (P1=80 последний блок, номер блока в P2)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">DELETE</td><td class="py-1 px-2 font-mono">E4</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Удаление приложения или SD (P2=00 только AID / 80 AID + связанные объекты)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">GET STATUS</td><td class="py-1 px-2 font-mono">F2</td><td class="py-1 px-2 font-mono">80/40/20/10</td><td class="py-1 px-2">Статус карты</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">GET DATA</td><td class="py-1 px-2 font-mono">CA</td><td class="py-1 px-2 font-mono">tag</td><td class="py-1 px-2">Чтение объектов данных</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">STORE DATA</td><td class="py-1 px-2 font-mono">E2</td><td class="py-1 px-2 font-mono">00/40/80/C0/E0</td><td class="py-1 px-2">Запись данных</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">SET STATUS</td><td class="py-1 px-2 font-mono">F0</td><td class="py-1 px-2 font-mono">80/40/60</td><td class="py-1 px-2">Управление жизненным циклом</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">EXTERNAL AUTHENTICATE</td><td class="py-1 px-2 font-mono">82</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Аутентификация SCP</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INTERNAL AUTHENTICATE</td><td class="py-1 px-2 font-mono">88</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Challenge-response</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">GET RESPONSE</td><td class="py-1 px-2 font-mono">C0</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Получение данных после <code class="font-mono text-sm">61XX</code> (Le настраивается)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4 id="ram-privileges" class="font-medium mb-1">Привилегии (INSTALL [for install])</h4>
|
||||
<p class="text-sm mb-2">Три байта привилегий (таблицы 11-7/8/9 спецификации GP), кодируются как length-value поле внутри данных INSTALL:</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">Байт 1</th><th class="text-left py-1 px-2">Байт 2</th><th class="text-left py-1 px-2">Байт 3</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b8</td><td class="py-1 px-2">Security Domain</td><td class="py-1 px-2">Trusted Path</td><td class="py-1 px-2">Receipt Generation</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b7</td><td class="py-1 px-2">DAP Verification</td><td class="py-1 px-2">Authorized Management</td><td class="py-1 px-2"></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b6</td><td class="py-1 px-2">Delegated Management</td><td class="py-1 px-2">Token Verification</td><td class="py-1 px-2"></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b5</td><td class="py-1 px-2">Card Lock</td><td class="py-1 px-2">Global Delete</td><td class="py-1 px-2"></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b4</td><td class="py-1 px-2">Card Terminate</td><td class="py-1 px-2">Global Lock</td><td class="py-1 px-2"></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b3</td><td class="py-1 px-2">Card Reset</td><td class="py-1 px-2">Global Registry</td><td class="py-1 px-2"></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b2</td><td class="py-1 px-2">CVM Management</td><td class="py-1 px-2">Final Application</td><td class="py-1 px-2"></td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">b1</td><td class="py-1 px-2">Mandated DAP Verification</td><td class="py-1 px-2"></td><td class="py-1 px-2"></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4 id="ram-msl" class="font-medium mb-1">MSL (Minimum Security Level) — байт SPI1 по TS 102 225</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 font-mono">00</td><td class="py-1 px-2">Нет проверки</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">11</td><td class="py-1 px-2">RC/CC/DS</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">12</td><td class="py-1 px-2">RC/DS/CC</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">15</td><td class="py-1 px-2">RC/DS/CC + MAC</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">16</td><td class="py-1 px-2">RC/DS/CC + MAC + Cipher</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">19</td><td class="py-1 px-2">RC/DS/CC + MAC + Cipher + DS</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">Полные таблицы GET STATUS P1/P2, тегов GET DATA, DELETE P1, STORE DATA P1 и SET STATUS см. в GlobalPlatform v2.3.1 и ETSI TS 102 226 §8.2.1.3.2.</p>
|
||||
|
||||
<h3 id="conversion" class="text-lg font-medium mb-2">2.5 Конвертация (боковые панели SIM/USIM)</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>IMSI → EF.IMSI</strong> — 15-значный IMSI в 9-байтный формат (TS 31.102 §4.2.3).</li>
|
||||
<li><strong>MSISDN → BCD</strong> — удалить <code class="font-mono text-sm">+</code>, дополнить нечётную длину символом <code class="font-mono text-sm">f</code>, поменять полубайты.</li>
|
||||
<li><strong>ICCID → hex</strong> — поменять полубайты строки ICCID.</li>
|
||||
<li><strong>Provider Name → SPN</strong> — GSM 7-bit packed, UCS2 non-BMP или UCS2 BMP (TS 31.102 §4.2.5, TS 23.038).</li>
|
||||
<li><strong>PLMN → EF_PLMNsel / PLMNwAcT</strong> — 3-байтный BCD + опциональный селектор технологии доступа.</li>
|
||||
<li><strong>Nibble swap</strong> — поменять пары полубайтов hex-строки чётной длины.</li>
|
||||
</ul>
|
||||
|
||||
<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>Карты</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>
|
||||
<h4 id="packet-structure" 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><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 font-mono">CPI</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Packet Identifier (02)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CPL</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Packet Length</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CHI</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Header Identifier (01)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CHL</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Header Length</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">SPI</td><td class="py-1 px-2">2</td><td class="py-1 px-2">Security Parameter Indicator</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">KIc</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Key Identifier для шифрования</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">KID</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Key Identifier для MAC</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">TAR</td><td class="py-1 px-2">3</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 font-mono">CNTR</td><td class="py-1 px-2">5</td><td class="py-1 px-2">Счётчик повторов</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">PCNTR</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Padding counter</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">RC/CC/DS</td><td class="py-1 px-2">8</td><td class="py-1 px-2">Контрольная сумма / MAC</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">Secured Data</td><td class="py-1 px-2">пер.</td><td class="py-1 px-2">APDU (с шифрованием при необходимости)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4 id="packet-crypto" class="font-medium mb-1">Крипто</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>3DES-CBC</strong> шифрование (нулевой ICV), ключи 8/16/24 байта — устарело с Rel-18, но поддерживается для обратной совместимости</li>
|
||||
<li><strong>AES-CBC</strong> шифрование (нулевой ICV, дополнение нулями до 16), ключи 16/24/32 байта (TS 102 225 §5.1.2, KIc <code class="font-mono text-sm">x2</code>)</li>
|
||||
<li><strong>Retail MAC</strong> (ISO 9797-1, MAC algorithm 3) для контрольной суммы DES/3DES</li>
|
||||
<li><strong>AES-CMAC</strong> (NIST SP 800-38B, усечённый до 8 октетов) для контрольной суммы AES (TS 102 225 §5.1.3.1, KID <code class="font-mono text-sm">x2</code>)</li>
|
||||
<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">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>«Парсер ответов»</strong> (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.</p>
|
||||
|
||||
<h3 id="cards" class="text-lg font-medium mb-2">3.2 Карты</h3>
|
||||
<p class="mb-2">Хранит предустановки карт локально в браузере (<code class="font-mono text-sm">localStorage</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры.</p>
|
||||
<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">Name</td><td class="py-1 px-2">Понятная метка</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Опциональный идентификатор карты</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Индикаторы ключа и алгоритма (например, 15 = индекс 1, 3DES-CBC2; x2 = AES)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">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>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">Обмен предустановками: <strong>Экспорт в JSON</strong> и <strong>Экспорт в файл</strong> для выгрузки, <strong>Импорт из файла</strong>, <strong>Вставить и импортировать</strong> или <strong>Импорт JSON из буфера</strong> для загрузки. Выбранная предустановка автоматически заполняет форму Secured Packet.</p>
|
||||
|
||||
<h3 id="ram" class="text-lg font-medium mb-2">3.3 RAM</h3>
|
||||
<p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Предустановка карты из подвкладки <strong>Карты</strong> обеспечивает SPI, ключи, TAR и счётчик.</p>
|
||||
|
||||
<h4 id="ram-operations" class="font-medium mb-1">Операции</h4>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Операция</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Обзор карты (все данные GP)</td><td class="py-1 px-2">Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками <strong>Удалить</strong> для каждого элемента.</td></tr>
|
||||
<tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)].</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 id="ram-explorer" class="font-medium mb-1">Обзор карты (Explorer View)</h4>
|
||||
<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>Удалить</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="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> (пиллы над деревом; выбор сохраняется в <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>Введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <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="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>Новый профиль</strong> — создаёт пустой набор правил, запросив имя.</li>
|
||||
<li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
|
||||
<li><strong>Профиль из снимка</strong> — выбирает сохранённый снимок карты и создаёт по правилу на каждый захваченный файл с теми же опциями сканирования (см. ниже), без картридера; имя профиля подставляется из имени снимка.</li>
|
||||
<li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li>
|
||||
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li>
|
||||
</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> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт; в этом отчёте заголовок — <em>Результаты сравнения снимков: <эталон> → <проверяемый></em>, а поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
|
||||
</ul>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<h4 id="custom-files" class="font-medium mb-1">Пользовательские файлы</h4>
|
||||
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim: введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>), затем нажмите <strong>Добавить</strong>; добавленные файлы появляются в дереве «Файловый менеджер». В каждой строке есть кнопки <strong>«Редактировать»</strong> (загружает запись в форму — кнопка становится <strong>«Сохранить»</strong>, появляется <strong>«Отмена»</strong>) и <strong>«Удалить»</strong> (без подтверждения). Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен — <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> и <strong>Импорт из файла</strong> / <strong>Вставить и импортировать</strong> / <strong>Импорт JSON из буфера</strong>.</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<li><strong>Access Technology Change</strong> — 13 типов RAT (тег <code class="font-mono text-sm">BF</code>)</li>
|
||||
<li><strong>Network Rejection</strong> — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля местоположения (MCC, MNC, LAC, RAC, TAC), технология доступа и единый выпадающий список из 53 кодов причин (EMM, GMM, 5GMM и LU)</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Отправка события использует <code class="font-mono text-sm">ENVELOPE(Event Download)</code> по TS 102 223 / TS 131 111.</p>
|
||||
|
||||
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Журнал проактивных команд</h3>
|
||||
<p class="text-sm mb-2">Хронологический список извлечённых проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор (для команд, у которых он есть). Для команд с данными ответа показывается строка <code class="font-mono text-sm">Ответ:</code> с байтами TERMINAL RESPONSE (без служебных TLV); ответы PROVIDE LOCAL INFORMATION декодируются через словарь данных PLI.</p>
|
||||
|
||||
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 Опрос STATUS</h3>
|
||||
<p class="text-sm mb-3">Кнопка <strong>Отправить STATUS</strong> отправляет STATUS (F2) вручную. Переключатель <strong>Опрос</strong> включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера <code class="font-mono text-sm">--poll-interval</code>, 1–255 с, по умолчанию 30 с, <code class="font-mono text-sm">0</code> отключает опрос) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.</p>
|
||||
|
||||
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 «Конфигурация TR» — данные ответа PROVIDE LOCAL INFORMATION</h3>
|
||||
<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>
|
||||
<li><strong>01</strong> IMEI · <strong>03</strong> Дата/время/TZ · <strong>04</strong> Язык · <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">Значения хранятся на сервере до перезапуска. Когда карта выдаёт PLI, сервер вставляет значения словаря в 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. Установка сервера</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">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>
|
||||
<li><strong>Смарт-картридер</strong> (PC/SC или serial/FTDI). Предпочтителен PC/SC; на Linux требуются <code class="font-mono text-sm">pcsc-lite</code> + <code class="font-mono text-sm">ccid</code></li>
|
||||
<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">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">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">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>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">setup.sh / setup.bat</td><td class="py-1 px-2">Создаёт <code class="font-mono text-sm">.venv/</code>, устанавливает pySim и сервер. Запускается один раз после клонирования.</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">start.sh / start.bat</td><td class="py-1 px-2">Запускает сервер из venv (при отсутствии — из глобальной установки).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<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">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">Если карта отсутствует, вкладка «Картридер» показывает «Карта не обнаружена. Вставьте карту и нажмите Подключить карту».</p>
|
||||
|
||||
<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
|
||||
# .venv\Scripts\activate # Windows
|
||||
|
||||
# Установить pysim
|
||||
pip install git+https://github.com/osmocom/pysim.git
|
||||
|
||||
# Установить pysim-otaman-server (editable — раздаёт встроенный PWA)
|
||||
pip install -e .
|
||||
|
||||
# Запустить сервер (PWA + API)
|
||||
pysim-otaman-server --http-port 8080</pre>
|
||||
<p class="text-sm mb-3">Подключите PC/SC-ридер с SIM-картой и откройте <code class="font-mono text-sm">http://127.0.0.1:8080</code> — PWA и API на одном origin, поэтому CORS не требуется.</p>
|
||||
<p class="text-sm mb-3">Если PWA раздаётся с публичного HTTPS-хоста (например, <code class="font-mono text-sm">https://otaman.example.com</code>), для доступа к локальному серверу карт нужны два условия: (1) сервер отвечает на preflight заголовком <code class="font-mono text-sm">Access-Control-Allow-Private-Network: true</code> (pysim-otaman-server ≥ 1.6.1 делает это автоматически), и (2) браузеру должно быть разрешено обращаться к локальной сети — в Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → разрешить сайт (или подтвердить запрос). Без разрешения браузера запрос к <code class="font-mono text-sm">127.0.0.1</code> блокируется ещё до отправки preflight.</p>
|
||||
|
||||
|
||||
<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">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>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2">✅ Совместимы</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2 font-mono">0.x.x</td><td class="py-1 px-2">❌ Устарел — обновите сервер</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2 font-mono">2.x.x+</td><td class="py-1 px-2">⚠️ Сервер новее — обновите PWA</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm">PWA проверяет версию сервера при подключении через <code class="font-mono text-sm">GET /api/version</code> и предупреждает о несовместимости.</p>
|
||||
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (localStorage.getItem('theme') === 'dark' ||
|
||||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
var toc = document.getElementById('toc');
|
||||
var main = document.querySelector('main');
|
||||
if (!toc || !main) return;
|
||||
var headings = Array.prototype.filter.call(main.querySelectorAll('h2, h3, h4'), function (h) {
|
||||
return /^\d/.test(h.textContent.trim());
|
||||
});
|
||||
var stack = [document.createElement('ol')];
|
||||
stack[0].className = 'space-y-1 list-none';
|
||||
var lastLevel = 2;
|
||||
headings.forEach(function (h) {
|
||||
var level = parseInt(h.tagName.charAt(1), 10);
|
||||
var li = document.createElement('li');
|
||||
var a = document.createElement('a');
|
||||
a.href = '#' + h.id;
|
||||
a.textContent = h.textContent;
|
||||
li.appendChild(a);
|
||||
if (level > lastLevel) {
|
||||
var ul = document.createElement('ul');
|
||||
ul.className = 'pl-4 mt-1 space-y-1 list-none';
|
||||
var parent = stack[stack.length - 1].lastElementChild;
|
||||
parent.appendChild(ul);
|
||||
stack.push(ul);
|
||||
} else if (level < lastLevel) {
|
||||
while (stack.length > 1 && level < lastLevel) { stack.pop(); lastLevel--; }
|
||||
}
|
||||
stack[stack.length - 1].appendChild(li);
|
||||
lastLevel = level;
|
||||
});
|
||||
toc.appendChild(stack[0]);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,562 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OTAMan — Help</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="bg-neutral-50 dark:bg-slate-900 text-gray-800 dark:text-slate-200">
|
||||
|
||||
<div class="flex">
|
||||
|
||||
<aside class="sticky top-0 h-screen overflow-y-auto w-72 shrink-0 border-r border-gray-300 dark:border-slate-700 px-4 py-6">
|
||||
<a href="index.html" class="block text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300 mb-4">← Back to app</a>
|
||||
<nav id="toc" class="text-sm"></nav>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 min-w-0 px-6 py-8">
|
||||
<h1 class="text-2xl font-bold text-heading mb-6">OTAMan <span class="text-xs text-gray-400 dark:text-slate-500 ml-2">Documentation</span></h1>
|
||||
<section class="mb-10">
|
||||
<h2 id="overview" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">1. Overview</h2>
|
||||
<p class="mb-3">OTAMan is a Progressive Web App (PWA) for building APDU commands, assembling SCP80 secured packets, browsing the SIM Toolkit (STK) menu, and simulating a real network environment against a SIM/USIM/UICC card via a PC/SC reader.</p>
|
||||
<p class="mb-3">The application is a single static <code class="font-mono text-sm">index.html</code> file. All computation runs in the browser; the card is accessed through a local HTTP server (<code class="font-mono text-sm">pysim-otaman-server</code>) that wraps the <a href="https://osmocom.org/projects/pysim" class="text-blue-600 dark:text-blue-400 hover:underline">pySim</a> library.</p>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">Browser (OTAMan PWA) → HTTP :8080 → pysim-otaman-server → pySim → PC/SC → card reader → UICC/SIM</pre>
|
||||
<p class="mb-3">Standards referenced across the application:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li>ETSI TS 102 221 — UICC-Terminal interface (CLA 00, USIM files)</li>
|
||||
<li>ETSI TS 102 222 — Administrative commands</li>
|
||||
<li>ETSI TS 102 223 — Card Application Toolkit (CAT, proactive commands)</li>
|
||||
<li>ETSI TS 102 225 — Secured packet structure for (U)SIM toolkit</li>
|
||||
<li>ETSI TS 102 226 — Remote APDU structure for UICC based applications</li>
|
||||
<li>ETSI TS 151 011 — SIM-ME interface (CLA A0)</li>
|
||||
<li>3GPP TS 131 111 — USIM Application Toolkit (USAT)</li>
|
||||
<li>3GPP TS 31.102 — USIM application characteristics</li>
|
||||
<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.</li>
|
||||
<li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li>
|
||||
<li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>C-APDU Parser</strong>, <strong>Response parser</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Cards</strong>, <strong>RAM</strong>), <strong>Profiler</strong> (list tabs <strong>Profiles</strong>, <strong>Card snapshots</strong>, <strong>Custom files</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), and <strong>Phone simulator</strong>.</li>
|
||||
<li>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. 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 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>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">SELECT</td><td class="py-1 px-2 font-mono">A4</td><td class="py-1 px-2">Select EF/DF by FID, path, AID or chain</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">UPDATE RECORD</td><td class="py-1 px-2 font-mono">DC</td><td class="py-1 px-2">Update a record in a record-oriented EF</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">UPDATE BINARY</td><td class="py-1 px-2 font-mono">D6</td><td class="py-1 px-2">Update binary content at an offset</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">READ RECORD</td><td class="py-1 px-2 font-mono">B2</td><td class="py-1 px-2">Read a record</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">READ BINARY</td><td class="py-1 px-2 font-mono">B0</td><td class="py-1 px-2">Read binary content</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">ERASE BINARY</td><td class="py-1 px-2 font-mono">0E</td><td class="py-1 px-2">Erase binary at an offset</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">ACTIVATE FILE</td><td class="py-1 px-2 font-mono">44</td><td class="py-1 px-2">Activate a file</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">DEACTIVATE FILE</td><td class="py-1 px-2 font-mono">04</td><td class="py-1 px-2">Deactivate a file</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">VERIFY PIN</td><td class="py-1 px-2 font-mono">20</td><td class="py-1 px-2">Verify PIN1 or PIN2</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CHANGE PIN</td><td class="py-1 px-2 font-mono">24</td><td class="py-1 px-2">Change PIN1 or PIN2</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">DISABLE PIN</td><td class="py-1 px-2 font-mono">26</td><td class="py-1 px-2">Disable a PIN</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">ENABLE PIN</td><td class="py-1 px-2 font-mono">28</td><td class="py-1 px-2">Enable a PIN</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">UNBLOCK PIN</td><td class="py-1 px-2 font-mono">2C</td><td class="py-1 px-2">Unblock a PIN with PUK</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">GET RESPONSE</td><td class="py-1 px-2 font-mono">C0</td><td class="py-1 px-2">Fetch data indicated by a preceding <code class="font-mono text-sm">61XX</code>/<code class="font-mono text-sm">9FXX</code> status word</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4 id="sim-select-methods" class="font-medium mb-1">SELECT methods</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">Method</th><th class="text-left py-1 px-2">P1</th><th class="text-left py-1 px-2">P2</th><th class="text-left py-1 px-2">Input</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">By FID</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">2-byte FID (4 hex)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">By full path from MF</td><td class="py-1 px-2 font-mono">08</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Full path hex from MF</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">By DF name / AID</td><td class="py-1 px-2 font-mono">04</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">AID (application ID)</td></tr>
|
||||
<tr><td class="py-1 px-2">Chain</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Comma-separated FIDs; a <code class="font-mono text-sm">C0</code> (or <code class="font-mono text-sm">C0:NN</code>) token inserts a GET RESPONSE hop</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">For record commands, the P2 mode is <strong>Absolute (04)</strong>, <strong>Next (02)</strong>, or <strong>Previous (03)</strong>. When a Case 4 command is immediately followed by a GET RESPONSE row, the chain builder strips its trailing Le byte automatically (ETSI TS 102 226 §5.1.1). A conversion panel is embedded in the right column (IMSI, MSISDN, ICCID, SPN, PLMN, nibble swap). See <a href="#conversion" class="text-blue-600 dark:text-blue-400 hover:underline">§2.5</a>.</p>
|
||||
|
||||
<h3 id="usim-rfm" class="text-lg font-medium mb-2">2.2 USIM RFM</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">00</code> (ETSI TS 102 221). Same chain builder and command set as SIM. Differences:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>SELECT</strong> requests FCP by default (P2=<code class="font-mono text-sm">04</code>) and appends Le=<code class="font-mono text-sm">00</code>; a <strong>Silent (P2=0C)</strong> checkbox selects without requesting FCP (no Le, no response data).</li>
|
||||
<li><strong>By path</strong> offers <strong>from MF</strong> (P1=<code class="font-mono text-sm">08</code>) or <strong>from current DF</strong> (P1=<code class="font-mono text-sm">09</code>).</li>
|
||||
<li>Each SELECT hop in a chain requests FCP unless marked silent.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="ber-tlv" class="text-lg font-medium mb-2">2.3 Expanded Script</h3>
|
||||
<p class="mb-2">Builds the Expanded Remote Application data format per ETSI TS 102 226 §5.2.1.</p>
|
||||
<h4 id="ber-format" class="font-medium mb-1">Format</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Definite (AA)</strong>: <code class="font-mono text-sm">AA</code> + length + Command TLVs</li>
|
||||
<li><strong>Indefinite (AE)</strong>: <code class="font-mono text-sm">AE</code> + <code class="font-mono text-sm">80</code> + Command TLVs + <code class="font-mono text-sm">00 00</code></li>
|
||||
</ul>
|
||||
<h4 id="ber-command-tlvs" class="font-medium mb-1">Command TLVs</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">Type</th><th class="text-left py-1 px-2">Tag</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">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><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 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>
|
||||
<li><strong>Reference to EFRMA record:</strong> <code class="font-mono text-sm">82 01 <ref></code> with record number <code class="font-mono text-sm">01</code>–<code class="font-mono text-sm">7F</code></li>
|
||||
<li><strong>Custom hex:</strong> arbitrary TLV value</li>
|
||||
</ul>
|
||||
|
||||
<h4 id="ber-script-chaining" class="font-medium mb-2 text-base">Script Chaining TLV (Tag 83)</h4>
|
||||
<p class="text-sm mb-2">Multi-packet script execution with context preservation:</p>
|
||||
<ul class="text-sm list-disc pl-5 mb-2">
|
||||
<li><strong>Chaining Flags:</strong> <code class="font-mono text-sm">01</code> first script (delete chaining info on reset), <code class="font-mono text-sm">11</code> first script (keep chaining info across reset, RFM only), <code class="font-mono text-sm">02</code> subsequent script (more to follow), <code class="font-mono text-sm">03</code> subsequent script (last)</li>
|
||||
<li><strong>Script ID:</strong> Correlation identifier across packets (1-4 bytes hex, auto-increment hints provided)</li>
|
||||
<li><strong>Additional Data:</strong> Extended chaining information (optional hex)</li>
|
||||
<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">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>
|
||||
<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">P1</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 font-mono">INSTALL [for load]</td><td class="py-1 px-2 font-mono">E6</td><td class="py-1 px-2 font-mono">02</td><td class="py-1 px-2">Register a load file for loading</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INSTALL [for install]</td><td class="py-1 px-2 font-mono">E6</td><td class="py-1 px-2 font-mono">0C</td><td class="py-1 px-2">Install an application or SD</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INSTALL [make selectable]</td><td class="py-1 px-2 font-mono">E6</td><td class="py-1 px-2 font-mono">08</td><td class="py-1 px-2">Make an application selectable</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INSTALL [registry update]</td><td class="py-1 px-2 font-mono">E6</td><td class="py-1 px-2 font-mono">40</td><td class="py-1 px-2">Update registry entries</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INSTALL [extradition]</td><td class="py-1 px-2 font-mono">E6</td><td class="py-1 px-2 font-mono">10</td><td class="py-1 px-2">Extradition between SDs</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">LOAD</td><td class="py-1 px-2 font-mono">E8</td><td class="py-1 px-2 font-mono">80</td><td class="py-1 px-2">Load executable code block (P1=80 last block, block number in P2)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">DELETE</td><td class="py-1 px-2 font-mono">E4</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Delete application or SD (P2=00 AID only / 80 AID + related objects)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">GET STATUS</td><td class="py-1 px-2 font-mono">F2</td><td class="py-1 px-2 font-mono">80/40/20/10</td><td class="py-1 px-2">Get card status</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">GET DATA</td><td class="py-1 px-2 font-mono">CA</td><td class="py-1 px-2 font-mono">tag</td><td class="py-1 px-2">Read card data objects</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">STORE DATA</td><td class="py-1 px-2 font-mono">E2</td><td class="py-1 px-2 font-mono">00/40/80/C0/E0</td><td class="py-1 px-2">Store data (key, certificate, …)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">SET STATUS</td><td class="py-1 px-2 font-mono">F0</td><td class="py-1 px-2 font-mono">80/40/60</td><td class="py-1 px-2">Lifecycle state management</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">EXTERNAL AUTHENTICATE</td><td class="py-1 px-2 font-mono">82</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">SCP host authentication</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INTERNAL AUTHENTICATE</td><td class="py-1 px-2 font-mono">88</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Card challenge-response</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">GET RESPONSE</td><td class="py-1 px-2 font-mono">C0</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Fetch data after a <code class="font-mono text-sm">61XX</code> status (Le configurable)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4 id="ram-privileges" class="font-medium mb-1">Privileges (INSTALL [for install])</h4>
|
||||
<p class="text-sm mb-2">Three privilege bytes (GP spec Tables 11-7/8/9), encoded as a length-value field inside the INSTALL data:</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">Bit</th><th class="text-left py-1 px-2">Byte 1</th><th class="text-left py-1 px-2">Byte 2</th><th class="text-left py-1 px-2">Byte 3</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b8</td><td class="py-1 px-2">Security Domain</td><td class="py-1 px-2">Trusted Path</td><td class="py-1 px-2">Receipt Generation</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b7</td><td class="py-1 px-2">DAP Verification</td><td class="py-1 px-2">Authorized Management</td><td class="py-1 px-2"></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b6</td><td class="py-1 px-2">Delegated Management</td><td class="py-1 px-2">Token Verification</td><td class="py-1 px-2"></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b5</td><td class="py-1 px-2">Card Lock</td><td class="py-1 px-2">Global Delete</td><td class="py-1 px-2"></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b4</td><td class="py-1 px-2">Card Terminate</td><td class="py-1 px-2">Global Lock</td><td class="py-1 px-2"></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b3</td><td class="py-1 px-2">Card Reset</td><td class="py-1 px-2">Global Registry</td><td class="py-1 px-2"></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">b2</td><td class="py-1 px-2">CVM Management</td><td class="py-1 px-2">Final Application</td><td class="py-1 px-2"></td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">b1</td><td class="py-1 px-2">Mandated DAP Verification</td><td class="py-1 px-2"></td><td class="py-1 px-2"></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4 id="ram-msl" class="font-medium mb-1">MSL (Minimum Security Level) — SPI1 byte per TS 102 225</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">Value</th><th class="text-left py-1 px-2">Meaning</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">No check</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">11</td><td class="py-1 px-2">RC/CC/DS</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">12</td><td class="py-1 px-2">RC/DS/CC</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">15</td><td class="py-1 px-2">RC/DS/CC + MAC</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">16</td><td class="py-1 px-2">RC/DS/CC + MAC + Cipher</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">19</td><td class="py-1 px-2">RC/DS/CC + MAC + Cipher + DS</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">Refer to GlobalPlatform v2.3.1 and ETSI TS 102 226 §8.2.1.3.2 for the full GET STATUS P1/P2, GET DATA tag, DELETE P1, STORE DATA P1, and SET STATUS tables.</p>
|
||||
|
||||
<h3 id="conversion" class="text-lg font-medium mb-2">2.5 Conversion (SIM/USIM sidebars)</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>IMSI → EF.IMSI</strong> — 15-digit IMSI to 9-byte format (TS 31.102 §4.2.3).</li>
|
||||
<li><strong>MSISDN → BCD</strong> — strip <code class="font-mono text-sm">+</code>, pad odd length with <code class="font-mono text-sm">f</code>, swap nibbles.</li>
|
||||
<li><strong>ICCID → hex</strong> — nibble-swap the ICCID string.</li>
|
||||
<li><strong>Provider Name → SPN</strong> — GSM 7-bit packed, UCS2 non-BMP, or UCS2 BMP (TS 31.102 §4.2.5, TS 23.038).</li>
|
||||
<li><strong>PLMN → EF_PLMNsel / PLMNwAcT</strong> — 3-byte BCD + optional access technology selector.</li>
|
||||
<li><strong>Nibble swap</strong> — swap nibble pairs of an even-length hex string.</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">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">
|
||||
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. SCP80 tab</h2>
|
||||
<p class="mb-3">The <strong>SCP80</strong> top-level tab groups the SCP80-related views. It is switched by three pills: <strong>Secured Packet</strong>, <strong>Cards</strong>, and <strong>RAM</strong>. Assembles SCP80 secured packets per 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">Builds SCP80 secured packets per ETSI TS 102 225.</p>
|
||||
<h4 id="packet-structure" class="font-medium mb-1">Packet structure</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">Field</th><th class="text-left py-1 px-2">Size</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 font-mono">CPI</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Packet Identifier (02)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CPL</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Packet Length</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CHI</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Header Identifier (01)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CHL</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Header Length</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">SPI</td><td class="py-1 px-2">2</td><td class="py-1 px-2">Security Parameter Indicator</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">KIc</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Key Identifier for ciphering</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">KID</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Key Identifier for MAC</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">TAR</td><td class="py-1 px-2">3</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 font-mono">CNTR</td><td class="py-1 px-2">5</td><td class="py-1 px-2">Replay counter</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">PCNTR</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Padding counter</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">RC/CC/DS</td><td class="py-1 px-2">8</td><td class="py-1 px-2">Cryptographic Checksum / MAC</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">Secured Data</td><td class="py-1 px-2">var.</td><td class="py-1 px-2">Padded APDU (encrypted if required)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4 id="packet-crypto" class="font-medium mb-1">Crypto</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>3DES-CBC</strong> encryption (zero ICV), 8/16/24-byte keys — deprecated since Rel-18, still supported for backwards compatibility</li>
|
||||
<li><strong>AES-CBC</strong> encryption (zero ICV, zero-padded to 16), 16/24/32-byte keys (TS 102 225 §5.1.2, KIc <code class="font-mono text-sm">x2</code>)</li>
|
||||
<li><strong>Retail MAC</strong> (ISO 9797-1 MAC algorithm 3) for the DES/3DES cryptographic checksum</li>
|
||||
<li><strong>AES-CMAC</strong> (NIST SP 800-38B, truncated to 8 octets) for the AES cryptographic checksum (TS 102 225 §5.1.3.1, KID <code class="font-mono text-sm">x2</code>)</li>
|
||||
<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). 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>
|
||||
<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>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Name</td><td class="py-1 px-2">Human-readable label</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Optional card identifier</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Key and algorithm indicators (e.g. 15 = index 1, 3DES-CBC2; x2 = AES)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">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>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">Presets can be shared with <strong>Export as JSON</strong> and <strong>Export to file</strong>, and restored with <strong>Import from file</strong>, <strong>Paste & import</strong>, or <strong>Import JSON from clipboard</strong>. The selected card preset auto-fills the Secured Packet form.</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 id="ram-explorer" class="font-medium mb-1">Explorer View</h4>
|
||||
<p class="text-sm mb-2">After “Explore Card” runs, the explorer displays:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>ISD</strong> — AID, lifecycle, privileges (no delete; the ISD cannot be removed)</li>
|
||||
<li><strong>Applications</strong> — AID, lifecycle, privileges, associated ELF/SD. Each has a <strong>Delete</strong> button (GP <code class="font-mono text-sm">DELETE</code> by AID).</li>
|
||||
<li><strong>Executable Load Files</strong> — AID, lifecycle, version, module AIDs. Each has <strong>Delete</strong> (ELF only) and <strong>Delete All</strong> (cascade: ELF + modules + installed Applications, P2=<code class="font-mono text-sm">0x80</code>) buttons.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Delete confirms via a browser prompt before sending the GP <code class="font-mono text-sm">DELETE</code> command via SCP80. The explorer auto-refreshes after a successful deletion.</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">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>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 File manager</h3>
|
||||
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills above the tree; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI above the content pane.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
|
||||
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
|
||||
<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="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">4.3 Raw APDU</h3>
|
||||
<p class="text-sm mb-3">Send an arbitrary APDU and view the raw response.</p>
|
||||
|
||||
<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>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>
|
||||
<li>Export the custom-file list as JSON to share with other machines.</li>
|
||||
</ol>
|
||||
|
||||
<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>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>
|
||||
<li>The server injects the dictionary values into each TERMINAL RESPONSE.</li>
|
||||
<li>Verify in the proactive log: the PLI entry shows the decoded response.</li>
|
||||
</ol>
|
||||
<p class="text-sm mb-1"><strong>B.2 Simulate network actions via ENVELOPE (event download)</strong></p>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Check the <strong>subscribed events</strong> list (from SET UP EVENT LIST).</li>
|
||||
<li>Click <strong>Send</strong> on an event (e.g. Location Status) and fill the form; an <code class="font-mono text-sm">ENVELOPE(Event Download)</code> is sent.</li>
|
||||
<li>For <strong>Network Rejection</strong>, select registration type → location fields → access technology → rejection cause.</li>
|
||||
<li>The card may respond with a proactive command, which the chain handler logs and answers automatically.</li>
|
||||
</ol>
|
||||
<p class="text-sm mb-1"><strong>B.3 Verify the simulated environment</strong></p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li>The proactive command log shows the full round-trip (command + TERMINAL RESPONSE bytes).</li>
|
||||
<li>The STATUS button / auto-polling keep the CAT session alive (drain loop).</li>
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
<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>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. 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: pick the <em>master</em> snapshot and the <em>snapshot to check</em>, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same pass/fail report; in that report the header reads <em>Snapshot comparison results: <master> → <checked></em> and the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li>
|
||||
</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: enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>), then press <strong>Add</strong>; added files appear in the File manager tree. Each row has <strong>Edit</strong> (reloads the entry into the form — the button becomes <strong>Save</strong> and a <strong>Cancel</strong> button appears) and <strong>Delete</strong> (no confirmation) buttons. The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> and restored with <strong>Import from file</strong> / <strong>Paste & import</strong> / <strong>Import JSON from clipboard</strong>.</p>
|
||||
|
||||
<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). Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
|
||||
|
||||
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 STATUS polling</h3>
|
||||
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). A <strong>Polling</strong> toggle enables background polling: after a configurable idle interval (server CLI <code class="font-mono text-sm">--poll-interval</code>, 1–255 s, default 30 s, <code class="font-mono text-sm">0</code> disables polling) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.</p>
|
||||
|
||||
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 TR Config — PROVIDE LOCAL INFORMATION response data</h3>
|
||||
<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>
|
||||
<li><strong>Smart card reader</strong> (PC/SC or serial/FTDI). PC/SC is preferable; on Linux it requires <code class="font-mono text-sm">pcsc-lite</code> + <code class="font-mono text-sm">ccid</code></li>
|
||||
<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">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">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">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>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">setup.sh / setup.bat</td><td class="py-1 px-2">Creates <code class="font-mono text-sm">.venv/</code>, installs pySim and the server. Run once after cloning.</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">start.sh / start.bat</td><td class="py-1 px-2">Starts the server from the venv (falls back to a global install).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<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> — <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 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">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
|
||||
# .venv\Scripts\activate # Windows
|
||||
|
||||
# Install pysim
|
||||
pip install git+https://github.com/osmocom/pysim.git
|
||||
|
||||
# Install pysim-otaman-server (editable, so it serves the bundled PWA)
|
||||
pip install -e .
|
||||
|
||||
# Start the server (serves PWA + API)
|
||||
pysim-otaman-server --http-port 8080</pre>
|
||||
<p class="text-sm mb-3">Connect a PC/SC reader with a SIM card, then open <code class="font-mono text-sm">http://127.0.0.1:8080</code> — the PWA and API share one origin, so no CORS is involved.</p>
|
||||
<p class="text-sm mb-3">If the PWA is served from a public HTTPS host (e.g. <code class="font-mono text-sm">https://otaman.example.com</code>), two things are required to reach a local card server: (1) the server must answer the preflight with <code class="font-mono text-sm">Access-Control-Allow-Private-Network: true</code> (pysim-otaman-server ≥ 1.6.1 does this automatically), and (2) the browser must be allowed to access the local network — in Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site (or accept the permission prompt). Without the browser permission, the request to <code class="font-mono text-sm">127.0.0.1</code> is blocked before any preflight is sent.</p>
|
||||
|
||||
|
||||
<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">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>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2">✅ Compatible</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2 font-mono">0.x.x</td><td class="py-1 px-2">❌ Outdated — update server</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2 font-mono">2.x.x+</td><td class="py-1 px-2">⚠️ Server newer — update PWA</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm">The PWA checks the server version on connect via <code class="font-mono text-sm">GET /api/version</code> and warns if versions are incompatible.</p>
|
||||
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (localStorage.getItem('theme') === 'dark' ||
|
||||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
var toc = document.getElementById('toc');
|
||||
var main = document.querySelector('main');
|
||||
if (!toc || !main) return;
|
||||
var headings = Array.prototype.filter.call(main.querySelectorAll('h2, h3, h4'), function (h) {
|
||||
return /^\d/.test(h.textContent.trim());
|
||||
});
|
||||
var stack = [document.createElement('ol')];
|
||||
stack[0].className = 'space-y-1 list-none';
|
||||
var lastLevel = 2;
|
||||
headings.forEach(function (h) {
|
||||
var level = parseInt(h.tagName.charAt(1), 10);
|
||||
var li = document.createElement('li');
|
||||
var a = document.createElement('a');
|
||||
a.href = '#' + h.id;
|
||||
a.textContent = h.textContent;
|
||||
li.appendChild(a);
|
||||
if (level > lastLevel) {
|
||||
var ul = document.createElement('ul');
|
||||
ul.className = 'pl-4 mt-1 space-y-1 list-none';
|
||||
var parent = stack[stack.length - 1].lastElementChild;
|
||||
parent.appendChild(ul);
|
||||
stack.push(ul);
|
||||
} else if (level < lastLevel) {
|
||||
while (stack.length > 1 && level < lastLevel) { stack.pop(); lastLevel--; }
|
||||
}
|
||||
stack[stack.length - 1].appendChild(li);
|
||||
lastLevel = level;
|
||||
});
|
||||
toc.appendChild(stack[0]);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -8,6 +8,7 @@
|
||||
"name": "otaman",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"aes-js": "^3.1.2",
|
||||
"des.js": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -115,6 +116,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/aes-js": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
|
||||
"integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -4,13 +4,13 @@
|
||||
"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": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.atroshin.ru/catarrh/otaman.git"
|
||||
"url": "https://github.com/anttro/otaman.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"type": "commonjs",
|
||||
@@ -22,6 +22,7 @@
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"aes-js": "^3.1.2",
|
||||
"des.js": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -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));
|
||||
}
|
||||
@@ -554,6 +554,44 @@ video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 768px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
max-width: 1024px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.container {
|
||||
max-width: 1536px;
|
||||
}
|
||||
}
|
||||
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
@@ -566,6 +604,10 @@ video {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.inset-0 {
|
||||
inset: 0px;
|
||||
}
|
||||
@@ -582,6 +624,10 @@ video {
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.top-0 {
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -608,6 +654,10 @@ video {
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.mb-10 {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@@ -632,6 +682,10 @@ video {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.ml-3 {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
@@ -648,6 +702,10 @@ video {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.mt-0\.5 {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
@@ -668,6 +726,10 @@ video {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
@@ -688,8 +750,8 @@ video {
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.max-h-20 {
|
||||
max-height: 5rem;
|
||||
.h-screen {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.max-h-32 {
|
||||
@@ -740,26 +802,14 @@ video {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.w-28 {
|
||||
width: 7rem;
|
||||
}
|
||||
|
||||
.w-3 {
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.w-32 {
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.w-36 {
|
||||
width: 9rem;
|
||||
}
|
||||
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.w-40 {
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.w-48 {
|
||||
width: 12rem;
|
||||
}
|
||||
@@ -772,6 +822,10 @@ video {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.w-72 {
|
||||
width: 18rem;
|
||||
}
|
||||
|
||||
.w-8 {
|
||||
width: 2rem;
|
||||
}
|
||||
@@ -804,10 +858,18 @@ video {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -822,6 +884,22 @@ video {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.list-inside {
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.list-decimal {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.list-none {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -842,6 +920,10 @@ video {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.items-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -858,6 +940,10 @@ video {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.gap-1\.5 {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -870,6 +956,10 @@ video {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.gap-x-3 {
|
||||
-moz-column-gap: 0.75rem;
|
||||
column-gap: 0.75rem;
|
||||
@@ -888,6 +978,12 @@ video {
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.space-y-0\.5 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(0.125rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(0.125rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.space-y-1 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
@@ -932,6 +1028,10 @@ video {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@@ -965,6 +1065,14 @@ video {
|
||||
border-left-width: 1px;
|
||||
}
|
||||
|
||||
.border-l-2 {
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
.border-r {
|
||||
border-right-width: 1px;
|
||||
}
|
||||
|
||||
.border-t {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
@@ -1133,15 +1241,32 @@ video {
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
.py-6 {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.py-8 {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.pb-1 {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.pl-2 {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.pl-4 {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.pl-5 {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.pt-1 {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
@@ -1175,8 +1300,13 @@ video {
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.text-\[10px\] {
|
||||
font-size: 10px;
|
||||
.text-\[13px\] {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
@@ -1189,9 +1319,14 @@ video {
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
@@ -1218,6 +1353,10 @@ video {
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
.leading-snug {
|
||||
line-height: 1.375;
|
||||
}
|
||||
|
||||
.text-amber-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(217 119 6 / var(--tw-text-opacity, 1));
|
||||
@@ -1273,6 +1412,11 @@ video {
|
||||
color: rgb(30 41 59 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-indigo-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(79 70 229 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-purple-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(147 51 234 / var(--tw-text-opacity, 1));
|
||||
@@ -1298,6 +1442,11 @@ video {
|
||||
color: rgb(185 28 28 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-slate-300 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-white {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
||||
@@ -1416,6 +1565,10 @@ video {
|
||||
border-color: rgb(51 65 85 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.dark\:border-slate-700\/50:is(.dark *) {
|
||||
border-color: rgb(51 65 85 / 0.5);
|
||||
}
|
||||
|
||||
.dark\:bg-blue-500:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(59 130 246 / var(--tw-bg-opacity, 1));
|
||||
@@ -1460,6 +1613,11 @@ video {
|
||||
color: rgb(74 222 128 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.dark\:text-indigo-400:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(129 140 248 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.dark\:text-purple-400:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(192 132 252 / var(--tw-text-opacity, 1));
|
||||
@@ -1527,4 +1685,117 @@ video {
|
||||
.dark\:hover\:text-slate-300:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 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));
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
const CACHE = 'otaman-v5';
|
||||
const CACHE = 'otaman-v133';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
'help-ru.html',
|
||||
'style.css',
|
||||
'sim.png',
|
||||
'favicon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'manifest.json',
|
||||
'des-bundle.js',
|
||||
'aes-bundle.js',
|
||||
'sim.svg',
|
||||
'sim_anim.svg',
|
||||
'nosim.svg',
|
||||
@@ -27,18 +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;
|
||||
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 => {
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html'],
|
||||
content: ['./index.html', './help.html', './help-ru.html'],
|
||||
theme: {
|
||||
extend: {
|
||||
textColor: {
|
||||
@@ -0,0 +1,417 @@
|
||||
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);
|
||||
}
|
||||
|
||||
function findNode(tree, label) {
|
||||
if (tree.label === label) return tree;
|
||||
if (tree.children) {
|
||||
for (const c of tree.children) {
|
||||
const r = findNode(c, label);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNodes(tree, label) {
|
||||
const out = [];
|
||||
if (tree.label === label) out.push(tree);
|
||||
if (tree.children) {
|
||||
for (const c of tree.children) out.push(...findNodes(c, label));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const consts = ['BER_QUAL', 'BER_DEVICES', 'BER_TONES', 'GSM7_ALPHABET', 'GSM7_EXT_MAP'];
|
||||
let prefix = '';
|
||||
for (const c of consts) {
|
||||
const m = html.match(new RegExp('const\\s+' + c + '\\s*=\\s*(?:\\[|\\{)[\\s\\S]*?(?:\\n[\\]\\}];|[\\]\\}];\\n)'));
|
||||
if (!m) throw new Error(c + ' not found');
|
||||
prefix += m[0].replace(/^const /, 'var ') + '\n';
|
||||
}
|
||||
const start = html.indexOf('// ===== C-APDU Parser =====');
|
||||
const end = html.indexOf('// ===== Response Parser =====');
|
||||
eval(prefix + html.slice(start, end).replace(/^const PARSE_INS = /m, 'var PARSE_INS = '));
|
||||
|
||||
test('Compact RAM INSTALL [for install] UICC', () => {
|
||||
const tree = parseHexTree('80E60C00214F08A000000151000000C70100EA128010000000020101020200011603B0000100');
|
||||
assert.strictEqual(tree.label, 'Compact C-APDU chain');
|
||||
assert.strictEqual(tree.children.length, 1);
|
||||
const apdu = tree.children[0];
|
||||
assert.strictEqual(apdu.label, 'APDU');
|
||||
assert.ok(findNode(apdu, 'INS').desc.includes('INSTALL'));
|
||||
});
|
||||
|
||||
test('Compact RAM INSTALL [for install] SIM (CA) access domain 5A', () => {
|
||||
const tree = parseHexTree('80E60C00204F08A000000151000000C70100CA11015A000000020101020200011603B00001');
|
||||
const apdu = tree.children[0];
|
||||
assert.ok(findNode(apdu, 'INS').desc.includes('INSTALL'));
|
||||
});
|
||||
|
||||
test('Expanded AA DISPLAY TEXT UCS2 "HI" + 5s', () => {
|
||||
const tree = parseHexTree('AA1681130103012101020281820D040048004904020105');
|
||||
assert.strictEqual(tree.label, 'Expanded Script (AA)');
|
||||
assert.strictEqual(tree.children.length, 1);
|
||||
assert.strictEqual(tree.children[0].label, 'Immediate Action');
|
||||
});
|
||||
|
||||
test('Expanded AA DISPLAY TEXT GSM7 "HI"', () => {
|
||||
const tree = parseHexTree('AA0F810D0103012101020281820D02C824');
|
||||
assert.strictEqual(tree.children.length, 1);
|
||||
assert.strictEqual(tree.children[0].label, 'Immediate Action');
|
||||
});
|
||||
|
||||
test('Expanded AA REFRESH + file list', () => {
|
||||
const tree = parseHexTree('AA10810E01030101000202818212036F3B2F');
|
||||
assert.strictEqual(tree.children.length, 1);
|
||||
assert.strictEqual(tree.children[0].label, 'Immediate Action');
|
||||
});
|
||||
|
||||
test('Expanded AE80 PLAY TONE + Error Action no action', () => {
|
||||
const tree = parseHexTree('AE80810C0103200101020281820E010182000000');
|
||||
assert.strictEqual(tree.label, 'Expanded Script (AE80)');
|
||||
assert.strictEqual(tree.children.length, 2);
|
||||
assert.strictEqual(tree.children[0].label, 'Immediate Action');
|
||||
assert.strictEqual(tree.children[1].label, 'Error Action');
|
||||
});
|
||||
|
||||
test('Immediate Action CR-set tags (real-world encoding)', () => {
|
||||
const tree = parseHexTree('AA0E8101818109810301010482028182');
|
||||
assert.strictEqual(tree.children.length, 2);
|
||||
assert.strictEqual(tree.children[0].label, 'Immediate Action');
|
||||
assert.strictEqual(tree.children[1].label, 'Immediate Action');
|
||||
});
|
||||
|
||||
test('CR-set DISPLAY TEXT UCS2 "HI" + 5s', () => {
|
||||
const tree = parseHexTree('AA1581138103012101820281828D040048004984020105');
|
||||
assert.strictEqual(tree.children.length, 1);
|
||||
});
|
||||
|
||||
test('CR-set PLAY TONE (tag 8E)', () => {
|
||||
const tree = parseHexTree('AA0E810C8103012001820281828E0101');
|
||||
assert.strictEqual(tree.children.length, 1);
|
||||
});
|
||||
|
||||
test('CR-set REFRESH file list (tag 92)', () => {
|
||||
const tree = parseHexTree('AA0F810D81030101048202818292023F00');
|
||||
assert.strictEqual(tree.children.length, 1);
|
||||
});
|
||||
|
||||
test('Expanded AA Error Action = proactive DISPLAY TEXT', () => {
|
||||
const tree = parseHexTree('AA0F820D0103012101020281820D022852');
|
||||
assert.strictEqual(tree.children.length, 1);
|
||||
assert.strictEqual(tree.children[0].label, 'Error Action');
|
||||
});
|
||||
|
||||
test('Expanded AA 22 C-APDU row (GET STATUS)', () => {
|
||||
const tree = parseHexTree('AA09220780F22000024F00');
|
||||
assert.strictEqual(tree.children.length, 1);
|
||||
assert.strictEqual(tree.children[0].label, 'C-APDU');
|
||||
});
|
||||
|
||||
test('Chained SIM SELECT + UPDATE RECORD', () => {
|
||||
const tree = parseHexTree('A0A40000026F3BDC0102032B2F2D');
|
||||
assert.strictEqual(tree.label, 'Compact C-APDU chain');
|
||||
assert.strictEqual(tree.children.length, 2);
|
||||
assert.strictEqual(tree.children[0].label, 'APDU');
|
||||
});
|
||||
|
||||
test('INTERNAL AUTHENTICATE', () => {
|
||||
const tree = parseHexTree('8088000008800F3495BA2355CD');
|
||||
assert.strictEqual(tree.children.length, 1);
|
||||
const apdu = tree.children[0];
|
||||
assert.strictEqual(apdu.label, 'APDU');
|
||||
assert.ok(findNode(apdu, 'INS').desc.includes('AUTHENTICATE'));
|
||||
assert.ok(findNode(apdu, 'Data'));
|
||||
});
|
||||
|
||||
test('baseCompTag clears CR bit for 0x80-0x9F', () => {
|
||||
assert.strictEqual(baseCompTag('81'), '01');
|
||||
assert.strictEqual(baseCompTag('82'), '02');
|
||||
assert.strictEqual(baseCompTag('8D'), '0D');
|
||||
assert.strictEqual(baseCompTag('8E'), '0E');
|
||||
assert.strictEqual(baseCompTag('92'), '12');
|
||||
assert.strictEqual(baseCompTag('84'), '04');
|
||||
assert.strictEqual(baseCompTag('85'), '05');
|
||||
assert.strictEqual(baseCompTag('01'), '01');
|
||||
assert.strictEqual(baseCompTag('22'), '22');
|
||||
});
|
||||
|
||||
test('parseTlvList handles BER-TLVs', () => {
|
||||
const tlvs = parseTlvList('810301210182028182');
|
||||
assert.strictEqual(tlvs.length, 2);
|
||||
assert.strictEqual(tlvs[0].tag, '81');
|
||||
assert.strictEqual(tlvs[0].length, 3);
|
||||
assert.strictEqual(tlvs[0].value, '012101');
|
||||
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');
|
||||
});
|
||||
|
||||
test('decodeTextData legacy UCS2 without DCS (regression: lead 00 is not a DCS)', () => {
|
||||
assert.strictEqual(decodeTextData('00480049'), 'HI');
|
||||
});
|
||||
|
||||
test('decodeTextData legacy Cyrillic UCS2 (uniform 04 high bytes)', () => {
|
||||
assert.strictEqual(decodeTextData('041f04400438043204350442'), 'Привет');
|
||||
});
|
||||
|
||||
test('decodeTextData DCS-prefixed UCS2 (08)', () => {
|
||||
assert.strictEqual(decodeTextData('0800480049'), 'HI');
|
||||
});
|
||||
|
||||
test('decodeTextData DCS-prefixed GSM7 packed (00)', () => {
|
||||
assert.strictEqual(decodeTextData('00C824'), 'HI');
|
||||
});
|
||||
|
||||
test('decodeTextData DCS-prefixed unpacked 8-bit (04)', () => {
|
||||
assert.strictEqual(decodeTextData('04414243'), 'ABC');
|
||||
});
|
||||
|
||||
test('decodeTextData explicit DCS 08 with malformed payload returns ?', () => {
|
||||
assert.strictEqual(decodeTextData('0841'), '?');
|
||||
});
|
||||
|
||||
test('decodePrivileges byte 2 b6 is Token Verification per GPC v2.3 Table 11-8', () => {
|
||||
assert.ok(decodePrivileges('0020').includes('Token Verification'));
|
||||
});
|
||||
|
||||
test('decodePrivileges', () => {
|
||||
assert.ok(decodePrivileges('00').includes('None'));
|
||||
assert.ok(decodePrivileges('80').includes('Security Domain'));
|
||||
});
|
||||
|
||||
test('LV INSTALL [for install] decode with labeled fields and trailing Le', () => {
|
||||
const tree = parseHexTree('80E60C0011000008A000000151000000010002C9000000');
|
||||
const apdu = tree.children[0];
|
||||
assert.ok(findNode(apdu, 'P1').desc.includes('for install + for make selectable'));
|
||||
const elf = findNode(apdu, 'ELF AID');
|
||||
assert.ok(elf);
|
||||
assert.strictEqual(elf.desc, '(empty)');
|
||||
assert.strictEqual(findNode(apdu, 'Application AID').desc, 'A000000151000000');
|
||||
assert.strictEqual(findNode(apdu, 'Privileges').desc, 'None');
|
||||
const params = findNode(apdu, 'Install parameters');
|
||||
assert.ok(params.children.some(c => c.label.includes('C9')));
|
||||
assert.strictEqual(findNode(apdu, 'Install token').desc, '(empty)');
|
||||
assert.strictEqual(tree.children[1].label, 'Le');
|
||||
});
|
||||
|
||||
test('Legacy TLV INSTALL falls back to raw Data node', () => {
|
||||
const tree = parseHexTree('80E60C00214F08A000000151000000C70100EA13801100000002010102020002011603B0000100');
|
||||
const apdu = tree.children[0];
|
||||
const data = findNodes(apdu, 'Data');
|
||||
assert.strictEqual(data.length, 1);
|
||||
assert.ok(!data[0].children || !data[0].label.includes('ELF'));
|
||||
assert.ok(findNode(apdu, 'P1').desc.includes('for install + for make selectable'));
|
||||
});
|
||||
|
||||
test('Install parameters EF nesting exposes inner CA TLV', () => {
|
||||
const tree = parseHexTree('80E60C0025000008A000000151000000010016C900EF12CA1000000000030101000003030002011600' + '00');
|
||||
const apdu = tree.children[0];
|
||||
const params = findNode(apdu, 'Install parameters');
|
||||
assert.ok(params.children.some(c => c.label.includes('EF')));
|
||||
const ef = params.children.find(c => c.label.includes('EF'));
|
||||
assert.ok(ef.children.some(c => c.label.includes('CA')));
|
||||
});
|
||||
|
||||
test('GET DATA case 2 renders P3 as Le', () => {
|
||||
const tree = parseHexTree('80CA5F5000');
|
||||
const apdu = tree.children[0];
|
||||
assert.ok(findNode(apdu, 'Le'));
|
||||
assert.ok(!findNode(apdu, 'Lc'));
|
||||
});
|
||||
|
||||
test('GET DATA case 4 decodes Lc + tag list + Le', () => {
|
||||
const tree = parseHexTree('80CA2F00025C0000');
|
||||
const apdu = tree.children[0];
|
||||
assert.ok(findNode(apdu, 'Lc'));
|
||||
assert.strictEqual(findNode(apdu, 'Data').desc, 'Tag list: 5C00');
|
||||
assert.ok(findNode(apdu, 'Le'));
|
||||
});
|
||||
|
||||
test('SET STATUS raw AID data labeled', () => {
|
||||
const tree = parseHexTree('80F0408008A000000151000000');
|
||||
const apdu = tree.children[0];
|
||||
assert.strictEqual(findNode(apdu, 'P1').desc, 'Application or SSD');
|
||||
assert.strictEqual(findNode(apdu, 'P2').desc, 'LOCKED');
|
||||
assert.ok(findNode(apdu, 'Data').desc.startsWith('AID (raw):'));
|
||||
});
|
||||
|
||||
test('SET STATUS ISD card state label', () => {
|
||||
const tree = parseHexTree('80F0807F00');
|
||||
const apdu = tree.children[0];
|
||||
assert.strictEqual(findNode(apdu, 'P1').desc, 'ISD');
|
||||
assert.strictEqual(findNode(apdu, 'P2').desc, 'CARD_LOCKED');
|
||||
assert.ok(!findNode(apdu, 'Data'));
|
||||
});
|
||||
|
||||
test('Trailing single byte consumed as Le in compact chain', () => {
|
||||
const tree = parseHexTree('A0A40000026F3BDC0102032B2F2D00');
|
||||
const last = tree.children[tree.children.length - 1];
|
||||
assert.strictEqual(last.label, 'Le');
|
||||
assert.strictEqual(last.hex, '00');
|
||||
});
|
||||
|
||||
test('ACTIVATE FILE case-1 (4 bytes)', () => {
|
||||
const tree = parseHexTree('00440000');
|
||||
const apdu = tree.children[0];
|
||||
assert.strictEqual(apdu.hex, '00440000');
|
||||
assert.strictEqual(apdu.desc, 'no data, no Le');
|
||||
assert.ok(!findNode(apdu, 'P3'));
|
||||
assert.ok(!findNode(apdu, 'Lc'));
|
||||
});
|
||||
|
||||
test('ACTIVATE FILE legacy 5-byte empty-Lc form', () => {
|
||||
const tree = parseHexTree('0044000000');
|
||||
const apdu = tree.children[0];
|
||||
assert.strictEqual(findNode(apdu, 'P3').desc, 'empty Lc (legacy form)');
|
||||
});
|
||||
|
||||
test('ACTIVATE FILE with FID data', () => {
|
||||
const tree = parseHexTree('00440000026F3B');
|
||||
const apdu = tree.children[0];
|
||||
assert.strictEqual(findNode(apdu, 'Data').desc, 'FID 6F3B');
|
||||
});
|
||||
|
||||
test('READ RECORD next mode: P1 ignored note', () => {
|
||||
const tree = parseHexTree('00B2000200');
|
||||
const apdu = tree.children[0];
|
||||
assert.strictEqual(findNode(apdu, 'P1').desc, 'ignored');
|
||||
assert.ok(findNode(apdu, 'P2').desc.includes('next record'));
|
||||
});
|
||||
|
||||
test('UPDATE RECORD absolute mode with record number', () => {
|
||||
const tree = parseHexTree('00DC010404AABBCCDD');
|
||||
const apdu = tree.children[0];
|
||||
assert.strictEqual(findNode(apdu, 'P1').desc, 'record #1');
|
||||
assert.strictEqual(findNode(apdu, 'P2').desc, 'absolute/current');
|
||||
});
|
||||
|
||||
test('SELECT P1/P2 descriptors', () => {
|
||||
const tree = parseHexTree('00A40804047FFF6FC500');
|
||||
const apdu = tree.children[0];
|
||||
assert.strictEqual(findNode(apdu, 'P1').desc, 'path from MF');
|
||||
assert.ok(findNode(apdu, 'P2').desc.includes('FCP'));
|
||||
});
|
||||
|
||||
test('Immediate Action EFRMA reference (01-7F)', () => {
|
||||
const tree = parseHexTree('AA03810105');
|
||||
const row = tree.children[0].children[0];
|
||||
assert.strictEqual(row.label, 'Reference to EFRMA record');
|
||||
assert.strictEqual(row.desc, 'Record 0x05');
|
||||
});
|
||||
|
||||
test('CLA 84-87 labeled GlobalPlatform secure messaging', () => {
|
||||
const tree = parseHexTree('8482030010' + '00112233445566778899AABBCCDDEEFF');
|
||||
const apdu = tree.children[0];
|
||||
assert.strictEqual(findNode(apdu, 'CLA').desc, 'GlobalPlatform (secure messaging)');
|
||||
});
|
||||
test('New PARSE_INS labels decode (FETCH le, RESIZE D4, RETRIEVE CB, AUTH 89)', () => {
|
||||
const fetchTree = parseHexTree('8012000020');
|
||||
assert.ok(findNode(fetchTree.children[0], 'INS').desc.includes('FETCH'));
|
||||
assert.ok(findNode(fetchTree.children[0], 'Le'));
|
||||
|
||||
const resizeTree = parseHexTree('80D4000004' + '00000FA0');
|
||||
assert.ok(findNode(resizeTree.children[0], 'INS').desc.includes('RESIZE FILE'));
|
||||
assert.strictEqual(findNode(resizeTree.children[0], 'Data').desc, '00000FA0');
|
||||
|
||||
const retrTree = parseHexTree('80CB000000');
|
||||
assert.ok(findNode(retrTree.children[0], 'INS').desc.includes('RETRIEVE DATA'));
|
||||
|
||||
const authTree = parseHexTree('0089000008' + '0011223344556677');
|
||||
assert.ok(findNode(authTree.children[0], 'INS').desc.includes('odd INS'));
|
||||
});
|
||||
|
||||
test('READ BINARY SFI mode and offset descriptors', () => {
|
||||
const sfi = parseHexTree('00B0830602');
|
||||
const p1sfi = findNode(sfi.children[0], 'P1').desc;
|
||||
assert.ok(p1sfi.includes('SFI') && p1sfi.includes('3'), p1sfi);
|
||||
assert.strictEqual(findNode(sfi.children[0], 'P2').desc, 'offset low');
|
||||
|
||||
const off = parseHexTree('00B0010202');
|
||||
assert.strictEqual(findNode(off.children[0], 'P1').desc, 'offset high');
|
||||
});
|
||||
|
||||
test('SELECT P2 response field per ETSI b5-b3', () => {
|
||||
const fcp = parseHexTree('00A40804047FFF6FC500');
|
||||
assert.ok(findNode(fcp.children[0], 'P2').desc.includes('FCP'));
|
||||
const none = parseHexTree('00A4000C026F3B');
|
||||
assert.ok(findNode(none.children[0], 'P2').desc.includes('no response data'));
|
||||
});
|
||||
|
||||
test('live RFM captures decode (silent hop / implied CLA / SIM chain)', () => {
|
||||
// capture line 1 head: silent relative hop 09/0C
|
||||
const t1 = parseHexTree('00A4090C026F46');
|
||||
const a1 = t1.children[0];
|
||||
assert.ok(findNode(a1, 'P1').desc.includes('path from current DF'));
|
||||
assert.ok(findNode(a1, 'P2').desc.includes('no response data'));
|
||||
assert.strictEqual(findNode(a1, 'Data').desc, '6F46');
|
||||
|
||||
// capture line 2: silent hop + READ RECORD as implied-CLA continuation
|
||||
const t2 = parseHexTree('00A4090C026FC5B2010414');
|
||||
assert.strictEqual(t2.children.length, 2);
|
||||
assert.strictEqual(t2.children[1].label, 'APDU (implied CLA)');
|
||||
assert.ok(findNode(t2.children[1], 'INS').desc.includes('READ RECORD'));
|
||||
|
||||
// capture line 3: SIM chained FID selects + UPDATE RECORD (34 x FF)
|
||||
const t3 = parseHexTree('A0A40000023F00A0A40000027F20A0A40000026FC5A0DC010422' + 'FF'.repeat(34));
|
||||
assert.strictEqual(t3.children.length, 4);
|
||||
assert.strictEqual(t3.children[3].label, 'APDU');
|
||||
assert.ok(findNode(t3.children[3], 'INS').desc.includes('UPDATE RECORD'));
|
||||
assert.ok(findNode(t3.children[3], 'Lc').desc.includes('34'));
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
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);
|
||||
}
|
||||
|
||||
class StubEl {
|
||||
constructor(value = '') {
|
||||
this.value = value;
|
||||
this.checked = false;
|
||||
this.disabled = false;
|
||||
this.children = [];
|
||||
this.parentElement = null;
|
||||
this.nextElementSibling = null;
|
||||
}
|
||||
querySelector(sel) {
|
||||
return this._find(sel);
|
||||
}
|
||||
querySelectorAll(sel) {
|
||||
const out = [];
|
||||
this._findAll(sel, out);
|
||||
return out;
|
||||
}
|
||||
_find(sel) {
|
||||
for (const c of this.children) {
|
||||
if (c._matches(sel)) return c;
|
||||
const r = c._find(sel);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
_findAll(sel, out) {
|
||||
for (const c of this.children) {
|
||||
if (c._matches(sel)) out.push(c);
|
||||
c._findAll(sel, out);
|
||||
}
|
||||
}
|
||||
_matches(sel) {
|
||||
if (sel.startsWith('.')) return this.cls === sel.slice(1);
|
||||
if (sel.startsWith('input[name="')) {
|
||||
const m = sel.match(/^input\[name="([^"]+)"\]\[value="([^"]+)"\]$/);
|
||||
if (m) return this.name === m[1] && this.value === m[2];
|
||||
const m2 = sel.match(/^input\[name="([^"]+)"\]$/);
|
||||
if (m2) return this.name === m2[1];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function el(cls, value = '') {
|
||||
const e = new StubEl(value);
|
||||
e.cls = cls;
|
||||
return e;
|
||||
}
|
||||
|
||||
function buildRow() {
|
||||
const row = new StubEl();
|
||||
row.cls = 'ber-row';
|
||||
row.dataset = { berIdx: '0' };
|
||||
const body = el('error-action-body');
|
||||
const type = el('error-action-type', 'proactive');
|
||||
type.nextElementSibling = body;
|
||||
body.parentElement = row;
|
||||
row.children.push(type, body);
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildPcHtml(extraFields) {
|
||||
// Build the .ber-pc tree expected by genBerPcValue
|
||||
const pc = el('ber-pc');
|
||||
const type = el('ber-pc-type', 'display');
|
||||
const num = el('ber-pc-num', '1');
|
||||
const qual = el('ber-pc-qual', '01');
|
||||
const src = el('ber-pc-src', '81');
|
||||
const dst = el('ber-pc-dst', '82');
|
||||
const text = el('ber-pc-text', 'HI');
|
||||
const enc = el('ber-pc-enc', 'ucs2');
|
||||
const alpha = el('ber-pc-alpha', '');
|
||||
const durEnable = el('ber-pc-dur-enable');
|
||||
durEnable.checked = true;
|
||||
const durUnit = el('ber-pc-dur-unit', '01');
|
||||
const durVal = el('ber-pc-dur-val', '30');
|
||||
pc.children.push(type, num, qual, src, dst, text, enc, alpha, durEnable, durUnit, durVal);
|
||||
if (extraFields) extraFields(pc);
|
||||
return pc;
|
||||
}
|
||||
|
||||
const FNS = ['berLenStr', 'gsm7TextToSeptets', 'gsm7Encode', 'genBerPcValue', 'genErrorActionValue',
|
||||
'genScriptChainingValue'];
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
const berTagsMatch = html.match(/const BER_TAGS = \{[^}]*\};/);
|
||||
const berQualMatch = html.match(/const BER_QUAL = \{[\s\S]*?\n\};/);
|
||||
const berDevMatch = html.match(/const BER_DEVICES = \[[\s\S]*?\];/);
|
||||
const berTonesMatch = html.match(/const BER_TONES = \[[\s\S]*?\];/);
|
||||
const gsm7AlphaMatch = html.match(/const GSM7_ALPHABET = \[[\s\S]*?\n\];/);
|
||||
const gsm7ExtMatch = html.match(/const GSM7_EXT_MAP = \{[\s\S]*?\n\};/);
|
||||
for (const m of [berTagsMatch, berQualMatch, berDevMatch, berTonesMatch, gsm7AlphaMatch, gsm7ExtMatch]) {
|
||||
if (!m) throw new Error('constant not found');
|
||||
code = m[0] + '\n' + code;
|
||||
}
|
||||
code += '\nvar __berConsts = {BER_TAGS, BER_QUAL, BER_DEVICES, BER_TONES, GSM7_ALPHABET, GSM7_EXT_MAP};';
|
||||
eval(code);
|
||||
const {BER_TAGS, BER_QUAL, BER_DEVICES, BER_TONES, GSM7_ALPHABET, GSM7_EXT_MAP} = __berConsts;
|
||||
|
||||
function mkRow(type) {
|
||||
const row = new StubEl();
|
||||
row.cls = 'ber-row';
|
||||
row.dataset = { berIdx: '0' };
|
||||
const body = el('error-action-body');
|
||||
const actionType = el('error-action-type', type);
|
||||
actionType.nextElementSibling = body;
|
||||
row.children.push(actionType, body);
|
||||
return row;
|
||||
}
|
||||
|
||||
test('BER_QUAL.refresh matches TS 102 223 §8.6 mapping', () => {
|
||||
const r = BER_QUAL.refresh;
|
||||
assert.strictEqual(r[0][0], '00');
|
||||
assert.strictEqual(r[0][1], 'NAA Initialization and Full File Change Notification');
|
||||
assert.strictEqual(r[4][0], '04');
|
||||
assert.strictEqual(r[4][1], 'UICC Reset');
|
||||
assert.strictEqual(r[5][1], 'NAA Application Reset (not for 2G SIM)');
|
||||
assert.strictEqual(r[6][1], 'NAA Session Reset (not for 2G SIM)');
|
||||
assert.strictEqual(r[7][1], 'Steering of Roaming');
|
||||
assert.strictEqual(r.length, 11);
|
||||
});
|
||||
|
||||
test('genBerPcValue DISPLAY TEXT GSM7 "HI"', () => {
|
||||
const pc = buildPcHtml();
|
||||
pc.children.find(c => c.cls === 'ber-pc-enc').value = 'gsm7';
|
||||
pc.children.find(c => c.cls === 'ber-pc-dur-enable').checked = false;
|
||||
const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
|
||||
assert.strictEqual(val, '810E8103012101820281828D0300C824');
|
||||
});
|
||||
|
||||
test('genBerPcValue DISPLAY TEXT UCS2 "HI" + 30s duration', () => {
|
||||
const pc = buildPcHtml();
|
||||
const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
|
||||
// 81 13 8103 01 21 01 8202 81 82 8D 04 0048 0049 84 02 01 1E
|
||||
assert.strictEqual(val, '81148103012101820281828D0508004800498402011E');
|
||||
});
|
||||
|
||||
test('genBerPcValue duration disabled omits 84', () => {
|
||||
const pc = buildPcHtml();
|
||||
pc.children.find(c => c.cls === 'ber-pc-dur-enable').checked = false;
|
||||
const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
|
||||
assert.ok(!val.includes('84'));
|
||||
});
|
||||
|
||||
test('genBerPcValue PLAY TONE uses type byte 20', () => {
|
||||
const pc = buildPcHtml();
|
||||
pc.children.find(c => c.cls === 'ber-pc-type').value = 'tone';
|
||||
pc.children.find(c => c.cls === 'ber-pc-qual').value = '00';
|
||||
pc.children.push(el('ber-pc-tone', '01'));
|
||||
const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
|
||||
assert.strictEqual(val, '810C8103012000820281828E0101');
|
||||
});
|
||||
|
||||
test('genBerPcValue REFRESH uses CR-set file list tag 92', () => {
|
||||
const pc = buildPcHtml();
|
||||
pc.children.find(c => c.cls === 'ber-pc-type').value = 'refresh';
|
||||
pc.children.find(c => c.cls === 'ber-pc-qual').value = '04';
|
||||
pc.children.push(el('ber-pc-flist', '3F00'));
|
||||
const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
|
||||
assert.strictEqual(val, '810D81030101048202818292023F00');
|
||||
});
|
||||
|
||||
test('genErrorActionValue noaction emits 82 00', () => {
|
||||
const row = mkRow('noaction');
|
||||
const val = genErrorActionValue(row, '82');
|
||||
assert.strictEqual(val, '8200');
|
||||
});
|
||||
|
||||
test('genErrorActionValue reference emits 82 01 ref', () => {
|
||||
const row = mkRow('reference');
|
||||
row.children[1].children.push(el('error-ref', '05'));
|
||||
const val = genErrorActionValue(row, '82');
|
||||
assert.strictEqual(val, '820105');
|
||||
});
|
||||
|
||||
test('genErrorActionValue reference rejects out-of-range ref', () => {
|
||||
const row = mkRow('reference');
|
||||
row.children[1].children.push(el('error-ref', '80'));
|
||||
assert.strictEqual(genErrorActionValue(row, '82'), '');
|
||||
});
|
||||
|
||||
test('genErrorActionValue proactive wraps proactive command', () => {
|
||||
const row = mkRow('proactive');
|
||||
const pc = buildPcHtml();
|
||||
row.children[1].children.push(pc);
|
||||
const val = genErrorActionValue(row, '82');
|
||||
// DISPLAY TEXT UCS2 "HI" + 30s inside Error Action
|
||||
assert.strictEqual(val, '82148103012101820281828D0508004800498402011E');
|
||||
});
|
||||
|
||||
test('genScriptChainingValue first emits 01', () => {
|
||||
const row = new StubEl();
|
||||
row.dataset = { berIdx: '0' };
|
||||
const first = el('chaining-first', 'first');
|
||||
first.name = 'chaining-0';
|
||||
first.checked = true;
|
||||
const interm = el('chaining-inter', 'intermediary');
|
||||
interm.name = 'chaining-0';
|
||||
const last = el('chaining-last', 'last');
|
||||
last.name = 'chaining-0-last';
|
||||
const keep = el('chaining-keep', 'keep');
|
||||
keep.name = 'chaining-0-keep';
|
||||
row.children.push(first, interm, last, keep, el('chaining-script-id'), el('chaining-additional'));
|
||||
const val = genScriptChainingValue(row, '83');
|
||||
assert.strictEqual(val, '830101');
|
||||
});
|
||||
|
||||
test('genScriptChainingValue first with keep emits 11', () => {
|
||||
const row = new StubEl();
|
||||
row.dataset = { berIdx: '0' };
|
||||
const first = el('chaining-first', 'first');
|
||||
first.name = 'chaining-0';
|
||||
first.checked = true;
|
||||
const interm = el('chaining-inter', 'intermediary');
|
||||
interm.name = 'chaining-0';
|
||||
const last = el('chaining-last', 'last');
|
||||
last.name = 'chaining-0-last';
|
||||
const keep = el('chaining-keep', 'keep');
|
||||
keep.name = 'chaining-0-keep';
|
||||
keep.checked = true;
|
||||
row.children.push(first, interm, last, keep, el('chaining-script-id'), el('chaining-additional'));
|
||||
const val = genScriptChainingValue(row, '83');
|
||||
assert.strictEqual(val, '830111');
|
||||
});
|
||||
|
||||
test('genScriptChainingValue intermediary last emits 03', () => {
|
||||
const row = new StubEl();
|
||||
row.dataset = { berIdx: '0' };
|
||||
const first = el('chaining-first', 'first');
|
||||
first.name = 'chaining-0';
|
||||
const interm = el('chaining-inter', 'intermediary');
|
||||
interm.name = 'chaining-0';
|
||||
interm.checked = true;
|
||||
const last = el('chaining-last', 'last');
|
||||
last.name = 'chaining-0-last';
|
||||
last.checked = true;
|
||||
const keep = el('chaining-keep', 'keep');
|
||||
keep.name = 'chaining-0-keep';
|
||||
row.children.push(first, interm, last, keep, el('chaining-script-id'), el('chaining-additional'));
|
||||
const val = genScriptChainingValue(row, '83');
|
||||
assert.strictEqual(val, '830103');
|
||||
});
|
||||
|
||||
test('genScriptChainingValue no position emits empty', () => {
|
||||
const row = new StubEl();
|
||||
row.dataset = { berIdx: '0' };
|
||||
const first = el('chaining-first', 'first');
|
||||
first.name = 'chaining-0';
|
||||
const interm = el('chaining-inter', 'intermediary');
|
||||
interm.name = 'chaining-0';
|
||||
const last = el('chaining-last', 'last');
|
||||
last.name = 'chaining-0-last';
|
||||
const keep = el('chaining-keep', 'keep');
|
||||
keep.name = 'chaining-0-keep';
|
||||
row.children.push(first, interm, last, keep, el('chaining-script-id'), el('chaining-additional'));
|
||||
assert.strictEqual(genScriptChainingValue(row, '83'), '');
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
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;\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 += '\nglobalThis.esc = s => s;\n';
|
||||
code += 'globalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
function setup() {
|
||||
const el = { textContent: 'status line', innerHTML: '' };
|
||||
const calls = { connected: [], resets: [], refreshStatus: [], proactive: 0 };
|
||||
_pysimCardStateKey = null;
|
||||
_pysimCardSession = null;
|
||||
_pysimProactiveSeq = null;
|
||||
globalThis.document = { getElementById: () => el, querySelectorAll: () => [] };
|
||||
globalThis.pysimSetConnected = v => calls.connected.push(v);
|
||||
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
|
||||
globalThis.pysimApplyAvailability = () => {};
|
||||
globalThis.isViewVisible = () => true;
|
||||
globalThis.pysimProactiveLogRender = () => { calls.proactive++; };
|
||||
return { el, calls };
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
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;\n';
|
||||
for (const fn of ['pysimCustomSave', 'pysimCustomSubmit', 'pysimCustomEdit', 'pysimCustomEditCancel', 'pysimCustomRemove', 'pysimCustomRender']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\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++; },
|
||||
};
|
||||
}
|
||||
|
||||
function setup(entries) {
|
||||
const els = {
|
||||
'pysim-cf-path': fakeEl('pysim-cf-path'),
|
||||
'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); };
|
||||
pysimCustomFiles = (entries || []).map(e => Object.assign({}, e));
|
||||
pysimCustomEditIndex = null;
|
||||
return els;
|
||||
}
|
||||
|
||||
const entry = (fid, name, parentFid) => ({ path: (parentFid || '3F00') + '/' + fid, name, fid, parentFid: parentFid || '3F00' });
|
||||
|
||||
test('rows render Edit and Delete buttons instead of the X glyph', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomRender();
|
||||
const out = els['pysim-cf-list'].innerHTML;
|
||||
assert.match(out, /pysimCustomEdit\(0\)/);
|
||||
assert.match(out, /pysimCustomRemove\(0\)/);
|
||||
assert.match(out, />Edit</);
|
||||
assert.match(out, />Delete</);
|
||||
assert.ok(!out.includes('✕'), 'old X glyph must be gone');
|
||||
});
|
||||
|
||||
test('edit fills the top form and switches it to Save mode', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
assert.strictEqual(pysimCustomEditIndex, 0);
|
||||
assert.strictEqual(els['pysim-cf-path'].value, '3F00/6F46');
|
||||
assert.strictEqual(els['pysim-cf-name'].value, 'EF.SPN');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Save');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Save');
|
||||
assert.ok(!els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||
assert.strictEqual(els['pysim-cf-path'].focused, 1);
|
||||
});
|
||||
|
||||
test('submit in edit mode updates the entry in place', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
els['pysim-cf-path'].value = '3F00/7F20/6F46';
|
||||
els['pysim-cf-name'].value = 'EF.SPNX';
|
||||
pysimCustomSubmit();
|
||||
assert.strictEqual(pysimCustomFiles.length, 1);
|
||||
assert.deepStrictEqual(pysimCustomFiles[0], { path: '3F00/7F20/6F46', name: 'EF.SPNX', fid: '6F46', parentFid: '7F20' });
|
||||
assert.strictEqual(pysimCustomEditIndex, null);
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||
});
|
||||
|
||||
test('saving an edited entry with its own path is not a duplicate', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
pysimCustomSubmit();
|
||||
assert.strictEqual(pysimCustomFiles.length, 1);
|
||||
assert.deepStrictEqual(globalThis.alertCalls, []);
|
||||
});
|
||||
|
||||
test('editing to another entry path is rejected as duplicate', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2')]);
|
||||
pysimCustomEdit(0);
|
||||
els['pysim-cf-path'].value = '3F00/6F44';
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(globalThis.alertCalls, ['Path already exists']);
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['3F00/6F46', '3F00/6F44']);
|
||||
assert.strictEqual(pysimCustomEditIndex, 0);
|
||||
});
|
||||
|
||||
test('cancel restores the Add mode and clears the inputs', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
pysimCustomEditCancel();
|
||||
assert.strictEqual(pysimCustomEditIndex, null);
|
||||
assert.strictEqual(els['pysim-cf-path'].value, '');
|
||||
assert.strictEqual(els['pysim-cf-name'].value, '');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Add');
|
||||
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||
});
|
||||
|
||||
test('deleting the entry being edited cancels the edit', () => {
|
||||
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||
pysimCustomEdit(0);
|
||||
pysimCustomRemove(0);
|
||||
assert.deepStrictEqual(pysimCustomFiles, []);
|
||||
assert.strictEqual(pysimCustomEditIndex, null);
|
||||
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||
});
|
||||
|
||||
test('deleting before the edited row shifts the edit index', () => {
|
||||
setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2'), entry('6F42', 'EF.SPN3')]);
|
||||
pysimCustomEdit(2);
|
||||
pysimCustomRemove(0);
|
||||
assert.strictEqual(pysimCustomEditIndex, 1);
|
||||
assert.strictEqual(pysimCustomFiles[1].name, 'EF.SPN3');
|
||||
});
|
||||
|
||||
test('submit adds a new entry when not editing', () => {
|
||||
const els = setup([]);
|
||||
els['pysim-cf-path'].value = '3f00/6f46';
|
||||
els['pysim-cf-name'].value = 'EF.SPN';
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(pysimCustomFiles, [{ path: '3F00/6F46', name: 'EF.SPN', fid: '6F46', parentFid: '3F00' }]);
|
||||
assert.strictEqual(els['pysim-cf-path'].value, '');
|
||||
assert.strictEqual(els['pysim-cf-name'].value, '');
|
||||
});
|
||||
@@ -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', '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']);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
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');
|
||||
const opens = (html.match(/<div\b/g) || []).length;
|
||||
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', 'profiler', 'pysim', 'phone']);
|
||||
assert.match(html, /data-tab="c-apdu">Remote APDU</);
|
||||
});
|
||||
|
||||
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('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('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"/);
|
||||
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,83 @@
|
||||
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';
|
||||
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 = 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);
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// Extract chain builder functions and dependencies
|
||||
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 = {};
|
||||
const doc = { getElementById: (id) => { if (!els[id]) els[id] = {value:''}; return els[id]; } };
|
||||
global.document = doc;
|
||||
|
||||
function reset() { for (const id of Object.keys(els)) delete els[id]; }
|
||||
|
||||
function genRamResult(fields) {
|
||||
const row = { cmd: fields.cmd, fields: fields };
|
||||
return chainRamBuildRowHex(0, row);
|
||||
}
|
||||
|
||||
function genRamApdu() {
|
||||
return genRamResult({
|
||||
cmd: 'install-install',
|
||||
aid: 'A000000151000000',
|
||||
elfAid: '',
|
||||
modAid: '',
|
||||
priv: '00',
|
||||
tkEnabled: true,
|
||||
tkMode: 'ea',
|
||||
tkPriority: '0',
|
||||
tkTimers: '0',
|
||||
tkTextlen: '0',
|
||||
tkMenus: '2',
|
||||
tkFirstpos: '1',
|
||||
tkFirstid: '01',
|
||||
tkLastpos: '2',
|
||||
tkLastid: '02',
|
||||
tkChannels: '0',
|
||||
tkMsl: '16',
|
||||
tkTar: 'B00001',
|
||||
tkAd: '',
|
||||
tkServices: '0',
|
||||
});
|
||||
}
|
||||
|
||||
test('UICC toolkit nested inside EA (m=2, services 0)', () => {
|
||||
const apdu = genRamApdu();
|
||||
assert.ok(apdu.includes('EA13801100000002010102020002011603B0000100'), apdu);
|
||||
});
|
||||
|
||||
test('UICC m=1 emits single pair', () => {
|
||||
const apdu = genRamResult({
|
||||
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
|
||||
tkEnabled: true, tkMode: 'ea', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
|
||||
tkMenus: '1', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '0', tkLastid: '00',
|
||||
tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '', tkServices: '0',
|
||||
});
|
||||
assert.ok(apdu.includes('EA11800F0000000101010002011603B0000100'), apdu);
|
||||
});
|
||||
|
||||
test('UICC m=3 fills middle pair with 0000', () => {
|
||||
const apdu = genRamResult({
|
||||
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
|
||||
tkEnabled: true, tkMode: 'ea', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
|
||||
tkMenus: '3', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '3', tkLastid: '03',
|
||||
tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '', tkServices: '0',
|
||||
});
|
||||
assert.ok(apdu.includes('EA158013000000030101000003030002011603B0000100'), apdu);
|
||||
});
|
||||
|
||||
test('UICC services 7 appended as final byte', () => {
|
||||
const apdu = genRamResult({
|
||||
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
|
||||
tkEnabled: true, tkMode: 'ea', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
|
||||
tkMenus: '2', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '2', tkLastid: '02',
|
||||
tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '', tkServices: '7',
|
||||
});
|
||||
assert.ok(apdu.includes('EA13801100000002010102020002011603B0000107'), apdu);
|
||||
});
|
||||
|
||||
test('SIM (CA) access domain FIRST, no services byte', () => {
|
||||
const apdu = genRamResult({
|
||||
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
|
||||
tkEnabled: true, tkMode: 'ca', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
|
||||
tkMenus: '2', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '2', tkLastid: '02',
|
||||
tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '5A', tkServices: '0',
|
||||
});
|
||||
assert.ok(apdu.includes('EF14CA12015A00000002010102020002011603B00001'), apdu);
|
||||
});
|
||||
|
||||
test('SIM (CA) blank access domain emits length byte 00', () => {
|
||||
const apdu = genRamResult({
|
||||
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
|
||||
tkEnabled: true, tkMode: 'ca', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
|
||||
tkMenus: '2', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '2', tkLastid: '02',
|
||||
tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '', tkServices: '0',
|
||||
});
|
||||
assert.ok(apdu.includes('EF13CA110000000002010102020002011603B00001'), apdu);
|
||||
});
|
||||
|
||||
test('SIM (CA) m=3, no TAR, blank access domain', () => {
|
||||
const apdu = genRamResult({
|
||||
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
|
||||
tkEnabled: true, tkMode: 'ca', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
|
||||
tkMenus: '3', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '3', tkLastid: '03',
|
||||
tkChannels: '0', tkMsl: '16', tkTar: '', tkAd: '', tkServices: '0',
|
||||
});
|
||||
assert.ok(apdu.includes('EF12CA1000000000030101000003030002011600'), apdu);
|
||||
});
|
||||
|
||||
test('UICC m=60 uses long-form BER lengths (EA 81 87 / inner 81 84)', () => {
|
||||
const apdu = genRamResult({
|
||||
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
|
||||
tkEnabled: true, tkMode: 'ea', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
|
||||
tkMenus: '60', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '60', tkLastid: '3C',
|
||||
tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '', tkServices: '0',
|
||||
});
|
||||
assert.ok(apdu.includes('EA8188808185'), apdu);
|
||||
});
|
||||
|
||||
test('LOAD P1 fixed to 80 (last block)', () => {
|
||||
const apdu = genRamResult({ cmd: 'load', data: 'AABBCC', block: '0' });
|
||||
assert.ok(apdu.startsWith('80E88000'), apdu);
|
||||
});
|
||||
|
||||
test('DELETE: P1=00, mode in P2', () => {
|
||||
let apdu = genRamResult({ cmd: 'delete', aid: 'AA1902BC225501', delMode: '00' });
|
||||
assert.ok(apdu.startsWith('80E40000'), apdu);
|
||||
|
||||
apdu = genRamResult({ cmd: 'delete', aid: 'AA1902BC225501', delMode: '80' });
|
||||
assert.ok(apdu.startsWith('80E40080'), apdu);
|
||||
});
|
||||
|
||||
test('STORE DATA ram-enc P1 values 00/40/80/C0/E0', () => {
|
||||
for (const [enc, p1] of [['00','00'],['40','40'],['80','80'],['C0','C0'],['E0','E0']]) {
|
||||
const apdu = genRamResult({ cmd: 'store-data', data: 'AABB', enc: enc, block: '0' });
|
||||
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,68 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// Response parser maps + lookupSw live between SW_MAP and updateRespCmd.
|
||||
// Rewrite top-level const -> var so the maps leak out of sloppy-mode eval.
|
||||
eval(extractBlock('const SW_MAP = {', 'function updateRespCmd').replace(/^const /gm, 'var '));
|
||||
|
||||
test('lookupSw wildcard 91XX resolves proactive pending (any length)', () => {
|
||||
const desc = lookupSw('91', '14', 'uicc');
|
||||
assert.ok(desc.includes('proactive'), desc);
|
||||
assert.ok(!desc.includes('no response data'));
|
||||
});
|
||||
|
||||
test('lookupSw 63CX extracts retry counter', () => {
|
||||
const desc = lookupSw('63', 'C3', 'uicc');
|
||||
assert.ok(desc.includes('3'), desc);
|
||||
});
|
||||
|
||||
test('SW_MAP.generic covers ISO 6A85/6A89/6A8A', () => {
|
||||
assert.ok(SW_MAP.generic['6A85']);
|
||||
assert.ok(SW_MAP.generic['6A89']);
|
||||
assert.ok(SW_MAP.generic['6A8A']);
|
||||
});
|
||||
|
||||
test('gp 6310 label does not reference removed P2=42 option', () => {
|
||||
assert.ok(!SW_MAP.gp['6310'].includes('42'));
|
||||
});
|
||||
|
||||
test('LIFECYCLE_MAP per GPC v2.3 life cycles', () => {
|
||||
assert.strictEqual(LIFECYCLE_MAP['01'], 'OP_READY (card) / LOADED (ELF)');
|
||||
assert.strictEqual(LIFECYCLE_MAP['03'], 'INSTALLED');
|
||||
assert.strictEqual(LIFECYCLE_MAP['07'], 'SELECTABLE');
|
||||
assert.strictEqual(LIFECYCLE_MAP['0F'], 'SECURED (card)');
|
||||
assert.strictEqual(LIFECYCLE_MAP['1F'], 'PERSONALIZED (SD)');
|
||||
assert.strictEqual(LIFECYCLE_MAP['7F'], 'CARD_LOCKED');
|
||||
assert.strictEqual(LIFECYCLE_MAP['FF'], 'TERMINATED');
|
||||
assert.strictEqual(LIFECYCLE_MAP['83'], 'LOCKED (SD)');
|
||||
});
|
||||
|
||||
test('PRIVILEGE_NAMES matches GPC v2.3 Tables 11-7/11-8/11-9', () => {
|
||||
assert.strictEqual(PRIVILEGE_NAMES[0][7], 'Mandated DAP Verification');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[1][2], 'Token Verification');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[1][7], 'Global Service');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[2][0], 'Receipt Generation');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[2][3], 'Contactless Self-Activation');
|
||||
});
|
||||
|
||||
test('TS 51.011 SIM families resolve (94xx/98xx/92xx/9Exx/9Fxx)', () => {
|
||||
assert.ok(lookupSw('94', '04', 'uicc').includes('not found'));
|
||||
assert.ok(lookupSw('94', '00', 'uicc').includes('No EF selected'));
|
||||
assert.ok(lookupSw('94', '08', 'uicc').includes('inconsistent with the command'));
|
||||
assert.ok(lookupSw('98', '40', 'uicc').includes('blocked'));
|
||||
assert.ok(lookupSw('92', '40', 'uicc').includes('Memory problem'));
|
||||
assert.ok(lookupSw('98', '50', 'gp').includes('max value'));
|
||||
assert.ok(lookupSw('9E', '15', 'uicc').includes('download'));
|
||||
assert.ok(lookupSw('9F', '20', 'uicc').includes('GET RESPONSE'));
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// Extract chain builder functions and dependencies
|
||||
const FNS = ['buildApdu', 'buildSelect', 'escHtml', 'chainInit', 'chainSimBuildRowHex'];
|
||||
let code = '';
|
||||
for (const f of FNS) {
|
||||
code += extractFunc(html, f) + '\n';
|
||||
}
|
||||
// Also extract _chains
|
||||
const m = html.match(/const _chains = \{\};/);
|
||||
if (m) code += m[0].replace(/^const /, 'var ') + '\n';
|
||||
eval(code);
|
||||
|
||||
function setup(mode, opts) {
|
||||
const chainId = mode === 'sim' ? 'chain-sim' : 'chain-usim';
|
||||
_chains[chainId] = { rows: [] };
|
||||
|
||||
const cmd = opts.cmd;
|
||||
const fields = {};
|
||||
|
||||
if (cmd === 'select') {
|
||||
fields.method = opts.selMethod || 'fid';
|
||||
if (opts.fid) fields.fid = opts.fid;
|
||||
if (opts.path) fields.path = opts.path;
|
||||
if (opts.dfname) fields.dfname = opts.dfname;
|
||||
if (opts.chain) fields.chain = opts.chain;
|
||||
if (opts.silent) fields.silent = opts.silent;
|
||||
if (opts.base) fields.base = opts.base;
|
||||
} else if (cmd === 'read-record') {
|
||||
fields.record = opts.record || '1';
|
||||
fields.recMode = opts.recMode || '04';
|
||||
fields.le = opts.le || '00';
|
||||
} else if (cmd === 'read-binary') {
|
||||
fields.offset = opts.offset || '0000';
|
||||
fields.le = opts.le || '00';
|
||||
} else if (cmd === 'update-record') {
|
||||
fields.record = opts.record || '1';
|
||||
fields.recMode = opts.recMode || '04';
|
||||
fields.data = opts.data || '';
|
||||
} else if (cmd === 'update-binary') {
|
||||
fields.offset = opts.offset || '0000';
|
||||
fields.data = opts.data || '';
|
||||
} else if (cmd === 'erase-binary') {
|
||||
fields.offset = opts.offset || '0000';
|
||||
} else if (cmd === 'activate-file' || cmd === 'deactivate-file') {
|
||||
fields.target = opts.actTarget || 'current';
|
||||
fields.file = opts.actFile || '';
|
||||
} else if (cmd === 'verify' || cmd === 'disable' || cmd === 'enable') {
|
||||
fields.pin = opts.pin || '01';
|
||||
fields.pinVal = opts.pinVal || '';
|
||||
} else if (cmd === 'change' || cmd === 'unblock') {
|
||||
fields.pin = opts.pin || '01';
|
||||
fields.pinOld = opts.pinOld || '';
|
||||
fields.pinNew = opts.pinNew || '';
|
||||
}
|
||||
|
||||
_chains[chainId].rows.push({ cmd: cmd, fields: fields });
|
||||
|
||||
if (opts.doSelect) {
|
||||
const selFields = {};
|
||||
selFields.method = opts.selMethod || 'fid';
|
||||
if (opts.fid) selFields.fid = opts.fid;
|
||||
if (opts.path) selFields.path = opts.path;
|
||||
if (opts.dfname) selFields.dfname = opts.dfname;
|
||||
if (opts.chain) selFields.chain = opts.chain;
|
||||
if (opts.silent) selFields.silent = opts.silent;
|
||||
if (opts.base) selFields.base = opts.base;
|
||||
_chains[chainId].rows.unshift({ cmd: 'select', fields: selFields });
|
||||
}
|
||||
|
||||
const rowIdx = opts.doSelect ? 1 : 0;
|
||||
return chainSimBuildRowHex(chainId, rowIdx, _chains[chainId].rows[rowIdx]);
|
||||
}
|
||||
|
||||
function setupMulti(mode, rows) {
|
||||
const chainId = mode === 'sim' ? 'chain-sim' : 'chain-usim';
|
||||
_chains[chainId] = { rows: [] };
|
||||
for (const r of rows) {
|
||||
_chains[chainId].rows.push({ cmd: r.cmd, fields: r.fields || {} });
|
||||
}
|
||||
let hex = '';
|
||||
for (let i = 0; i < _chains[chainId].rows.length; i++) {
|
||||
hex += chainSimBuildRowHex(chainId, i, _chains[chainId].rows[i]);
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
test('VERIFY PIN FF-pads to 8 bytes (Lc=08)', () => {
|
||||
const apdu = setup('usim', { cmd: 'verify', pin: '01', pinVal: '1234' });
|
||||
assert.strictEqual(apdu, '002000010831323334FFFFFFFF');
|
||||
});
|
||||
|
||||
test('CHANGE PIN emits two 8-byte fields (Lc=10)', () => {
|
||||
const apdu = setup('usim', { cmd: 'change', pin: '01', pinOld: '1234', pinNew: '5678' });
|
||||
assert.strictEqual(apdu, '002400011031323334FFFFFFFF35363738FFFFFFFF');
|
||||
});
|
||||
|
||||
test('DISABLE PIN single padded PIN (INS 26)', () => {
|
||||
const apdu = setup('usim', { cmd: 'disable', pin: '02', pinVal: '9999' });
|
||||
assert.strictEqual(apdu, '002600020839393939FFFFFFFF');
|
||||
});
|
||||
|
||||
test('ENABLE PIN single padded PIN (INS 28)', () => {
|
||||
const apdu = setup('usim', { cmd: 'enable', pin: '01', pinVal: '1111' });
|
||||
assert.strictEqual(apdu, '002800010831313131FFFFFFFF');
|
||||
});
|
||||
|
||||
test('UNBLOCK PIN two padded PINs (INS 2C)', () => {
|
||||
const apdu = setup('usim', { cmd: 'unblock', pin: '02', pinOld: '12345678', pinNew: '4321' });
|
||||
assert.strictEqual(apdu, '002C000210313233343536373834333231FFFFFFFF');
|
||||
});
|
||||
|
||||
test('ACTIVATE FILE case-1 (no data, no Le)', () => {
|
||||
const apdu = setup('usim', { cmd: 'activate-file' });
|
||||
assert.strictEqual(apdu, '00440000');
|
||||
});
|
||||
|
||||
test('DEACTIVATE FILE case-1', () => {
|
||||
const apdu = setup('sim', { cmd: 'deactivate-file' });
|
||||
assert.strictEqual(apdu, 'A0040000');
|
||||
});
|
||||
|
||||
test('ACTIVATE FILE by FID (P1=00 + Lc/FID)', () => {
|
||||
const apdu = setup('usim', { cmd: 'activate-file', actTarget: 'fid', actFile: '6F3B' });
|
||||
assert.strictEqual(apdu, '00440000026F3B');
|
||||
});
|
||||
|
||||
test('ACTIVATE FILE by path from MF (P1=08)', () => {
|
||||
const apdu = setup('usim', { cmd: 'activate-file', actTarget: 'mfpath', actFile: '7FFF6FC5' });
|
||||
assert.strictEqual(apdu, '00440800047FFF6FC5');
|
||||
});
|
||||
|
||||
test('DEACTIVATE FILE by path from current DF (P1=09, SIM CLA)', () => {
|
||||
const apdu = setup('sim', { cmd: 'deactivate-file', actTarget: 'dfpath', actFile: '6F3B' });
|
||||
assert.strictEqual(apdu, 'A0040900026F3B');
|
||||
});
|
||||
|
||||
test('READ RECORD next mode forces P1=00', () => {
|
||||
const apdu = setup('usim', { cmd: 'read-record', recMode: '02', le: '20' });
|
||||
assert.strictEqual(apdu, '00B2000220');
|
||||
});
|
||||
|
||||
test('SELECT by FID USIM: P2=04 requests FCP, Le=00', () => {
|
||||
const apdu = setup('usim', { cmd: 'select', selMethod: 'fid', fid: '6FC5' });
|
||||
assert.strictEqual(apdu, '00A40004026FC500');
|
||||
});
|
||||
|
||||
test('USIM chain: every hop requests FCP (04 + Le)', () => {
|
||||
const apdu = setup('usim', { cmd: 'select', selMethod: 'chain', chain: '3F00,2FE2' });
|
||||
assert.strictEqual(apdu, '00A40004023F000000A40004022FE200');
|
||||
});
|
||||
|
||||
test('SIM chain with GET RESPONSE hop', () => {
|
||||
const apdu = setup('sim', { cmd: 'select', selMethod: 'chain', chain: '3F00,2FE2,C0' });
|
||||
assert.strictEqual(apdu, 'A0A40000023F00A0A40000022FE2A0C0000000');
|
||||
});
|
||||
|
||||
test('GET RESPONSE hop with explicit Le (C0:NN)', () => {
|
||||
const apdu = setup('usim', { cmd: 'select', selMethod: 'chain', chain: 'C0:0F' });
|
||||
assert.strictEqual(apdu, '00C000000F');
|
||||
});
|
||||
|
||||
test('silent path select + READ RECORD keeps full CLA (live PNN read)', () => {
|
||||
const chainId = 'chain-usim';
|
||||
_chains[chainId] = { rows: [
|
||||
{ cmd: 'select', fields: { method: 'path', path: '6FC5', base: 'df', silent: true } },
|
||||
{ cmd: 'read-record', fields: { record: '1', recMode: '04', le: '14' } },
|
||||
]};
|
||||
let apdu = '';
|
||||
for (let i = 0; i < _chains[chainId].rows.length; i++) {
|
||||
apdu += chainSimBuildRowHex(chainId, i, _chains[chainId].rows[i]);
|
||||
}
|
||||
assert.strictEqual(apdu, '00A4090C026FC500B2010414');
|
||||
});
|
||||
|
||||
test('select + READ BINARY emits explicit CLA on second command', () => {
|
||||
const chainId = 'chain-usim';
|
||||
_chains[chainId] = { rows: [
|
||||
{ cmd: 'select', fields: { method: 'path', path: '6FC5', base: 'df', silent: true } },
|
||||
{ cmd: 'read-binary', fields: { offset: '0000', le: '0A' } },
|
||||
]};
|
||||
let apdu = '';
|
||||
for (let i = 0; i < _chains[chainId].rows.length; i++) {
|
||||
apdu += chainSimBuildRowHex(chainId, i, _chains[chainId].rows[i]);
|
||||
}
|
||||
assert.strictEqual(apdu, '00A4090C026FC500B000000A');
|
||||
});
|
||||
|
||||
test('USIM silent path-from-current-DF hop (live RFM idiom 09/0C)', () => {
|
||||
const apdu = setup('usim', { cmd: 'select', selMethod: 'path', path: '6F46', base: 'df', silent: true });
|
||||
assert.strictEqual(apdu, '00A4090C026F46');
|
||||
});
|
||||
|
||||
test('USIM silent FID select (P2=0C, no Le)', () => {
|
||||
const apdu = setup('usim', { cmd: 'select', selMethod: 'fid', fid: '6FC5', silent: true });
|
||||
assert.strictEqual(apdu, '00A4000C026FC5');
|
||||
});
|
||||
|
||||
test('SELECT by path USIM: P2=04, Le=00', () => {
|
||||
const apdu = setup('usim', { cmd: 'select', selMethod: 'path', path: '7FFF6FC5' });
|
||||
assert.strictEqual(apdu, '00A40804047FFF6FC500');
|
||||
});
|
||||
|
||||
test('SELECT by FID SIM (CLA A0): no Le anywhere', () => {
|
||||
const apdu = setup('sim', { cmd: 'select', selMethod: 'fid', fid: '6FC5' });
|
||||
assert.strictEqual(apdu, 'A0A40000026FC5');
|
||||
});
|
||||
@@ -5,6 +5,8 @@ const path = require('node:path');
|
||||
|
||||
const des = require('des.js');
|
||||
global.des = des;
|
||||
const aesjs = require('aes-js');
|
||||
global.aesjs = aesjs;
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
@@ -25,7 +27,8 @@ function extractFunc(src, name) {
|
||||
}
|
||||
|
||||
const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt',
|
||||
'xorBytes', 'zeroPad', 'cbcMac', 'genSp'];
|
||||
'xorBytes', 'zeroPad', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
|
||||
'aesCmac', 'genSp'];
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
|
||||
@@ -90,13 +93,13 @@ test('ciphered + CC SPI 16/01 (counter_must_be_higher, plaintext PoR)', () => {
|
||||
test('unciphered + CC SPI 02/09', () => {
|
||||
assert.strictEqual(
|
||||
makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' }),
|
||||
'001D1502091515B0000000000000010085A8CA1A9828B0BB00A40000023F00');
|
||||
'1502091515B0000000000000010085A8CA1A9828B0BB00A40000023F00');
|
||||
});
|
||||
|
||||
test('unciphered packet uses CPL = octets from CHL to end (0x001d)', () => {
|
||||
test('unciphered packet starts at CHL, no CPL prefix (pySim parity)', () => {
|
||||
const out = makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' });
|
||||
assert.strictEqual(out.slice(0, 4), '001D');
|
||||
assert.strictEqual(out.length, 62);
|
||||
assert.strictEqual(out.slice(0, 2), '15');
|
||||
assert.strictEqual(out.length, 58);
|
||||
});
|
||||
|
||||
test('sysmocom public reference vector (spi1 04 / spi2 19, cntr=0)', () => {
|
||||
@@ -121,3 +124,96 @@ test('cbcMac known answer (synthetic key)', () => {
|
||||
const input = hexToBytes('001d1502091515b0000000000000010000a40000023f00');
|
||||
assert.strictEqual(bytesToHex(cbcMac(input, hexToBytes(K))), '85A8CA1A9828B0BB');
|
||||
});
|
||||
|
||||
// Public synthetic AES keys from pySim tests/unittests/test_ota.py (no live keys).
|
||||
const KIC_AES = '200102030405060708090a0b0c0d0e0f';
|
||||
const KID_AES = '201102030405060708090a0b0c0d0e0f';
|
||||
|
||||
test('aesCmac known answer (NIST SP 800-38B, truncated to 8)', () => {
|
||||
const key = hexToBytes('2b7e151628aed2a6abf7158809cf4f3c');
|
||||
assert.strictEqual(bytesToHex(aesCmac(new Uint8Array(0), key)), 'BB1D6929E9593728');
|
||||
assert.strictEqual(
|
||||
bytesToHex(aesCmac(hexToBytes('6bc1bee22e409f96e93d7e117393172a'), key)),
|
||||
'070A16B46B4D4144');
|
||||
assert.strictEqual(
|
||||
bytesToHex(aesCmac(hexToBytes('6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411'), key)),
|
||||
'DFA66747DE9AE630');
|
||||
});
|
||||
|
||||
test('AES ciphered + CC SPI 16/19 (counter higher)', () => {
|
||||
assert.strictEqual(
|
||||
makeRun({
|
||||
'sp-apdu': '00A40004023F00',
|
||||
'sp-spi1': '16',
|
||||
'sp-spi2-hex': '19',
|
||||
'sp-kic-hex': '22',
|
||||
'sp-kid-hex': '22',
|
||||
'sp-tar': 'B00011',
|
||||
'sp-cntr': '0000000011',
|
||||
'sp-kic-key': KIC_AES,
|
||||
'sp-kid-key': KID_AES,
|
||||
}),
|
||||
'00281516192222B000115A47655527E96E832F1A5C698655715D4331454A0D83952C0ED35245706976B1');
|
||||
});
|
||||
|
||||
test('AES unciphered + CC SPI 12/09 (counter higher)', () => {
|
||||
assert.strictEqual(
|
||||
makeRun({
|
||||
'sp-apdu': '00A40004023F00',
|
||||
'sp-spi1': '12',
|
||||
'sp-spi2-hex': '09',
|
||||
'sp-kic-hex': '22',
|
||||
'sp-kid-hex': '22',
|
||||
'sp-tar': 'B00011',
|
||||
'sp-cntr': '0000000011',
|
||||
'sp-kic-key': KIC_AES,
|
||||
'sp-kid-key': KID_AES,
|
||||
}),
|
||||
'1512092222B0001100000000110029826122C7A0B79500A40004023F00');
|
||||
});
|
||||
|
||||
test('AES ciphered + CC SPI 1E/19 (counter +1)', () => {
|
||||
assert.strictEqual(
|
||||
makeRun({
|
||||
'sp-apdu': '00A40004023F00',
|
||||
'sp-spi1': '1E',
|
||||
'sp-spi2-hex': '19',
|
||||
'sp-kic-hex': '22',
|
||||
'sp-kid-hex': '22',
|
||||
'sp-tar': 'B00011',
|
||||
'sp-cntr': '0000000011',
|
||||
'sp-kic-key': KIC_AES,
|
||||
'sp-kid-key': KID_AES,
|
||||
}),
|
||||
'0028151E192222B0001118B202EE47A3203E7370861C383B4142E704157B36E5C0EB4BB33EB6036CBAF8');
|
||||
});
|
||||
|
||||
test('AES rejects no_counter (SPI1 b5b4 = 00)', () => {
|
||||
const err = makeRun({
|
||||
'sp-apdu': '00A40004023F00',
|
||||
'sp-spi1': '06',
|
||||
'sp-spi2-hex': '19',
|
||||
'sp-kic-hex': '22',
|
||||
'sp-kid-hex': '22',
|
||||
'sp-tar': 'B00011',
|
||||
'sp-cntr': '0000000011',
|
||||
'sp-kic-key': KIC_AES,
|
||||
'sp-kid-key': KID_AES,
|
||||
});
|
||||
assert.ok(err.startsWith('Error: AES requires a replay-protected counter'));
|
||||
});
|
||||
|
||||
test('AES rejects 8-byte key', () => {
|
||||
const err = makeRun({
|
||||
'sp-apdu': '00A40004023F00',
|
||||
'sp-spi1': '16',
|
||||
'sp-spi2-hex': '19',
|
||||
'sp-kic-hex': '22',
|
||||
'sp-kid-hex': '22',
|
||||
'sp-tar': 'B00011',
|
||||
'sp-cntr': '0000000011',
|
||||
'sp-kic-key': '0011223344556677',
|
||||
'sp-kid-key': KID_AES,
|
||||
});
|
||||
assert.strictEqual(err, 'Error: AES KIc key must be 16, 24, or 32 bytes');
|
||||
});
|
||||
@@ -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,18 @@
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-otaman-server"
|
||||
version = "2.1.2"
|
||||
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.
|
||||
# Declaring it here would make pip resolve its full tree (including the SMPP
|
||||
# bridge -> Twisted -> twisted-iocpsupport, which needs MSVC on Windows).
|
||||
|
||||
[project.scripts]
|
||||
pysim-otaman-server = "pysim_otaman_server.__main__:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["pysim_otaman_server*"]
|
||||
@@ -0,0 +1,216 @@
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from http.server import HTTPServer
|
||||
from pySim.card_handler import CardHandler
|
||||
from pySim.commands import SimCardCommands
|
||||
from pySim.log import PySimLogger
|
||||
from pySim.cards import UiccCardBase
|
||||
|
||||
from .shell import load_pysim_app
|
||||
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
|
||||
|
||||
def _log_stdout(msg):
|
||||
elapsed = time.time() - _server_start
|
||||
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
|
||||
|
||||
|
||||
def _default_web_dir():
|
||||
# <repo>/frontend, whether run from source or an editable install.
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'frontend')
|
||||
|
||||
|
||||
def main():
|
||||
global _server_start
|
||||
_server_start = time.time()
|
||||
mod = load_pysim_app()
|
||||
parser = mod.option_parser
|
||||
parser.description = 'pysim-otaman-server — HTTP API for pysim'
|
||||
parser.add_argument('--http-host', default='127.0.0.1', help='Bind address (default: 127.0.0.1)')
|
||||
parser.add_argument('--http-port', type=int, default=8080, help='Bind port (default: 8080)')
|
||||
parser.add_argument('--web-dir', default=_default_web_dir(), metavar='PATH',
|
||||
help='Directory with the OTAMan PWA static files to serve (default: <repo>/frontend)')
|
||||
parser.add_argument('--log-requests', action='store_true', default=False, help='Log request/response payloads to stderr')
|
||||
parser.add_argument('--sms-oa', default='12345', metavar='DIGITS',
|
||||
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('--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
|
||||
rs = None
|
||||
sim_menu = None
|
||||
event_list = None
|
||||
# Auto-detect PC/SC reader if none was explicitly specified.
|
||||
# Handles late pcscd startup and USB enumeration delays.
|
||||
if opts.pcsc_dev is None and opts.pcsc_regex is None:
|
||||
try:
|
||||
from smartcard.System import readers
|
||||
for attempt in range(3):
|
||||
r = readers()
|
||||
if r:
|
||||
sys.stderr.write('INIT: PC/SC reader detected: %s\n' % r[0])
|
||||
opts.pcsc_dev = 0
|
||||
break
|
||||
if attempt < 2:
|
||||
sys.stderr.write('INIT: no PC/SC readers found, retrying in 2s...\n')
|
||||
time.sleep(2)
|
||||
except Exception:
|
||||
pass # smartcard module not available or pcscd unreachable
|
||||
|
||||
try:
|
||||
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
|
||||
_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'))
|
||||
sim_menu = sm or sim_menu
|
||||
event_list = el or event_list
|
||||
for _ in range(3):
|
||||
st_data, st_sw = _send_status(scc)
|
||||
sys.stderr.write('INIT: drain STATUS -> %s\n' % st_sw)
|
||||
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
|
||||
# directly to fd 1 so it survives the app.stdout/StringIO redirection in the
|
||||
# HTTP handlers and the INFO level suppression.
|
||||
PySimLogger.setup(print_callback=_log_stdout)
|
||||
PySimLogger.set_level(logging.DEBUG)
|
||||
# PysimApp.__init__ and every `equip` wipe the transport apdu_tracer
|
||||
# (_onchange_apdu_trace sets it to None). Re-attach our tracer and make
|
||||
# sure it stays attached across equip/re-equip.
|
||||
tracer = _LoggingApduTracer()
|
||||
def _reattach_tracer():
|
||||
if app.card:
|
||||
app.card._scc._tp.apdu_tracer = tracer
|
||||
_reattach_tracer()
|
||||
orig_onchange = app._onchange_apdu_trace
|
||||
def _onchange_apdu_trace(param_name, old, new):
|
||||
orig_onchange(param_name, old, new)
|
||||
_reattach_tracer()
|
||||
app._onchange_apdu_trace = _onchange_apdu_trace
|
||||
server = HTTPServer((opts.http_host, opts.http_port), PysimHandler)
|
||||
server.sl = sl
|
||||
server.scc = scc
|
||||
server.card = card
|
||||
server.rs = rs
|
||||
server.app = app
|
||||
server.sms_oa = opts.sms_oa
|
||||
server.sms_sc = opts.sms_sm_sc
|
||||
server.log_requests = opts.log_requests
|
||||
server.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
|
||||
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 = 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)."
|
||||
% (opts.http_host, opts.http_port))
|
||||
print("─" * 70)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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,22 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def load_pysim_app():
|
||||
import pySim
|
||||
pysim_dir = os.path.dirname(pySim.__file__)
|
||||
candidates = [
|
||||
os.path.join(os.path.dirname(pysim_dir), 'pySim-shell.py'),
|
||||
os.path.join(os.path.dirname(sys.executable), 'pySim-shell.py'),
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.exists(path):
|
||||
spec = importlib.util.spec_from_file_location("pySim_shell", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
raise ImportError(
|
||||
"pySim-shell.py not found. Make sure pysim is installed "
|
||||
"(pip install pysim) and pySim-shell.py is on the PATH."
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
# pysim dependencies minus the SMPP bridge (smpp.twisted3). Used by setup.bat.
|
||||
# pySim-shell does not use SMPP; omitting smpp.twisted3 avoids the
|
||||
# twisted -> twisted-iocpsupport build chain on Windows.
|
||||
pyscard
|
||||
pyserial
|
||||
pytlv
|
||||
cmd2>=2.6.2,<4.0
|
||||
jsonpath-ng
|
||||
construct>=2.10.70
|
||||
bidict
|
||||
pyosmocom>=0.0.12
|
||||
pyyaml>=5.4
|
||||
termcolor
|
||||
colorlog
|
||||
pycryptodomex
|
||||
packaging
|
||||
smpp.pdu @ git+https://github.com/hologram-io/smpp.pdu
|
||||
asn1tools
|
||||
@@ -0,0 +1,86 @@
|
||||
@echo off
|
||||
REM pysim-otaman-server setup script for Windows
|
||||
REM Creates a venv and installs pysim and its dependencies.
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set VENV_DIR=%~dp0.venv
|
||||
|
||||
echo === pysim-otaman-server setup ===
|
||||
echo.
|
||||
|
||||
REM Check Python
|
||||
python --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Python is required but not found.
|
||||
echo Install Python 3.8+ from https://python.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Found Python
|
||||
python --version
|
||||
|
||||
REM Check Git
|
||||
git --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Git is required but not found.
|
||||
echo Install Git from https://git-scm.com
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Found Git
|
||||
git --version
|
||||
echo.
|
||||
|
||||
REM Create venv
|
||||
if not exist "%VENV_DIR%" (
|
||||
echo Creating virtual environment...
|
||||
python -m venv "%VENV_DIR%"
|
||||
)
|
||||
call "%VENV_DIR%\Scripts\activate.bat"
|
||||
|
||||
REM Upgrade pip
|
||||
python -m pip install --upgrade pip -q
|
||||
|
||||
REM pyscard has precompiled wheels for Python 3.10-3.13.
|
||||
REM On Python 3.9 / 3.14 pip builds it from source (requires Microsoft C++ Build Tools).
|
||||
echo Note: pyscard ships precompiled wheels for Python 3.10-3.13.
|
||||
echo Python 3.9 / 3.14 will build pyscard from source and require
|
||||
echo Microsoft C++ Build Tools ("Desktop development with C++").
|
||||
echo.
|
||||
|
||||
REM Install pysim dependencies first, then pysim itself (without the SMPP
|
||||
REM bridge - not needed by pysim-shell). Installing pysim with --no-deps after
|
||||
REM its dependencies avoids pip's resolver warning about the intentionally
|
||||
REM omitted smpp.twisted3 (SMPP bridge) dependency.
|
||||
echo === Installing pysim dependencies ===
|
||||
pip install -r "%~dp0requirements-pysim.txt"
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Failed to install pysim dependencies.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo === Installing pysim ===
|
||||
pip install --no-deps git+https://github.com/osmocom/pysim.git
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Failed to install pysim.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
REM Install pysim-otaman-server
|
||||
echo === Installing pysim-otaman-server ===
|
||||
pip install -e "%~dp0."
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Failed to install pysim-otaman-server.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
echo === Setup complete ===
|
||||
echo Run start.bat to start the server.
|
||||
pause
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# pysim-otaman-server setup script
|
||||
# Creates a venv and installs pysim and its dependencies.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
|
||||
# Check prerequisites
|
||||
command -v python3 >/dev/null 2>&1 || { echo "Error: Python 3 is required but not found. Install Python 3.8+ from https://python.org"; exit 1; }
|
||||
command -v git >/dev/null 2>&1 || { echo "Error: Git is required but not found. Install Git from https://git-scm.com"; exit 1; }
|
||||
|
||||
PY_VER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
echo "Found Python $PY_VER"
|
||||
|
||||
# Create venv
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
source "$VENV_DIR/bin/activate"
|
||||
PIP="$VENV_DIR/bin/pip"
|
||||
|
||||
# Upgrade pip
|
||||
$PIP install --upgrade pip -q
|
||||
|
||||
# Install pysim from the Osmocom repository
|
||||
echo ""
|
||||
echo "=== Installing pysim ==="
|
||||
$PIP install git+https://github.com/osmocom/pysim.git
|
||||
|
||||
# Install pysim-otaman-server
|
||||
echo ""
|
||||
echo "=== Installing pysim-otaman-server ==="
|
||||
$PIP install -e "$SCRIPT_DIR"
|
||||
|
||||
# Check for PC/SC
|
||||
echo ""
|
||||
if command -v pcscd >/dev/null 2>&1; then
|
||||
echo "=== PC/SC daemon found ==="
|
||||
if ! pgrep -x pcscd >/dev/null; then
|
||||
echo "Starting pcscd..."
|
||||
sudo pcscd || echo "Warning: could not start pcscd. Start it manually: sudo systemctl start pcscd"
|
||||
fi
|
||||
else
|
||||
echo "Note: PC/SC daemon not found. If you use a USB smart card reader,"
|
||||
echo "install pcsc-lite and ccid:"
|
||||
echo " Debian/Ubuntu: sudo apt install pcscd pcsc-tools"
|
||||
echo " Arch Linux: sudo pacman -S pcsc-lite ccid"
|
||||
echo " Fedora: sudo dnf install pcsc-lite pcsc-lite-ccid"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Setup complete ==="
|
||||
echo "Run ./start.sh to start the server."
|
||||
@@ -0,0 +1,33 @@
|
||||
@echo off
|
||||
REM pysim-otaman-server start script for Windows
|
||||
REM Starts the server, preferring the venv if it exists.
|
||||
REM PC/SC is built into Windows, so defaults to reader 0.
|
||||
|
||||
set VENV_DIR=%~dp0.venv
|
||||
|
||||
if exist "%VENV_DIR%\Scripts\pysim-otaman-server.exe" (
|
||||
echo Starting pysim-otaman-server from venv on http://127.0.0.1:8080
|
||||
echo Press Ctrl+C to stop.
|
||||
"%VENV_DIR%\Scripts\pysim-otaman-server.exe" --http-port 8080 -p 0
|
||||
goto :eof
|
||||
)
|
||||
|
||||
if exist "%~dp0pysim_otaman_server\__main__.py" (
|
||||
echo Starting pysim-otaman-server from source on http://127.0.0.1:8080
|
||||
echo Press Ctrl+C to stop.
|
||||
python -m pysim_otaman_server --http-port 8080 -p 0
|
||||
goto :eof
|
||||
)
|
||||
|
||||
where pysim-otaman-server >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo Starting pysim-otaman-server on http://127.0.0.1:8080
|
||||
echo Press Ctrl+C to stop.
|
||||
pysim-otaman-server --http-port 8080 -p 0
|
||||
goto :eof
|
||||
)
|
||||
|
||||
echo Error: pysim-otaman-server not installed.
|
||||
echo Run setup.bat first or install manually:
|
||||
echo pip install pysim-otaman-server
|
||||
pause
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# pysim-otaman-server start script
|
||||
# Starts the server, preferring the venv if it exists.
|
||||
# Auto-detects PC/SC reader if available.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
|
||||
# Auto-detect reader
|
||||
READER_ARGS=""
|
||||
if command -v pcscd > /dev/null 2>&1; then
|
||||
# Give pcscd a moment if it's not running yet (USB enumeration delay)
|
||||
if ! pgrep -x pcscd > /dev/null 2>&1 && ! pgrep -x pcscd.bin > /dev/null 2>&1; then
|
||||
sleep 1
|
||||
fi
|
||||
if pgrep -x pcscd > /dev/null 2>&1 || pgrep -x pcscd.bin > /dev/null 2>&1; then
|
||||
READER_ARGS="-p 0"
|
||||
fi
|
||||
fi
|
||||
|
||||
SERVER=""
|
||||
if [ -f "$VENV_DIR/bin/pysim-otaman-server" ]; then
|
||||
SERVER="$VENV_DIR/bin/pysim-otaman-server"
|
||||
elif command -v pysim-otaman-server &> /dev/null; then
|
||||
SERVER="pysim-otaman-server"
|
||||
elif [ -f "$SCRIPT_DIR/pysim_otaman_server/__main__.py" ]; then
|
||||
echo "Starting pysim-otaman-server from source on http://127.0.0.1:8080"
|
||||
cd "$SCRIPT_DIR" && python3 -m pysim_otaman_server --http-port 8080 $READER_ARGS
|
||||
exit $?
|
||||
else
|
||||
echo "Error: pysim-otaman-server not installed."
|
||||
echo "Run setup.sh first or install manually:"
|
||||
echo " pip install pysim-otaman-server"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting pysim-otaman-server on http://127.0.0.1:8080"
|
||||
echo "Press Ctrl+C to stop."
|
||||
$SERVER --http-port 8080 $READER_ARGS
|
||||
@@ -1,12 +0,0 @@
|
||||
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');
|
||||
const opens = (html.match(/<div\b/g) || []).length;
|
||||
const closes = (html.match(/<\/div>/g) || []).length;
|
||||
|
||||
test('HTML <div> tags are balanced', () => {
|
||||
assert.strictEqual(opens, closes, `Unbalanced divs: ${opens} opens vs ${closes} closes`);
|
||||
});
|
||||
@@ -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,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')
|
||||
@@ -0,0 +1,647 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for the OTA helper functions in pysim_otaman_server.server.
|
||||
|
||||
Reference vectors are key-free: synthetic dummy keys plus the already-public
|
||||
sysmocom sample-key vectors that ship in pySim's own tests/unittests/test_ota.py.
|
||||
No live/sample card keys and no ICCIDs appear here.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
# pySim checkout is a sibling of this repo; put it on sys.path so the server
|
||||
# module (which imports pySim at module level) can be exercised against it.
|
||||
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 (
|
||||
_build_sms_tpdu,
|
||||
_build_tr,
|
||||
_decode_cmd,
|
||||
_decode_por,
|
||||
_decode_tr,
|
||||
_log_proactive,
|
||||
_ota_reference,
|
||||
_record_tr,
|
||||
_spi_from_bytes,
|
||||
_tr_data_only,
|
||||
)
|
||||
|
||||
# Synthetic dummy key material (no real card keys).
|
||||
K = '00112233445566778899AABBCCDDEEFF'
|
||||
# Public sysmocom sample keys from pySim tests/unittests/test_ota.py.
|
||||
KIC3 = 'C21DD66ACAC13CB3BC8B331B24AFB57B'
|
||||
KID3 = '12110C78E678C25408233076AA033615'
|
||||
# Public synthetic AES keys from pySim tests/unittests/test_ota.py.
|
||||
KIC_AES = '200102030405060708090a0b0c0d0e0f'
|
||||
KID_AES = '201102030405060708090a0b0c0d0e0f'
|
||||
|
||||
APDU = '00a40000023f00'
|
||||
|
||||
# (spi1, spi2) -> expected secured packet, generated with _ota_reference
|
||||
# against pySim's OtaDialectSms.encode_cmd and cross-checked with the JS genSp().
|
||||
REFERENCE_VECTORS = {
|
||||
('06', '09'):
|
||||
'00201506091515b00000c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116',
|
||||
('16', '01'):
|
||||
'00201516011515b00000e42573469e68a8462a57a505b0e2b1c09c1928c7a182311f',
|
||||
('02', '09'):
|
||||
'001d1502091515b0000000000000010085a8ca1a9828b0bb00a40000023f00',
|
||||
}
|
||||
|
||||
# AES-128 reference vectors (public synthetic keys from pySim test_ota.py).
|
||||
AES_APDU = '00a40004023f00'
|
||||
AES_REFERENCE_VECTORS = {
|
||||
('16', '19'):
|
||||
'00281516192222b000115a47655527e96e832f1a5c698655715d4331454a0d83952c0ed35245706976b1',
|
||||
('12', '09'):
|
||||
'001d1512092222b0001100000000110029826122c7a0b79500a40004023f00',
|
||||
('1e', '19'):
|
||||
'0028151e192222b0001118b202ee47a3203e7370861c383b4142e704157b36e5c0eb4bb33eb6036cbaf8',
|
||||
}
|
||||
|
||||
|
||||
class TestSpiFromBytes(unittest.TestCase):
|
||||
def test_06_09_ciphered_cc(self):
|
||||
spi = _spi_from_bytes(0x06, 0x09)
|
||||
self.assertEqual(spi, {
|
||||
'counter': 'no_counter',
|
||||
'ciphering': True,
|
||||
'rc_cc_ds': 'cc',
|
||||
'por_in_submit': False,
|
||||
'por_shall_be_ciphered': False,
|
||||
'por_rc_cc_ds': 'cc',
|
||||
'por': 'por_required',
|
||||
})
|
||||
|
||||
def test_16_01_counter_must_be_higher(self):
|
||||
spi = _spi_from_bytes(0x16, 0x01)
|
||||
self.assertEqual(spi['counter'], 'counter_must_be_higher')
|
||||
self.assertTrue(spi['ciphering'])
|
||||
self.assertEqual(spi['rc_cc_ds'], 'cc')
|
||||
self.assertEqual(spi['por_rc_cc_ds'], 'no_rc_cc_ds')
|
||||
|
||||
def test_02_09_unciphered_cc(self):
|
||||
spi = _spi_from_bytes(0x02, 0x09)
|
||||
self.assertFalse(spi['ciphering'])
|
||||
self.assertEqual(spi['rc_cc_ds'], 'cc')
|
||||
self.assertEqual(spi['por_rc_cc_ds'], 'cc')
|
||||
|
||||
def test_04_19_ciphered_no_cc(self):
|
||||
spi = _spi_from_bytes(0x04, 0x19)
|
||||
self.assertTrue(spi['ciphering'])
|
||||
self.assertEqual(spi['rc_cc_ds'], 'no_rc_cc_ds')
|
||||
self.assertTrue(spi['por_shall_be_ciphered'])
|
||||
self.assertEqual(spi['por_rc_cc_ds'], 'cc')
|
||||
|
||||
|
||||
class TestBuildSmsTpdu(unittest.TestCase):
|
||||
CHUNK = '00201506091515b00000c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116'
|
||||
SCTS = bytes.fromhex('24051215173000')
|
||||
|
||||
def _build(self, *args, **kwargs):
|
||||
with mock.patch('pysim_otaman_server.server._encode_scts', return_value=self.SCTS):
|
||||
return _build_sms_tpdu(*args, **kwargs)
|
||||
|
||||
def test_single_message_with_cpi(self):
|
||||
self.assertEqual(
|
||||
self._build(self.CHUNK, include_cpi=True),
|
||||
'4005812143f57ff6240512151730002502700000201506091515b00000'
|
||||
'c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116')
|
||||
|
||||
def test_single_message_without_cpi(self):
|
||||
self.assertEqual(
|
||||
self._build(self.CHUNK, include_cpi=False),
|
||||
'0405812143f57ff6240512151730002200201506091515b00000'
|
||||
'c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116')
|
||||
|
||||
def test_first_chunk_has_cpi(self):
|
||||
self.assertEqual(
|
||||
self._build(self.CHUNK, chunk_total=3, chunk_num=1, include_cpi=True),
|
||||
'4405812143f57ff6240512151730002a070003010301700000201506091515b00000'
|
||||
'c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116')
|
||||
|
||||
def test_later_chunk_concat_only(self):
|
||||
self.assertEqual(
|
||||
self._build(self.CHUNK, chunk_total=3, chunk_num=2, include_cpi=True),
|
||||
'4405812143f57ff6240512151730002805000301030200201506091515b00000'
|
||||
'c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116')
|
||||
|
||||
|
||||
class TestOtaReference(unittest.TestCase):
|
||||
def test_ciphered_spi_06_09(self):
|
||||
out, _ = _ota_reference('06', '09', '15', '15', 'b00000', '0000000001', APDU, K, K)
|
||||
self.assertEqual(out, REFERENCE_VECTORS[('06', '09')])
|
||||
|
||||
def test_ciphered_spi_16_01(self):
|
||||
out, _ = _ota_reference('16', '01', '15', '15', 'b00000', '0000000001', APDU, K, K)
|
||||
self.assertEqual(out, REFERENCE_VECTORS[('16', '01')])
|
||||
|
||||
def test_unciphered_spi_02_09(self):
|
||||
out, _ = _ota_reference('02', '09', '15', '15', 'b00000', '0000000001', APDU, K, K)
|
||||
self.assertEqual(out, REFERENCE_VECTORS[('02', '09')])
|
||||
|
||||
def test_unciphered_cpl_is_0x001d(self):
|
||||
# Regression: CPL counts octets from the CHL octet to the last octet
|
||||
# of the secured data (29 here), it must NOT be len(out)-2 (27/0x001b).
|
||||
out, _ = _ota_reference('02', '09', '15', '15', 'b00000', '0000000001', APDU, K, K)
|
||||
self.assertEqual(out[:4], '001d')
|
||||
self.assertEqual(len(out) // 2, 31)
|
||||
|
||||
def test_sysmocom_reference_vector(self):
|
||||
# Public vector from pySim tests/unittests/test_ota.py (test_cmd_3des_ciphered).
|
||||
out, _ = _ota_reference('04', '19', '35', '35', 'b00000', '0000000000', APDU, KIC3, KID3)
|
||||
self.assertEqual(out, '00180d04193535b00000e3ec80a849b554421276af3883927c20')
|
||||
|
||||
def test_returns_spi_dict(self):
|
||||
_, spi = _ota_reference('16', '01', '15', '15', 'b00000', '0000000001', APDU, K, K)
|
||||
self.assertEqual(spi['counter'], 'counter_must_be_higher')
|
||||
self.assertTrue(spi['ciphering'])
|
||||
|
||||
def test_aes128_ciphered_cc(self):
|
||||
out, _ = _ota_reference('16', '19', '22', '22', 'b00011', '0000000011', AES_APDU, KIC_AES, KID_AES)
|
||||
self.assertEqual(out, AES_REFERENCE_VECTORS[('16', '19')])
|
||||
|
||||
def test_aes128_unciphered_cc(self):
|
||||
out, _ = _ota_reference('12', '09', '22', '22', 'b00011', '0000000011', AES_APDU, KIC_AES, KID_AES)
|
||||
self.assertEqual(out, AES_REFERENCE_VECTORS[('12', '09')])
|
||||
|
||||
def test_aes128_counter_plus_one(self):
|
||||
out, spi = _ota_reference('1e', '19', '22', '22', 'b00011', '0000000011', AES_APDU, KIC_AES, KID_AES)
|
||||
self.assertEqual(out, AES_REFERENCE_VECTORS[('1e', '19')])
|
||||
self.assertEqual(spi['counter'], 'counter_must_be_lower')
|
||||
|
||||
|
||||
class TestDecodePor(unittest.TestCase):
|
||||
def test_plaintext_no_cc_synthetic(self):
|
||||
r = _decode_por('02', '01', '15', '15', '0000000001', K, K,
|
||||
'027100000e0ab0000000000000010000016e00')
|
||||
self.assertEqual(r['response_status'], 'por_ok')
|
||||
self.assertEqual(r['tar'], 'B00000')
|
||||
self.assertEqual(r['decoded']['last_status_word'], '6e00')
|
||||
|
||||
def test_sysmocom_signed(self):
|
||||
r = _decode_por('06', '09', '35', '35', '0000000001', KIC3, KID3,
|
||||
'027100001612b000110000000000000055f47118381175fb01612f')
|
||||
self.assertEqual(r['response_status'], 'por_ok')
|
||||
self.assertEqual(r['decoded']['last_status_word'], '612f')
|
||||
|
||||
def test_sysmocom_ciphered(self):
|
||||
r = _decode_por('06', '19', '35', '35', '0000000001', KIC3, KID3,
|
||||
'027100001c12b000119660ebdb81be189b5e4389e9e7ab2bc0954f963ad869ed7c')
|
||||
self.assertEqual(r['response_status'], 'por_ok')
|
||||
self.assertEqual(r['decoded']['last_status_word'], '612f')
|
||||
|
||||
def test_sysmocom_no_cc(self):
|
||||
r = _decode_por('06', '01', '35', '35', '0000000001', KIC3, KID3,
|
||||
'027100000e0ab000110000000000000001612f')
|
||||
self.assertEqual(r['response_status'], 'por_ok')
|
||||
self.assertEqual(r['decoded']['last_status_word'], '612f')
|
||||
|
||||
def test_complete_field_report(self):
|
||||
"""All parsed PoR fields are surfaced verbatim (v1.9.4)."""
|
||||
raw = '027100000e0ab000110000000000000001612f'
|
||||
r = _decode_por('06', '01', '35', '35', '0000000001', KIC3, KID3, raw)
|
||||
self.assertEqual(r['response_status'], 'por_ok')
|
||||
self.assertEqual(r['tar'], 'B00011')
|
||||
self.assertEqual(r['cntr'], '0000000000')
|
||||
self.assertEqual(r['pcntr'], 0)
|
||||
self.assertEqual(r['rpl'], 14)
|
||||
self.assertEqual(r['rhl'], 10)
|
||||
self.assertEqual(r['cc_rc'], '')
|
||||
self.assertEqual(r['raw'], raw)
|
||||
self.assertNotIn('cntr_low', str(r))
|
||||
|
||||
def test_cntr_low_fields(self):
|
||||
r = _decode_por('02', '01', '15', '15', '0000000001', K, K,
|
||||
'027100000b0ab0000000000000070002')
|
||||
self.assertEqual(r['response_status'], 'cntr_low')
|
||||
self.assertEqual(r['tar'], 'B00000')
|
||||
self.assertEqual(r['cntr'], '0000000007')
|
||||
self.assertEqual(r['rpl'], 11)
|
||||
self.assertEqual(r['rhl'], 10)
|
||||
self.assertIsNone(r.get('decoded'))
|
||||
|
||||
def test_sysmocom_bad_cc_returns_none(self):
|
||||
r = _decode_por('06', '09', '35', '35', '0000000001', KIC3, KID3,
|
||||
'027100001612b000110000000000000055f47118381175fb02612f')
|
||||
self.assertIsNone(r)
|
||||
|
||||
def test_aes128_ciphered(self):
|
||||
r = _decode_por('06', '19', '22', '22', '0000000001', KIC_AES, KID_AES,
|
||||
'027100002412b00011ebc6b497e2cad7aedf36ace0e3a29b38853f0fe9ccde81913be5702b73abce1f')
|
||||
self.assertEqual(r['response_status'], 'por_ok')
|
||||
self.assertEqual(r['decoded']['last_status_word'], '6132')
|
||||
|
||||
def test_malformed_returns_none(self):
|
||||
for bad in ['', '00', '00027100000e0a', '027100000e0ab00000', 'garbage', 'zz']:
|
||||
self.assertIsNone(
|
||||
_decode_por('02', '01', '15', '15', '0000000001', K, K, bad),
|
||||
msg='expected None for %r' % bad)
|
||||
|
||||
|
||||
class TestProactiveDecode(unittest.TestCase):
|
||||
"""Server-side proactive command/TR decode helpers (v1.8.0 log feature)."""
|
||||
|
||||
def setUp(self):
|
||||
import pysim_otaman_server.server as srv
|
||||
srv._PROACTIVE_SESSION_START = 1234.0
|
||||
srv._PLI_DATA[0x00] = '93055210011000'
|
||||
|
||||
def test_decode_cmd_poll_interval(self):
|
||||
r = _decode_cmd(0x03, bytes.fromhex('d00d8103010300820283818402011e'), None)
|
||||
self.assertEqual(r, [{'label': 'Interval', 'value': '30 s'}])
|
||||
|
||||
def test_decode_cmd_setup_event_list(self):
|
||||
r = _decode_cmd(0x05, bytes.fromhex('d00c810301050082028381990101'), None)
|
||||
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'}])
|
||||
|
||||
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_empty_raw(self):
|
||||
self.assertEqual(_decode_cmd(0x26, b'', None), [])
|
||||
self.assertEqual(_decode_cmd(0x03, None, None), [])
|
||||
|
||||
def test_decode_tr_pli_location(self):
|
||||
r = _decode_tr('26', '00', '93055210011000')
|
||||
self.assertEqual(r, [
|
||||
{'label': 'MCC', 'value': '250'},
|
||||
{'label': 'MNC', 'value': '11'},
|
||||
{'label': 'LAC/TAC', 'value': '1000'},
|
||||
])
|
||||
|
||||
def test_decode_tr_pli_imei(self):
|
||||
r = _decode_tr('26', '01', '94082143658709214305')
|
||||
self.assertEqual(r[0], {'label': 'IMEI', 'value': '123456789012345'})
|
||||
|
||||
def test_decode_tr_pli_access_technology(self):
|
||||
r = _decode_tr('26', '06', 'bf0103')
|
||||
self.assertEqual(r, [{'label': 'Access Technology', 'value': 'UTRAN (3)'}])
|
||||
|
||||
def test_decode_tr_pli_search_mode(self):
|
||||
r = _decode_tr('26', '09', 'ad0101')
|
||||
self.assertEqual(r, [{'label': 'Search Mode', 'value': 'Manual'}])
|
||||
|
||||
def test_decode_tr_poll_interval(self):
|
||||
r = _decode_tr('03', None, '8402011e')
|
||||
self.assertEqual(r, [{'label': 'Interval', 'value': '30 s'}])
|
||||
|
||||
def test_decode_tr_empty(self):
|
||||
self.assertEqual(_decode_tr('26', '00', ''), [])
|
||||
|
||||
def test_tr_data_only_strips_boilerplate(self):
|
||||
tr = bytes.fromhex('81030326008202818393055210011000030100')
|
||||
self.assertEqual(_tr_data_only(tr).hex(), '93055210011000')
|
||||
|
||||
def test_build_and_record_tr(self):
|
||||
entry = {'type_hex': '26', 'qualifier': '00'}
|
||||
tr = _build_tr(None, 3, 0x26, 0x83, 0x81, 0x00)
|
||||
_record_tr(entry, tr, '9000')
|
||||
self.assertEqual(entry['tr_hex'], '93055210011000')
|
||||
self.assertEqual(entry['tr_sw'], '9000')
|
||||
self.assertEqual([f['label'] for f in entry['tr_decoded']], ['MCC', 'MNC', 'LAC/TAC'])
|
||||
|
||||
def test_record_tr_without_sw(self):
|
||||
entry = {'type_hex': '26', 'qualifier': '00'}
|
||||
_record_tr(entry, bytes.fromhex('93055210011000'))
|
||||
self.assertNotIn('tr_sw', entry)
|
||||
self.assertEqual(entry['tr_hex'], '93055210011000')
|
||||
|
||||
def test_log_proactive_fields(self):
|
||||
entry = _log_proactive(0x26, b'\xd0', 0x00, 7)
|
||||
self.assertEqual(entry['cmd_num'], 7)
|
||||
self.assertEqual(entry['type_hex'], '26')
|
||||
self.assertEqual(entry['qualifier'], '00')
|
||||
self.assertEqual(entry['raw'], 'd0')
|
||||
self.assertEqual(entry['cmd_decoded'][0]['label'], 'Qualifier')
|
||||
self.assertIn('id', entry)
|
||||
|
||||
def test_log_proactive_malformed_raw(self):
|
||||
entry = _log_proactive(0x21, bytes([0xD0, 0xFF]), 0x00)
|
||||
self.assertEqual(entry['cmd_decoded'], [])
|
||||
|
||||
def test_log_proactive_no_cmd_num(self):
|
||||
entry = _log_proactive(0x05, bytes.fromhex('990101'), None)
|
||||
self.assertNotIn('cmd_num', entry)
|
||||
|
||||
def test_record_tr_basic_result(self):
|
||||
entry = {'type_hex': '03', 'qualifier': None}
|
||||
_record_tr(entry, bytes.fromhex('8103010300820281838402011e030100'))
|
||||
self.assertEqual(entry['tr_result'], '00')
|
||||
self.assertEqual(entry['tr_result_name'], 'Command performed successfully')
|
||||
self.assertEqual(entry['tr_hex'], '8402011e')
|
||||
|
||||
def test_record_tr_general_result(self):
|
||||
entry = {'type_hex': '26', 'qualifier': '00'}
|
||||
_record_tr(entry, bytes.fromhex('8103032600820281839305521001100083022001'))
|
||||
self.assertEqual(entry['tr_result'], '2001')
|
||||
self.assertEqual(entry['tr_result_name'], 'ME currently unable to process command')
|
||||
self.assertEqual(entry['tr_hex'], '93055210011000')
|
||||
|
||||
def test_record_tr_unknown_result(self):
|
||||
entry = {'type_hex': '26', 'qualifier': '00'}
|
||||
_record_tr(entry, bytes.fromhex('810303260082028181030107'))
|
||||
self.assertEqual(entry['tr_result'], '07')
|
||||
self.assertNotIn('tr_result_name', entry)
|
||||
|
||||
def test_record_tr_no_result_tlv(self):
|
||||
entry = {'type_hex': '26', 'qualifier': '00'}
|
||||
_record_tr(entry, bytes.fromhex('810303260082028181'))
|
||||
self.assertNotIn('tr_result', entry)
|
||||
|
||||
|
||||
class TestExpandedRemoteResponse(unittest.TestCase):
|
||||
"""Expanded Remote Response parsing (TS 102 226 §5.2.2)."""
|
||||
|
||||
def test_expanded_response_single_command(self):
|
||||
entry = {'type_hex': '03', 'qualifier': None}
|
||||
_record_tr(entry, bytes.fromhex('810301030082028183030100'))
|
||||
self.assertEqual(entry['tr_result'], '00')
|
||||
self.assertEqual(entry['tr_result_name'], 'Command performed successfully')
|
||||
|
||||
def test_expanded_response_parser_single_command(self):
|
||||
# Simple test of the construct parsing structure
|
||||
try:
|
||||
from construct import Struct, Int8ub, Bytes, GreedyBytes, Optional, Array, this
|
||||
from osmocom.utils import b2h
|
||||
|
||||
# Create sample expanded response data
|
||||
secured_data = bytes.fromhex('01' '01' '9000' '11') # response_count, cmd#, SW, data
|
||||
|
||||
ExpandedRemoteResponse = Struct(
|
||||
'response_count'/Int8ub,
|
||||
'responses'/Array(this.response_count, Struct(
|
||||
'command_number'/Int8ub,
|
||||
'status_word'/Bytes(2),
|
||||
'response_data'/GreedyBytes,
|
||||
'error_details'/Optional(Struct(
|
||||
'error_code'/Int8ub,
|
||||
'error_info'/GreedyBytes
|
||||
)),
|
||||
'chaining_context'/Optional(Struct(
|
||||
'script_id'/Bytes(4),
|
||||
'is_first'/Int8ub,
|
||||
'is_last'/Int8ub,
|
||||
))
|
||||
))
|
||||
)
|
||||
expanded = ExpandedRemoteResponse.parse(secured_data)
|
||||
self.assertEqual(expanded.response_count, 1)
|
||||
self.assertEqual(expanded.responses[0].command_number, 1)
|
||||
self.assertEqual(b2h(expanded.responses[0].status_word).upper(), '9000')
|
||||
self.assertEqual(b2h(expanded.responses[0].response_data).upper(), '11')
|
||||
except Exception as e:
|
||||
self.fail(f"ExpandedRemoteResponse parsing failed: {e}")
|
||||
|
||||
def test_expanded_response_with_error(self):
|
||||
entry = {'type_hex': '26', 'qualifier': '01'}
|
||||
# Result TLV: 03 01 6A (error_code 0x6A)
|
||||
_record_tr(entry, bytes.fromhex('81030326008202818303016A'))
|
||||
self.assertEqual(entry['tr_result'], '6a')
|
||||
self.assertEqual(entry['tr_result_name'], 'Command performed with limited understanding')
|
||||
|
||||
def test_expanded_response_with_chaining(self):
|
||||
entry = {'type_hex': '26', 'qualifier': '00'}
|
||||
# Result: 03 01 00 + chaining context with script_id
|
||||
_record_tr(entry, bytes.fromhex('81030326008202818303010093'))
|
||||
self.assertEqual(entry['tr_result'], '00')
|
||||
self.assertEqual(entry['tr_result_name'], 'Command performed successfully')
|
||||
|
||||
|
||||
class TestSmsConcat(unittest.TestCase):
|
||||
"""Tests for _parse_sms_concat — SMS UDH concatenation parsing."""
|
||||
|
||||
def test_no_udh_sms_submit(self):
|
||||
"""SMS-SUBMIT without TP-UDHI: entire UD is payload."""
|
||||
from pysim_otaman_server.server import _parse_sms_concat
|
||||
# First octet 0x01: MTI=01 (SUBMIT), no UDH, no VP
|
||||
# MR=00, DA_len=05, DA_type=90, DA=2143F5, PID=00, DCS=04, UDL=03, UD=AABBCC
|
||||
tpdu = bytes.fromhex('0100' # first octet + MR
|
||||
'05' # DA length
|
||||
'90' # DA type
|
||||
'2143F5' # DA data (3 bytes for 5 digits)
|
||||
'0004' # PID + DCS
|
||||
'03' # UDL
|
||||
'AABBCC') # UD (payload)
|
||||
ref, total, num, payload = _parse_sms_concat(tpdu)
|
||||
self.assertIsNone(ref)
|
||||
self.assertIsNone(total)
|
||||
self.assertIsNone(num)
|
||||
self.assertEqual(payload.hex(), 'aabbcc')
|
||||
|
||||
def test_8bit_concat_iei_0x00(self):
|
||||
"""SMS-SUBMIT with IEI 0x00 (8-bit reference concatenation)."""
|
||||
from pysim_otaman_server.server import _parse_sms_concat
|
||||
# First octet 0x41: MTI=01 (SUBMIT), TP-UDHI=1, no VP
|
||||
# MR=00, DA_len=05, DA_type=90, DA=2143F5, PID=00, DCS=04
|
||||
# UDL=09, UDHL=05, UDH: 00 03 04 04 01 (concat IE), payload=AABBCC
|
||||
tpdu = bytes.fromhex('4100' # first octet + MR
|
||||
'05' # DA length
|
||||
'90' # DA type
|
||||
'2143F5' # DA data
|
||||
'0004' # PID + DCS
|
||||
'09' # UDL (1 UDHL + 5 UDH + 3 payload = 9)
|
||||
'05' # UDHL = 5 bytes of UDH
|
||||
'0003' # IEI=0x00, IEDL=3
|
||||
'04' # ref
|
||||
'04' # total (4 segments)
|
||||
'01' # num (segment 1)
|
||||
'AABBCC') # payload
|
||||
ref, total, num, payload = _parse_sms_concat(tpdu)
|
||||
self.assertEqual(ref, 0x04)
|
||||
self.assertEqual(total, 4)
|
||||
self.assertEqual(num, 1)
|
||||
self.assertEqual(payload.hex(), 'aabbcc')
|
||||
|
||||
def test_16bit_concat_iei_0x08(self):
|
||||
"""SMS-SUBMIT with IEI 0x08 (16-bit reference concatenation)."""
|
||||
from pysim_otaman_server.server import _parse_sms_concat
|
||||
# First octet 0x41: MTI=01, TP-UDHI=1
|
||||
# UDH: 06 (UDHL) 08 04 01 02 03 04 (16-bit concat: ref=0x0102, total=3, num=4)
|
||||
# payload=FF
|
||||
tpdu = bytes.fromhex('4100'
|
||||
'05'
|
||||
'90'
|
||||
'2143F5'
|
||||
'0004'
|
||||
'08' # UDL (1 UDHL + 6 UDH + 1 payload = 8)
|
||||
'06' # UDHL
|
||||
'0804' # IEI=0x08, IEDL=4
|
||||
'0102' # ref (16-bit, big-endian)
|
||||
'03' # total
|
||||
'04' # num
|
||||
'FF') # payload
|
||||
ref, total, num, payload = _parse_sms_concat(tpdu)
|
||||
self.assertEqual(ref, 0x0102)
|
||||
self.assertEqual(total, 3)
|
||||
self.assertEqual(num, 4)
|
||||
self.assertEqual(payload.hex(), 'ff')
|
||||
|
||||
def test_udh_with_cpi(self):
|
||||
"""UDH with concatenation IE + CPI IE (0x70)."""
|
||||
from pysim_otaman_server.server import _parse_sms_concat
|
||||
# First octet 0x41: MTI=01, TP-UDHI=1
|
||||
# UDHL=07, UDH: 00 03 04 04 01 (concat) + 70 00 (CPI)
|
||||
tpdu = bytes.fromhex('4100'
|
||||
'05'
|
||||
'90'
|
||||
'2143F5'
|
||||
'0004'
|
||||
'0A' # UDL (1 UDHL + 7 UDH + 1 payload = 9? no: 1+5+2+1=9, but UDH=7 bytes)
|
||||
'07' # UDHL = 7
|
||||
'0003' # IEI=0x00, IEDL=3
|
||||
'04' # ref
|
||||
'04' # total
|
||||
'01' # num
|
||||
'7000' # CPI IE (IEI=0x70, IEDL=0)
|
||||
'DD') # payload
|
||||
ref, total, num, payload = _parse_sms_concat(tpdu)
|
||||
self.assertEqual(ref, 0x04)
|
||||
self.assertEqual(total, 4)
|
||||
self.assertEqual(num, 1)
|
||||
self.assertEqual(payload.hex(), 'dd')
|
||||
|
||||
def test_empty_payload(self):
|
||||
"""Segment with empty payload after UDH."""
|
||||
from pysim_otaman_server.server import _parse_sms_concat
|
||||
# First octet 0x41: MTI=01, TP-UDHI=1
|
||||
tpdu = bytes.fromhex('4100'
|
||||
'05'
|
||||
'90'
|
||||
'2143F5'
|
||||
'0004'
|
||||
'06' # UDL (1 UDHL + 5 UDH + 0 payload = 6)
|
||||
'05' # UDHL
|
||||
'0003'
|
||||
'01'
|
||||
'02'
|
||||
'01') # no payload after UDH
|
||||
ref, total, num, payload = _parse_sms_concat(tpdu)
|
||||
self.assertEqual(ref, 0x01)
|
||||
self.assertEqual(total, 2)
|
||||
self.assertEqual(num, 1)
|
||||
self.assertEqual(len(payload), 0)
|
||||
|
||||
def test_short_tpdu(self):
|
||||
"""Truncated TPDU returns gracefully."""
|
||||
from pysim_otaman_server.server import _parse_sms_concat
|
||||
ref, total, num, payload = _parse_sms_concat(b'\x01')
|
||||
self.assertIsNone(ref)
|
||||
self.assertIsNone(total)
|
||||
self.assertIsNone(num)
|
||||
|
||||
def test_none_input(self):
|
||||
"""None input returns empty payload."""
|
||||
from pysim_otaman_server.server import _parse_sms_concat
|
||||
ref, total, num, payload = _parse_sms_concat(None)
|
||||
self.assertIsNone(ref)
|
||||
self.assertIsNone(total)
|
||||
self.assertIsNone(num)
|
||||
self.assertEqual(len(payload), 0)
|
||||
|
||||
def test_short_tpdu(self):
|
||||
"""Truncated TPDU returns gracefully."""
|
||||
from pysim_otaman_server.server import _parse_sms_concat
|
||||
ref, total, num, payload = _parse_sms_concat(b'\x44')
|
||||
self.assertIsNone(ref)
|
||||
self.assertIsNone(total)
|
||||
self.assertIsNone(num)
|
||||
|
||||
def test_none_input(self):
|
||||
"""None input returns empty payload."""
|
||||
from pysim_otaman_server.server import _parse_sms_concat
|
||||
ref, total, num, payload = _parse_sms_concat(None)
|
||||
self.assertIsNone(ref)
|
||||
self.assertIsNone(total)
|
||||
self.assertIsNone(num)
|
||||
self.assertEqual(len(payload), 0)
|
||||
|
||||
|
||||
class TestSmsReassembly(unittest.TestCase):
|
||||
"""Tests for SMS segment reassembly logic."""
|
||||
|
||||
def test_single_segment_no_concat(self):
|
||||
"""Single segment without UDH → submit_tpdu_hex is set directly."""
|
||||
from pysim_otaman_server.server import PoRSubmitHandler, _find_sms_tpdu, _parse_sms_concat
|
||||
handler = PoRSubmitHandler()
|
||||
# Build a simple D0 with tag 8B containing an SMS-SUBMIT without UDH
|
||||
sms_tpdu = bytes.fromhex('040005902143F50004' # SMS-SUBMIT header
|
||||
'03' # UDL
|
||||
'AABBCC') # payload
|
||||
# Wrap in D0 proactive command
|
||||
d0 = bytes([0xD0, len(sms_tpdu) + 4, # approximate BER length
|
||||
0x81, 0x03, 0x01, 0x13, 0x00, # Command Details
|
||||
0x82, 0x02, 0x81, 0x83, # Device Identities
|
||||
0x8B, len(sms_tpdu)]) # tag 8B
|
||||
# Simulate _find_sms_tpdu extracting tag 8B
|
||||
found = sms_tpdu.hex()
|
||||
# Parse and check
|
||||
ref, total, num, payload = _parse_sms_concat(sms_tpdu)
|
||||
self.assertIsNone(ref)
|
||||
handler.submit_tpdu_hex = found # single segment path
|
||||
self.assertEqual(handler.submit_tpdu_hex, found)
|
||||
|
||||
def test_multi_segment_reassembly(self):
|
||||
"""3 segments with IEI 0x00 in random order → assembled in correct order."""
|
||||
from pysim_otaman_server.server import PoRSubmitHandler
|
||||
handler = PoRSubmitHandler()
|
||||
|
||||
# Segment payloads (after UDH)
|
||||
payloads = [b'\x01\x02', b'\x03\x04', b'\x05\x06']
|
||||
ref = 0x42
|
||||
total = 3
|
||||
|
||||
# Simulate receiving segments in random order: 2, 0, 1
|
||||
for idx in [1, 0, 2]:
|
||||
num = idx + 1
|
||||
handler.sms_segments.append((ref, total, num, payloads[idx].hex()))
|
||||
# Check if all segments collected
|
||||
matching = [s for s in handler.sms_segments if s[0] == ref]
|
||||
if len(matching) >= total:
|
||||
sorted_segs = sorted(matching, key=lambda s: s[2])
|
||||
assembled = b''.join(bytes.fromhex(s[3]) for s in sorted_segs)
|
||||
handler.submit_tpdu_hex = assembled.hex()
|
||||
|
||||
self.assertEqual(handler.submit_tpdu_hex, '010203040506')
|
||||
|
||||
def test_independent_references(self):
|
||||
"""Two different reference numbers are independent."""
|
||||
from pysim_otaman_server.server import PoRSubmitHandler
|
||||
handler = PoRSubmitHandler()
|
||||
|
||||
# Ref 0x01: 2 segments
|
||||
handler.sms_segments.append((0x01, 2, 1, 'AA'))
|
||||
handler.sms_segments.append((0x01, 2, 2, 'BB'))
|
||||
matching = [s for s in handler.sms_segments if s[0] == 0x01]
|
||||
if len(matching) >= 2:
|
||||
sorted_segs = sorted(matching, key=lambda s: s[2])
|
||||
assembled = b''.join(bytes.fromhex(s[3]) for s in sorted_segs)
|
||||
handler.submit_tpdu_hex = assembled.hex()
|
||||
|
||||
self.assertEqual(handler.submit_tpdu_hex, 'aabb')
|
||||
|
||||
# Ref 0x02: 1 segment (independent)
|
||||
handler.sms_segments.append((0x02, 1, 1, 'CC'))
|
||||
matching2 = [s for s in handler.sms_segments if s[0] == 0x02]
|
||||
if len(matching2) >= 1:
|
||||
sorted_segs2 = sorted(matching2, key=lambda s: s[2])
|
||||
assembled2 = b''.join(bytes.fromhex(s[3]) for s in sorted_segs2)
|
||||
# Only update if ref 0x02 is complete
|
||||
handler.submit_tpdu_hex = assembled2.hex()
|
||||
|
||||
# Last assembly was ref 0x02
|
||||
self.assertEqual(handler.submit_tpdu_hex, 'cc')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||