scp81: PSK TLS server and command scripting (phases B/C) + BIP fix (v2.1.5)

- scp81.py: PSK TLS listener (stdlib ssl PSK callbacks) speaking the GP
  HTTP administration dialog; configurable framing (chunked/Content-Length,
  TLS record split, Apache-style/compact headers, Connection header,
  keep-alive, Next-URI template with %d, TLS version/cipher, answer delay,
  keylog for capture decryption)
- server.py: script responder + Response Scripting parsing (AF/AB, 80/23
  TLVs), memory decoder, SCP81 start options, terminal-side timer
  management, background-mode BIP events, permissive OPEN CHANNEL
- BIP fix: the RECEIVE DATA channel-data TLV length is BER long form
  (36 81 <len>) above 127 bytes; a raw length byte is mis-parsed on the
  card, so the large TLS records never reached its stack (a live card
  fetched the script response and silently never processed it - endless
  resume). The card now executes scripts and returns R-APDUs: memory
  (13 applets, 50646 B NV free, 2402 B volatile), ISD, stored HTTP OTA
  parameters, ELF and application registries
- frontend: SCP81 tab (listener, script selection, HTTP OTA log), phone
  event forms, i18n; service worker v141
- docs: api.md, scp81-findings.md (attempt matrix + root cause analysis);
  tools/scp81_decrypt.py decrypts listener captures via the keylog
- tests: 187 python + 337 frontend
This commit is contained in:
2026-09-16 01:50:29 +03:00
parent ea1730b206
commit 175ca934d8
18 changed files with 3049 additions and 56 deletions
+17 -3
View File
@@ -31,7 +31,7 @@ npm run build
## Interface
Five top-level tabs: **Remote APDU**, **SCP80**, **Profiler**, **Card reader**, and **Phone simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the Card reader tab has three sub-tabs: **File manager**, **pySim command line**, and **Raw APDU**; the Profiler tab lists **Profiles**, **Card snapshots**, and **Custom files**.
Six top-level tabs: **Remote APDU**, **SCP80**, **SCP81**, **Profiler**, **Card reader**, and **Phone simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the Card reader tab has three sub-tabs: **File manager**, **pySim command line**, and **Raw APDU**; the Profiler tab lists **Profiles**, **Card snapshots**, and **Custom files**.
---
@@ -556,12 +556,16 @@ The **Phone simulator** tab provides real-time CAT session interaction. It has t
- **Location Status** — dropdown for Normal / Limited / No service
- **Access Technology Change** — dropdown for all 13 RAT types
- **Card Reader Status, Language, UICC Access** — appropriate inputs
- **Channel Status** — channel selector, link state (not established / TCP
LISTEN / established) and info (no further info / link dropped), per TS 102 223 8.56
- **Network Rejection** — full adaptive form with registration type dropdown
(LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access
technology selection, and 53-cause unified rejection cause code dropdown
covering EMM, GMM, 5GMM, and LU causes
**Proactive Command Log** — chronological list of proactive commands encountered (seconds elapsed, type code, name, byte count). Covers SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, and PROVIDE LOCAL INFORMATION.
**Proactive Command Log** — chronological list of proactive commands encountered (seconds elapsed, type code, name, byte count). Covers SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, PROVIDE LOCAL INFORMATION, TIMER MANAGEMENT, and the BIP commands (OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA, GET CHANNEL STATUS); BIP commands are decoded with both plain and comprehension-required TLV tags.
**Timer management** — the server acts as the terminal for TIMER MANAGEMENT (TS 102 223 §6.6.21/§7.4): started timers are tracked per card session, deactivate/get TERMINAL RESPONSEs carry the remaining value, and on expiry the card receives ENVELOPE (TIMER EXPIRATION). The live card uses this to retry the OTA session after a failed OPEN CHANNEL.
**TR Config: PLI data dictionary** — editable per-qualifier hex values for all 22 PROVIDE LOCAL INFORMATION qualifiers (TS 102 223 + TS 131 111). 10 qualifiers have inline decode/encode forms (toggle):
@@ -580,6 +584,16 @@ The **Phone simulator** tab provides real-time CAT session interaction. It has t
Values persist on the server until restart. Apply → hex updates; Save → POSTs to server. The server will use these values to populate TERMINAL RESPONSE data for future PLI proactive commands.
## SCP81
The **SCP81** tab drives HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B). The card's BIP channel is always redirected to a local listener on the server:
- **Capture (dump)** — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering. Use it to inspect what the card asks for.
- **PSK TLS server** — answers the handshake with the TLS 1.2 PSK cipher suites of the spec and speaks the GP HTTP administration dialog (`X-Admin-*` headers, `200` with a command string or `204 No Content`). Enter the **PSK Identity** the card uses and the **PSK key (hex)**; the key is only sent to the local server, never stored or logged.
- **Script** — the command script served over the session: **Memory + ELF info** (default) sends `GET DATA FF21` (available non-volatile/volatile memory, applet count) and `GET STATUS P1=20/10` (Executable Load Files and modules registry) as RAM/GP commands in TS 102 226 Command Scripting templates, one C-APDU per request; **None** closes every session with `204`. Custom APDU lists are accepted by the API.
The state line shows the listener, the negotiated identity and live channels (bytes in/out); the log records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step, including each R-APDU (`script-rapdu`, `script-memory`). The same controls are available through `POST /api/scp81/bip` and `GET /api/scp81/script` (see `docs/api.md`).
## PWA
OTAMan is a Progressive Web App and can be installed for offline use. Use the **INSTALL PWA** button in the header, or use the browser's install prompt.
@@ -637,7 +651,7 @@ pysim-otaman-server --http-port 8080
| `--apdu-trace` | Log APDU-level traces to stderr |
| `--log-requests` | Log request/response payloads to stderr |
| `--sms-oa` / `--sms-sm-sc` | SMS-DELIVER originating address / SM-SC for PoR-in-submit |
| `--terminal-profile` | TERMINAL PROFILE payload hex (default 10-byte GSM profile) |
| `--terminal-profile` | TERMINAL PROFILE payload hex (default: 33-byte real-handset profile that advertises BIP events/commands; the live card ignores HTTP OTA without it) |
| `--poll-interval` | Idle interval before automatic STATUS polling (default 30s; `0` disables polling) |
| `--full-pysim-init` | Use pysim's stock init/equip (redundant card resets). The default init/equip is reset-free — only explicit equip/reset reconnect the card |
| `--no-auto-equip` | Do not initialize a card automatically right after it is inserted (default: auto-equip on) |
+16 -2
View File
@@ -31,7 +31,7 @@ npm run build
## Интерфейс
Пять вкладок: **Remote APDU**, **SCP80**, **Profiler**, **Card reader** и **Phone simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
Шесть вкладок: **Remote APDU**, **SCP80**, **SCP81**, **Profiler**, **Card reader** и **Phone simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
---
@@ -529,9 +529,13 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
- **События без данных** (User Activity, Idle Screen и др.) — однократное уведомление
- **Location Status** — выпадающий список: Normal / Limited / No service
- **Access Technology Change** — 13 типов RAT
- **Channel Status** — выбор канала, состояние линии (не установлена / TCP
LISTEN / установлена) и информация (нет данных / линия разорвана), TS 102 223 8.56
- **Network Rejection** — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля локации (MCC, MNC, LAC, RAC, TAC), доступные технологии, 53-позиционный выпадающий список причин отказа (EMM, GMM, 5GMM, LU)
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор.
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор. Поддерживаются SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, PROVIDE LOCAL INFORMATION, TIMER MANAGEMENT и BIP-команды (OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA, GET CHANNEL STATUS); BIP-команды декодируются с обычными и comprehension-required TLV-тегами.
**Управление таймерами** — сервер выполняет роль терминала для TIMER MANAGEMENT (TS 102 223 §6.6.21/§7.4): запущенные картой таймеры отслеживаются в рамках сессии, TERMINAL RESPONSE на deactivate/get содержит остаток, а по истечении карта получает ENVELOPE (TIMER EXPIRATION). Живая карта использует это для повторения OTA-сессии после неудачного OPEN CHANNEL.
**Конфигурация TR: словарь PLI** — редактируемые hex-значения для всех 22 квалификаторов PROVIDE LOCAL INFORMATION (TS 102 223 + TS 131 111). 10 квалификаторов имеют встроенные формы декодирования/кодирования:
@@ -550,6 +554,16 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
Значения сохраняются на сервере до перезапуска. Apply → hex обновляется; Save → POST на сервер.
## SCP81
Вкладка **SCP81** управляет HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B). BIP-канал карты всегда перенаправляется на локальный слушатель сервера:
- **Capture (dump)** — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая. Удобно для изучения запросов карты.
- **PSK TLS server** — отвечает на рукопожатие PSK-наборами TLS 1.2 из спецификации и ведёт HTTP-диалог административной сессии GP (заголовки `X-Admin-*`, `200` со строкой команд или `204 No Content`). Укажите **PSK Identity**, которую использует карта, и **PSK ключ (hex)**; ключ передаётся только локальному серверу, не сохраняется и не записывается в журнал.
- **Script** — сценарий команд, отдаваемых в сессии: **Память + ELF** (по умолчанию) отправляет `GET DATA FF21` (доступная энергонезависимая/энергозависимая память, число апплетов) и `GET STATUS P1=20/10` (реестр Executable Load File и модулей) как RAM/GP-команды в Command Scripting template по TS 102 226, по одной C-APDU на запрос; **None** закрывает каждую сессию ответом `204`. Свой список APDU можно задать через API.
Строка состояния показывает слушатель, согласованную identity и активные каналы (байты in/out); журнал фиксирует OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA и каждый шаг TLS/HTTP. Те же функции доступны через `POST /api/scp81/bip` (см. `docs/api.md`).
## PWA
OTAMan — Progressive Web App. Можно установить для offline-использования через кнопку **INSTALL PWA** или через браузер.
+95
View File
@@ -46,6 +46,10 @@ connect and warns if versions are incompatible.
| `/api/pli-qualifiers` | GET | List of qualifier codes with descriptions |
| `/api/pli-dict` | GET | Current dictionary (hex values per qualifier) |
| `/api/pli-dict` | POST | Update dictionary entries |
| `/api/scp81/bip` | POST | Start/stop the HTTP OTA listener (dump capture or PSK TLS server) |
| `/api/scp81/status` | GET | BIP terminal + listener state (channels, PSK identity seen) |
| `/api/scp81/log` | GET | HTTP OTA event log (`?after=<seq>`) |
| `/api/scp81/log-clear` | POST | Clear the HTTP OTA event log |
## Endpoint details
@@ -378,6 +382,13 @@ optional hex for events that carry data. Returns the SW and any response data:
{"sw": "9000", "data": "..."}
```
Channel status (event `0x0A`, TS 102 223 §8.56) carries the Channel status TLV
`B8 02 <status> <info>`, where the status byte is the channel id (17) OR-ed
with the state bits (0x00 link not established / 0x40 TCP LISTEN / 0x80 link
established) and the info byte is `00` (no further info) or `05` (link
dropped). The server also sends this event automatically when a BIP link drops
outside a proactive command and the card subscribed to `0x0A`.
### `GET /api/proactive-log`
Returns the last 50 proactive commands fetched during CAT sessions, newest
@@ -444,3 +455,87 @@ Returns the current PLI data dictionary as a qualifier-code map.
Updates dictionary entries. Body is a map of qualifier code to hex value; keys
must be known qualifiers and values valid hex, otherwise they are ignored.
Returns the updated dictionary.
### `POST /api/scp81/bip`
Starts or stops the local target the card's BIP channel is redirected to.
Dump mode (default) captures whatever the card sends (e.g. its TLS
ClientHello) without answering:
```json
{"action": "start", "mode": "dump", "host": "127.0.0.1", "port": 8443}
```
TLS mode runs the Phase B PSK TLS server (GPC v2.2 Amendment B): the PSK key
and optional identity are applied to the TLS handshake, and the GP HTTP
administration dialog (`X-Admin-*` headers, 200 with a command string or 204
No Content) is served. `psk_hex` is required (the previous key is reused when
omitted); `psk_identity` restricts the accepted identity. The key is never
stored or logged.
```json
{"action": "start", "mode": "tls", "host": "127.0.0.1", "port": 8443,
"psk_hex": "00112233445566778899aabbccddeeff",
"psk_identity": "89012345678901234567"}
```
Stop either mode with `{"action": "stop"}` (also disables the BIP terminal).
### `GET /api/scp81/status`
```json
{"bip": {"enabled": true, "target": "127.0.0.1:8443", "channels": [], "seq": 12},
"listener": {"mode": "tls", "host": "127.0.0.1", "port": 8443,
"psk_identity": null, "identity_seen": "89012345678901234567"}}
```
### `GET /api/scp81/log`
Returns the BIP/TLS event log (open/close, SEND/RECEIVE DATA hex, TLS
handshake and HTTP request/response records). `?after=<seq>` returns only
newer entries; `seq` echoes the latest sequence number.
### `GET /api/scp81/script`
Returns the active command script and the R-APDUs collected so far:
```json
{"script": ["80CAFF2100", "80F28002024F0000"], "sent": 1,
"results": [{"index": 1, "sw": "9000", "rapdu": "FF210C810102..."}]}
```
The script is selected when starting the TLS listener with the `script`
parameter: `explore` (default — the reference administration server's command
sequence: GET DATA FF21 extended resources / free memory, GET STATUS P1=80
Issuer Security Domain, GET DATA 0085 HTTP administration parameters, GET
STATUS P1=40 executable load files and P1=10 applications), `none` (answer
every POST with 204), or an explicit list of APDU hex strings. Each APDU is
delivered in an `AE 80 22 <len> <apdu> 00 00` Command Scripting template
(TS 102 226 §5.2.1) with `X-Admin-Next-URI`; the card returns its R-APDUs in
the next POST's Response Scripting template, which is parsed and logged
(`script-rapdu`, `script-memory`).
TLS mode also accepts `chunked` (**default `true`** — the reference server's
chunked framing; the card rejects a chunked response that also carries a
Content-Length) and `chunk_size` (default `0` — the whole response in one TLS
record, as in the decrypted reference session; a positive value writes the
head and each body piece as its own record). Both are echoed by
`GET /api/scp81/status`.
`keep_alive` (default `true`, matching the reference session: the card sends
all its POSTs on one connection until the 204) ends the TLS connection after
each response
(after the card drained the BIP buffer, with `close_notify`, so the card
processes the script and opens a new connection for its next POST);
`compact_headers` (default `false`) drops the space after each header colon,
`apache_headers` (default `true`) adds Date/Server/X-Powered-By like the
reference servers and puts Transfer-Encoding before Content-Type,
`conn_header` (default `'none'` = omit the header, like the reference)
declares the connection fate, `tls_version` pins `1.1`/`1.0` for cards that
only speak the older record layer, `cipher` pins one suite, `next_uri`
overrides the per-command `X-Admin-Next-URI` (`%d` = command id; empty string
omits the header), `link_events` (default `true`) controls the automatic
Channel status events, `answer_delay` waits before answering a request. `keylog` writes the TLS traffic secrets to
the given file (SSLKEYLOGFILE format) for debugging captures — it contains key
material, use a temporary path.
+200
View File
@@ -0,0 +1,200 @@
# SCP81 / HTTP OTA live-card findings
Living debug log for the HTTP OTA (RAM over HTTP) work against the live UICC.
Purpose: record **every attempted configuration and its outcome**, so the same
variations are not repeated. Add rows as tests are run; keep the confirmed
rules section current.
Setup: `pysim_otaman_server` with a PC/SC reader, the PWA SCP81 tab (or
`POST /api/scp81/bip`), the card triggered by its SMS-PP push / the Location
status event. Server log at `GET /api/scp81/log`, script state at
`GET /api/scp81/script`, proactive history at `GET /api/proactive-log`.
## RESOLVED 2026-09-16: the card never received the response - BIP TLV bug
**Root cause:** our RECEIVE DATA TERMINAL RESPONSE encoded the channel-data
TLV length as a raw byte (`36 ED ...` for a 237-byte chunk). BER requires the
long form for lengths >127: **`36 81 ED ...`** (the reference terminal traces
use exactly that, e.g. `push_3311_success_req2.pcapng`). The card's BIP layer
silently mis-parsed the malformed TLV, so the TLS record bytes never reached
its TLS stack: no alert, no script processing, and the SD kept resuming its
dialog ("no complete script received") forever. Every delivery <=127 bytes
(handshake records, 204 responses) always worked - which is why the handshake
succeeded and only the large script responses "vanished".
**Fix:** `_handle_bip_command` (cmd 0x42) BER-encodes the channel data length
(`36 81 <len>` above 127); regression test
`test_receive_data_tlv_long_form_length`.
**Result with the live card** (one push, `explore` script, 5/5 commands):
```
#1 80CAFF2100 SW 9000 FF210B 81010D 8202C5D6 83020962 (13 applets,
free NV 50646 B, free volatile 2402 B)
#2 80F28002024F0000 SW 9000 ISD A000000003000000 + D276000005AAFFCAFE00
#3 80CA008500 SW 9000 stored HTTP OTA parameters
#4 80F24002024F0000 SW CAFE 127-byte ELF registry page (more available)
#5 80F21002024F0000 SW CAFE 127-byte applications page (more available)
```
Every command returned `X-Admin-Script-Status: ok` on the card's own POST to
the incremented `X-Admin-Next-URI`, on the same keep-alive connection, and the
session ended with 204 + mutual close_notify - exactly the reference flow.
`SW CAFE` marks a truncated 127-byte page: the remaining entries need a
continuation GET STATUS (P2=02 with the last AID as search criterion).
## Live card facts (verified via the reader, 2026-09-16)
- `80CAFF2100` (GET DATA extended card resources) **works**:
`FF21 0B 81 01 0D 82 02 C5 D6 83 02 09 62` -> 13 applets installed,
free NV memory `0xC5D6` = 50646 B, free volatile `0x0962` = 2402 B.
- `80CA008500` (GET DATA HTTP administration parameters) **works** and returns
the SD's stored OTA configuration: `8A 09 "localhost"`, `8B 14 <agent id>`,
`8C 01 "/"` (stored URI), `85 14 <PSK identity>`, `86 07 00 01 25 03 00 10 00`
(retry counter 1, timer **10 minutes**), `02 40 01` (KVN/KID), APN-ish
`C7 04 03 47 50 42`, destination `BE 05 21 5B D5 05 02` = 91.213.5.2.
- `80F28002/80F24002/80F21002 ...4F0000` return `6985` through the reader when
the ISD is not the current DF; the reference platform sends
`80F28002024F0000` over HTTP, where the SD executes inside the ISD.
- `SELECT` of the ISD (`00A4040008A000000003000000`) returns `6112`;
a subsequent GET RESPONSE (`00C0000012`) returns `6D00`.
- BIP device identities: OPEN CHANNEL uses destination `0x82`; SEND/RECEIVE
DATA carry channel `0x21..0x27` (e.g. `82 02 81 22` = channel 2).
- Subscribed events (`99 03`): `03` location status, `09` data available,
`0A` channel status.
- A Location status event re-triggers the OTA session only while the last
session is incomplete; after a clean session end the card waits for a push.
- The SD stores a 10-minute retry timer (`25 03 00 10 00`).
## Confirmed rules (with evidence)
1. **The card needs a clean TLS close, with the close_notify actually
fetched.** Keep-alive (no close) -> fatal `unexpected_message` after it
fetched the response. `close_notify` sent *after* the buffer drained is
never fetched (the card ends the dialog on its own first). Correct order:
send it while the response still waits, then wait for the drain, then
close.
2. **The card's abort alert is `fatal unexpected_message`** - decrypted with
the listener's `keylog` option (see `tools/scp81_decrypt.py`).
3. **A dropped link must be signalled (TS 102 223 7.5.11), and only after the
buffered data was fetched.** Signalling the drop while bytes are still in
the BIP buffer makes the card abort the fetch mid-record and end the
session. Omitting the signal entirely hangs the SD: after a listener
restart dropped the channel silently, the card ignored pushes and location
events for minutes; a manual `ENVELOPE (Channel status, B8 02 02 05)`
immediately made it start a fresh session.
4. **The Next-URI shape matters.** A path-only or absolute Next-URI (`/`,
`/1`, `http://127.0.0.1:8443/api/scp81`) draws the fatal
`unexpected_message`; the reference-style relative path **with a query**
(`/adminserver?PHPSESSID=...&apdu_id=101`) does not.
5. **The reference administration server** (`samples/HTTP_OTA/
httpota_adminserver_php_v2`) uses: command script
`AE 80 22 <len> <apdu> 00 00`; response `200` with
`X-Admin-Protocol`, `X-Admin-Next-URI: /adminserver?PHPSESSID=<id>&apdu_id=<n>`,
`Content-Type: ...;version=1.0`, **chunked** body (100-byte chunks);
the card returns the R-APDU as the body of its next POST with
`X-Admin-Script-Status: ok`; the server ends with `204`.
Its log proves the card followed the Next-URI three times within 1-2 s per
step (`Got next request ... Script status is 'ok' - storing R-APDU data`).
6. **`chunked=false` (Content-Length) has never produced an R-APDU.** All
sessions that ended silently (clean close, no alert, no POST) used
`Content-Length`. Hypothesis: the card only treats a chunked body as a
command script; with Content-Length it sees an empty script, executes
nothing and ends the session gracefully.
## The one fully successful session trace (ground truth)
`traces/HTTPOTA_session_3311_success1.pcap` (2019, **plain HTTP on port 80**,
one TCP connection for the whole session, card `3311` - *not* our UICC):
```
POST /server/adminagent?cmd=1 <- card (trigger URI, with query!)
200 OK + Date/Server + X-Admin-Protocol
+ X-Admin-Next-URI: /Download?req=1 + Content-Length: 11
+ Content-Type: .../card-content-mgt;version=1.0
body: ae 80 22 05 80 ca 00 85 00 00 00 (script: GET DATA 0085)
POST /Download?req=1 <- card, SAME connection
X-Admin-Script-Status: ok
Content-Type: .../card-content-mgt-response;version=1.0
Transfer-Encoding: chunked
body: "8
" af 80 23 02 6a 88 00 00 "0
" (R-APDU SW 6A88)
200 OK + X-Admin-Next-URI: /Download?req=2 + Content-Length: 14
body: ae 80 22 08 80 f2 80 02 02 4f 00 00 00 00 (GET STATUS P1=80)
POST /Download?req=2 -> X-Admin-Script-Status: ok, chunked
body: "1F
" af 80 23 19 <25-byte R-APDU ... 90 00> 00 00 "0
"
200 OK + /Download?req=3 + 11-byte script
POST /Download?req=3 -> status ok, R-APDU 23 02 6d 00 (SW 6D00)
204 No Content <- session ends
```
Confirmed from it: the card echoes the `X-Admin-Next-URI` (path *and* query)
verbatim; its response POST goes on the **same TCP connection**; its response
is the `AF 80 23 <len> <R-APDU> 00 00` indefinite Response Scripting template
(in a chunked body, with `X-Admin-Script-Status`); the server's script
`AE 80 22 <len> <APDU> 00 00` matches ours byte for byte; the server uses
`Content-Length` (not chunked), no `Connection` header (implicit keep-alive),
and ends with 204.
## Attempt matrix
| # | transport | framing | Next-URI | close | link events | outcome |
|---|-----------|---------|----------|-------|-------------|---------|
| 1 | dump mode only | - | - | - | off | OPEN CHANNEL + ClientHello captured (Phase A) |
| 2 | TLS, 204 only | - | - | yes | off | session completes cleanly, no alert (Phase B, live) |
| 3 | TLS + script | chunked 100 | `/N` | early (raced fetch) | on | fetch truncated (237/399); card re-opened and repeated its POST with `X-Admin-Resume: true` -> breakdown-resume works |
| 4 | TLS + script | chunked 100 / single | `/1`, `/`, absolute | keep-alive | off | full fetch, then fatal `unexpected_message` (Next-URI shape) |
| 5 | TLS + script | single | none (`""`) | keep-alive | off | no alert, no POST, session left open (spec: no Next-URI -> no response) |
| 6 | TLS + script | chunked 100 | reference | close_notify after drain | off | full fetch, alert (notify never fetched) |
| 7 | TLS + script | chunked 100 | reference | close_notify before drain | off | full fetch, alert (head split into its own record) |
| 8 | TLS + script | **single record** | reference | drain + close_notify | off | **no alert**, card CLOSE CHANNELs, no R-APDU (`chunked=false` -> suspected empty script) |
| 9 | TLS + script | single record | reference | keep-alive (no close) | off | fatal `unexpected_message` (close required) |
| 10 | TLS + script | chunked 100 | reference | drain + close_notify | off | full fetch, then alert; later the SD hung until a manual link-dropped event |
| 11 | TLS + script | single record | reference | keep-alive | off | fatal `unexpected_message` after the full fetch (no close) |
| 12 | TLS + script | single record | reference | drain + close_notify | off | **no alert**, card CLOSE CHANNELs, no R-APDU (`Content-Length`) |
| 13 | TLS + script | chunked100 + single | reference | drain + close_notify | off | no alert, no R-APDU |
| 14 | TLS + script | single record | reference | keep-alive | off | alert again |
| 15 | TLS + script | chunked 100 | reference | keep-alive | on | alert (small records, ruled out record size) |
| 16 | TLS + script | single record | reference | keep-alive, no `Connection` header | on | alert |
| 17 | TLS + script (RFM! `00D6` write-probe) | chunked, single | reference | drain + close_notify | on | no alert, no R-APDU; EF.SPN unchanged - **RFM result is void**: the ISD only accepts RAM commands |
All script attempts used the `explore` list, except #8-#17 which used only
`80CAFF2100` (or the RFM probe). #3-#17 ran with the card's PSK identity
`89390…903` (push trigger) or `89701…` (event trigger).
**Status after #17 (superseded by the 2026-09-16 resolution above):** the
failures were caused by the BIP TLV length bug, not by the HTTP/TLS details;
resume mode was a symptom (the working session even started as a resume). The
key working recipe (also now the server default): one keep-alive connection,
Apache-style headers, `Transfer-Encoding: chunked` body with the script in
one TLS record, no Connection header, `X-Admin-Next-URI` with a query whose
command id increments.
**Also confirmed:** a TLS half-close (close_notify then keep reading for the
card's POST which RFC 5246 leaves open in practice) cannot be done with
CPython's `ssl`: `SSLSocket.unwrap()` with a short timeout raises and poisons
the session (tested), so the `half_close` option is a documented no-op.
## Next tests / work
1. **Continuation pages:** follow `SW CAFE` (127-byte listing pages) with
GET STATUS P1=40/10 P2=02 using the last returned AID as the search
criterion, and append the pages to the result set (memory + full ELF and
application registries).
2. **UI:** show the decoded memory/applications results (and page merging) in
the SCP81 tab; expose the framing options there.
3. Load/store operations (RAM INSTALL/LOAD) over SCP81 using the same recipe.
## Tooling
- `tools/scp81_decrypt.py <log.json> <keys.log>` - decrypts the dialog from
`GET /api/scp81/log` plus the listener's `keylog` file (SSLKEYLOGFILE
format; PSK-AES128-CBC-SHA256, TLS 1.2 PRF + OpenSSL CLI). Shows each
record's plaintext and any alert level/description.
- Start the listener with `"keylog": "/tmp/.../scp81.keys"` to collect the
secrets (contains key material - use a temp path, never commit).
+269 -9
View File
@@ -18,7 +18,7 @@
<div class="max-w-7xl mx-auto px-6 py-2">
<div class="flex items-center justify-between mb-3">
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.1.2</span></h1>
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.1.5</span></h1>
<div class="flex items-center gap-4">
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span>
@@ -35,6 +35,7 @@
<div class="flex flex-wrap gap-1 mb-4 border-b border-gray-300 dark:border-slate-600">
<button class="tab-btn active px-4 py-1.5 text-sm rounded-t bg-blue-600 dark:bg-blue-500 text-white dark:text-white" data-tab="c-apdu">Remote APDU</button>
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="scp80">SCP80</button>
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="scp81" data-l10n="SCP81">SCP81</button>
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="profiler" data-l10n="Profiler">Profiler</button>
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="pysim" data-l10n="Card reader">Card reader</button>
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="phone" data-l10n="Phone simulator">Phone simulator</button>
@@ -954,6 +955,60 @@
</div>
</div>
<div id="tab-scp81" class="tab-content hidden">
<div class="flex items-center gap-2 mb-2">
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="HTTP OTA listener">HTTP OTA listener</span>
<span id="scp81-state" class="text-xs text-gray-400"></span>
</div>
<div class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Mode">Mode</label>
<select id="scp81-mode" onchange="scp81ModeChanged()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
<option value="dump" data-l10n="Capture (dump)">Capture (dump)</option>
<option value="tls" data-l10n="PSK TLS server">PSK TLS server</option>
</select>
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Host">Host</label>
<input id="scp81-host" value="127.0.0.1" class="w-36 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Port">Port</label>
<input id="scp81-port" value="8443" class="w-24 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Script">Script</label>
<select id="scp81-script" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
<option value="explore" data-l10n="Memory + ELF info">Memory + ELF info</option>
<option value="none" data-l10n="None">None</option>
</select>
</div>
<div id="scp81-psk-row" class="flex items-end gap-3">
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="PSK Identity (text)">PSK Identity (text)</label>
<input id="scp81-psk-id" placeholder="89012345678901234567" class="w-56 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="PSK key (hex)">PSK key (hex)</label>
<input id="scp81-psk-hex" type="password" placeholder="00112233445566778899aabbccddeeff" class="w-72 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
</div>
</div>
<button id="scp81-start-btn" data-needs="server" onclick="scp81Start()" class="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Start">Start</button>
<button id="scp81-stop-btn" data-needs="server" onclick="scp81Stop()" class="px-3 py-1.5 text-sm rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Stop">Stop</button>
</div>
<div id="scp81-msg" class="text-xs mt-2 hidden"></div>
<div class="mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="The key is only sent to the local server and is never stored or logged.">The key is only sent to the local server and is never stored or logged.</div>
</div>
<div class="border border-gray-200 dark:border-slate-700 rounded p-3">
<div class="flex justify-between items-center mb-2">
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="HTTP OTA log">HTTP OTA log</span>
<button data-needs="server" onclick="scp81LogClear()" class="px-2.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Clear">Clear</button>
</div>
<div id="scp81-log" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[55vh] overflow-auto"></div>
</div>
</div>
<div id="event-send-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
<h3 id="event-send-title" class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200"></h3>
@@ -1042,6 +1097,7 @@
<script>
// ===== Tab switching =====
function switchTab(name) {
if (_scp81Timer && name !== 'scp81') { clearInterval(_scp81Timer); _scp81Timer = null; }
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.getElementById('tab-' + name).classList.remove('hidden');
document.querySelectorAll('.tab-btn').forEach(btn => {
@@ -1056,6 +1112,7 @@ function switchTab(name) {
else if (name === 'pysim') setHelpAnchor(pysimHelpAnchor);
else if (name === 'profiler') { profilerSetView('list'); setHelpAnchor('profiler'); }
else if (name === 'phone') phoneSwitchSubtab('phone');
else if (name === 'scp81') scp81Enter();
}
let scp80HelpAnchor = 'secured-packet';
@@ -6437,7 +6494,25 @@ const EVENT_FORMS = {
},
0x08: { title: 'Browser termination', note: 'not_yet' },
0x09: null,
0x0A: { title: 'Channel status', note: 'not_yet' },
0x0A: { title: 'Channel status',
fields: [
{ id: 'channel', label: 'Channel', type: 'select',
opts: [{v:0,l:'0 \u2014 no channel available'},
{v:1,l:'Channel 1'},{v:2,l:'Channel 2'},{v:3,l:'Channel 3'},
{v:4,l:'Channel 4'},{v:5,l:'Channel 5'},{v:6,l:'Channel 6'},{v:7,l:'Channel 7'}] },
{ id: 'state', label: 'Link state', type: 'select',
opts: [{v:0x00,l:'Link not established / TCP closed'},
{v:0x40,l:'TCP in LISTEN state (UICC server mode)'},
{v:0x80,l:'Link established / TCP established'}] },
{ id: 'info', label: 'Channel info', type: 'select',
opts: [{v:0x00,l:'No further info can be given'},
{v:0x05,l:'Link dropped (network failure or user cancellation)'}] },
],
build(v) {
const st = (parseInt(v.state) || 0) | (parseInt(v.channel) || 0);
return 'B802' + st.toString(16).padStart(2,'0') + (parseInt(v.info) || 0).toString(16).padStart(2,'0');
}
},
0x0B: { title: 'Access Technology Change',
fields: [
{ id: 'tech', label: 'Access Technology', type: 'select',
@@ -6822,8 +6897,10 @@ const CMD_NAMES = {
'13': 'SEND SHORT MESSAGE', '20': 'PLAY TONE',
'21': 'DISPLAY TEXT', '22': 'GET INKEY', '23': 'GET INPUT',
'24': 'SELECT ITEM', '25': 'SET UP MENU',
'26': 'PROVIDE LOCAL INFORMATION',
'26': 'PROVIDE LOCAL INFORMATION', '27': 'TIMER MANAGEMENT',
'15': 'LAUNCH BROWSER', '70': 'ACTIVATE',
'40': 'OPEN CHANNEL', '41': 'CLOSE CHANNEL',
'42': 'RECEIVE DATA', '43': 'SEND DATA', '44': 'GET CHANNEL STATUS',
};
const CMD_QUALIFIER_SHORT = {
@@ -6842,8 +6919,26 @@ const CMD_QUALIFIER_SHORT = {
0x0D:'BCInfo', 0x0E:'MultiAT', 0x0F:'MultiLoc', 0x10:'MultiNMR',
0x11:'CSG', 0x12:'HNB-IP', 0x13:'HNB-Macro', 0x14:'WLAN', 0x15:'Slices',
0x16:'CAG', 0x17:'RejSlice'},
'27': {0x00:'Start', 0x01:'Deactivate', 0x02:'Get'},
};
function cmdQualifierShort(typeHex, q) {
// OPEN CHANNEL qualifier is a bit field (TS 102 223 8.6):
// b1 immediate link establishment, b2 automatic reconnection,
// b3 background mode (b1 ignored), b4 DNS server address(es) requested.
if (typeHex === '40') {
const p = [];
if (q & 0x04) p.push('Background');
else if (q & 0x01) p.push('Immediate');
else p.push('OnDemand');
if (q & 0x02) p.push('AutoReconn');
if (q & 0x08) p.push('DNS');
return p.join('+');
}
const qs = CMD_QUALIFIER_SHORT[typeHex];
return qs ? (qs[q] || '') : '';
}
let _pysimLogExpanded = new Set();
function pysimLogToggle(id) {
@@ -6882,14 +6977,12 @@ async function pysimProactiveLogRender() {
}
let html = '';
log.forEach((e, i) => {
const name = CMD_NAMES[e.type_hex] || ('Cmd 0x' + e.type_hex.toUpperCase());
const name = CMD_NAMES[e.type_hex]
|| (e.type_name && e.type_name !== 'UNKNOWN' ? e.type_name : 'Cmd 0x' + e.type_hex.toUpperCase());
let qual = '';
if (e.qualifier) {
const qs = CMD_QUALIFIER_SHORT[e.type_hex];
if (qs) {
const qv = qs[parseInt(e.qualifier,16)];
if (qv) qual = ' [' + qv + ']';
}
const qv = cmdQualifierShort(e.type_hex, parseInt(e.qualifier, 16));
if (qv) qual = ' [' + qv + ']';
}
const expanded = _pysimLogExpanded.has(e.id);
html += '<div data-log-id="' + e.id + '" class="border-b border-gray-100 dark:border-slate-700/50">'
@@ -6926,6 +7019,148 @@ async function pysimProactiveLogRender() {
}
}
// ===== SCP81: HTTP OTA listener (dump capture / PSK TLS server) =====
let _scp81Timer = null;
function scp81ModeChanged() {
const mode = document.getElementById('scp81-mode').value;
document.getElementById('scp81-psk-row').style.display = (mode === 'tls') ? '' : 'none';
}
function scp81Msg(text, cls) {
const el = document.getElementById('scp81-msg');
el.textContent = text;
el.className = 'text-xs mt-2 ' + (cls || 'text-gray-500 dark:text-slate-400');
}
function scp81LogLine(e) {
const parts = [String(e.seq), e.kind];
if (e.index) parts.push('#' + e.index);
if (e.apdu) parts.push(e.apdu);
if (e.sw) parts.push('SW ' + e.sw);
if (e.applets !== undefined) parts.push('applets=' + e.applets);
if (e.free_nv !== undefined) parts.push('free NV=' + e.free_nv);
if (e.free_volatile !== undefined) parts.push('free vol=' + e.free_volatile);
if (e.channel) parts.push('ch' + e.channel);
if (e.requested) parts.push(e.requested);
if (e.target) parts.push('-> ' + e.target);
if (e.bytes) parts.push(e.bytes + 'B');
if (e.identity) parts.push('id=' + e.identity);
if (e.agent) parts.push('from=' + e.agent);
if (e.method) parts.push(e.method);
if (e.uri) parts.push(e.uri);
if (e.status) parts.push('HTTP ' + e.status);
if (e.cipher) parts.push(e.cipher);
if (e.reason) parts.push('(' + e.reason + ')');
if (e.error) parts.push(e.error);
if (e.hex) parts.push(e.hex.slice(0, 96));
return parts.join(' ');
}
async function scp81StatusRefresh() {
const el = document.getElementById('scp81-state');
try {
const st = await pysimFetch('/api/scp81/status');
const l = st.listener;
const bip = st.bip || {};
if (!l) {
el.textContent = t('not running');
return st;
}
let s = l.mode + ' ' + l.host + ':' + l.port;
if (l.mode === 'tls') {
s += ' | PSK ' + t('configured');
if (l.identity_seen) s += ' | id: ' + l.identity_seen;
else if (l.psk_identity) s += ' | id: ' + l.psk_identity;
}
const ch = (bip.channels || []).map(c => 'ch' + c.id + ' in:' + c.bytes_in + ' out:' + c.bytes_out).join(', ');
if (ch) s += ' | ' + ch;
el.textContent = s;
return st;
} catch (err) {
el.textContent = String(err.message || err);
return null;
}
}
async function scp81LogRefresh() {
const el = document.getElementById('scp81-log');
try {
const log = await pysimFetch('/api/scp81/log');
const entries = (log.entries || []).slice(-200);
if (!entries.length) {
el.innerHTML = '<span class="text-gray-400">' + t('No entries yet.') + '</span>';
return;
}
el.innerHTML = entries.map(e =>
'<div class="py-0.5 border-b border-gray-100 dark:border-slate-700/50 break-all">' + esc(scp81LogLine(e)) + '</div>'
).join('');
} catch (err) {
el.innerHTML = '<span class="text-red-500">Error: ' + esc(String(err.message || err)) + '</span>';
}
}
async function scp81Start() {
const mode = document.getElementById('scp81-mode').value;
const body = {
action: 'start', mode: mode,
host: document.getElementById('scp81-host').value.trim() || '127.0.0.1',
port: parseInt(document.getElementById('scp81-port').value.trim() || '8443', 10),
};
if (mode === 'tls') {
const key = document.getElementById('scp81-psk-hex').value.replace(/\s+/g, '');
if (!key) {
scp81Msg(t('PSK key (hex)') + ': ' + t('required'), 'text-red-500');
return;
}
body.psk_hex = key;
// Always send the identity: empty means "accept any identity".
body.psk_identity = document.getElementById('scp81-psk-id').value.trim();
body.script = document.getElementById('scp81-script').value;
}
try {
const resp = await pysimFetch('/api/scp81/bip', body);
if (resp.ok) scp81Msg(t('Listening.'), 'text-emerald-600 dark:text-emerald-400');
else scp81Msg(resp.error || t('Error'), 'text-red-500');
scp81StatusRefresh();
scp81LogRefresh();
} catch (err) {
scp81Msg(String(err.message || err), 'text-red-500');
}
}
async function scp81Stop() {
try {
await pysimFetch('/api/scp81/bip', { action: 'stop' });
scp81Msg(t('Stopped.'), 'text-gray-500 dark:text-slate-400');
scp81StatusRefresh();
scp81LogRefresh();
} catch (err) {
scp81Msg(String(err.message || err), 'text-red-500');
}
}
async function scp81LogClear() {
try {
await pysimFetch('/api/scp81/log-clear', {});
scp81LogRefresh();
} catch (err) {
scp81Msg(String(err.message || err), 'text-red-500');
}
}
function scp81Enter() {
scp81ModeChanged();
scp81StatusRefresh();
scp81LogRefresh();
if (_scp81Timer) clearInterval(_scp81Timer);
_scp81Timer = setInterval(() => {
if (document.getElementById('tab-scp81').classList.contains('hidden')) return;
scp81StatusRefresh();
scp81LogRefresh();
}, 2000);
}
// ===== PLI data dictionary =====
function decPlmn(hex3) {
const h = hex3.replace(/\s/g, '').slice(0, 6);
@@ -9426,6 +9661,23 @@ const LANG_RU = {
'Verify vs pySim': 'Проверить в pySim',
'Send to Card': 'Отправить на карту',
'Phone simulator': 'Симулятор телефона',
'HTTP OTA listener': 'HTTP OTA слушатель',
'Mode': 'Режим',
'Capture (dump)': 'Захват (dump)',
'PSK TLS server': 'PSK TLS сервер',
'Script': 'Сценарий',
'Memory + ELF info': 'Память + ELF',
'Host': 'Хост',
'Port': 'Порт',
'PSK key (hex)': 'PSK ключ (hex)',
'HTTP OTA log': 'Журнал HTTP OTA',
'The key is only sent to the local server and is never stored or logged.': 'Ключ передаётся только локальному серверу, не сохраняется и не записывается в журнал.',
'Listening.': 'Слушает.',
'Stopped.': 'Остановлено.',
'not running': 'не запущен',
'configured': 'настроен',
'required': 'обязательно',
'No entries yet.': 'Записей пока нет.',
'Sort:': 'Сортировка:',
'Name': 'Имя',
'Probe all files': 'Проверить все файлы',
@@ -9477,6 +9729,14 @@ const LANG_RU = {
'Status': 'Статус',
'Card reader removed': 'Картридер извлечён',
'Card reader inserted': 'Картридер вставлен',
'Channel': 'Канал',
'Link state': 'Состояние линии',
'Channel info': 'Информация канала',
'Link not established / TCP closed': 'Линия не установлена / TCP закрыт',
'TCP in LISTEN state (UICC server mode)': 'TCP в состоянии LISTEN (режим сервера UICC)',
'Link established / TCP established': 'Линия установлена / TCP установлен',
'No further info can be given': 'Дополнительная информация отсутствует',
'Link dropped (network failure or user cancellation)': 'Линия разорвана (сбой сети или отмена пользователем)',
'PROVIDE LOCAL INFORMATION response data': 'Данные для PROVIDE LOCAL INFORMATION',
'Send STATUS': 'Отправить STATUS',
'help': 'справка',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v133';
const CACHE = 'otaman-v141';
const URLS = [
'index.html',
'help.html',
+70
View File
@@ -0,0 +1,70 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractBlock(startMarker, endMarker) {
const start = html.indexOf(startMarker);
const end = html.indexOf(endMarker, start);
if (start < 0 || end < 0) throw new Error('block not found');
return html.slice(start, end);
}
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
// Rewrite top-level const -> var so the maps leak out of sloppy-mode eval.
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
eval(extractFunc(html, 'cmdQualifierShort'));
eval(extractBlock('const REJECTION_CAUSES = [', 'const EVENT_FORMS = {').replace(/^const /gm, 'var '));
eval(extractBlock('const EVENT_FORMS = {', 'const PLI_QUALIFIERS = [').replace(/^const /gm, 'var '));
test('CMD_NAMES decodes timer management and the BIP commands', () => {
assert.strictEqual(CMD_NAMES['27'], 'TIMER MANAGEMENT');
assert.strictEqual(CMD_NAMES['40'], 'OPEN CHANNEL');
assert.strictEqual(CMD_NAMES['41'], 'CLOSE CHANNEL');
assert.strictEqual(CMD_NAMES['42'], 'RECEIVE DATA');
assert.strictEqual(CMD_NAMES['43'], 'SEND DATA');
assert.strictEqual(CMD_NAMES['44'], 'GET CHANNEL STATUS');
});
test('cmdQualifierShort decodes TIMER MANAGEMENT actions', () => {
assert.strictEqual(cmdQualifierShort('27', 0x00), 'Start');
assert.strictEqual(cmdQualifierShort('27', 0x01), 'Deactivate');
assert.strictEqual(cmdQualifierShort('27', 0x02), 'Get');
});
test('cmdQualifierShort decodes OPEN CHANNEL qualifier flags', () => {
assert.strictEqual(cmdQualifierShort('40', 0x00), 'OnDemand');
assert.strictEqual(cmdQualifierShort('40', 0x01), 'Immediate');
assert.strictEqual(cmdQualifierShort('40', 0x03), 'Immediate+AutoReconn');
assert.strictEqual(cmdQualifierShort('40', 0x05), 'Background');
assert.strictEqual(cmdQualifierShort('40', 0x0C), 'Background+DNS');
});
test('cmdQualifierShort returns empty for unknown types', () => {
assert.strictEqual(cmdQualifierShort('99', 0x01), '');
});
test('channel status event builds the B8 channel status TLV', () => {
const build = EVENT_FORMS[0x0A].build;
assert.strictEqual(EVENT_FORMS[0x0A].note, undefined);
assert.strictEqual(build({ channel: '2', state: '128', info: '5' }), 'B8028205');
assert.strictEqual(build({ channel: '1', state: '0', info: '0' }), 'B8020100');
assert.strictEqual(build({ channel: '0', state: '64', info: '0' }), 'B8024000');
});
+1 -1
View File
@@ -13,7 +13,7 @@ test('HTML <div> tags are balanced', () => {
test('top-level tabs match the rearranged views', () => {
const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]);
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'profiler', 'pysim', 'phone']);
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'scp81', 'profiler', 'pysim', 'phone']);
assert.match(html, /data-tab="c-apdu">Remote APDU</);
});
+59
View File
@@ -0,0 +1,59 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
eval(extractFunc(html, 'scp81LogLine'));
test('scp81LogLine renders a BIP open entry', () => {
assert.strictEqual(
scp81LogLine({ seq: 4, kind: 'open', channel: 1, requested: '77.221.153.19:10174', target: '127.0.0.1:8443' }),
'4 open ch1 77.221.153.19:10174 -> 127.0.0.1:8443');
});
test('scp81LogLine renders a TLS request with the GP headers', () => {
assert.strictEqual(
scp81LogLine({ seq: 5, kind: 'tls-request', method: 'POST', uri: '/server/adminagent?cmd=1', agent: '0123456789', bytes: 0 }),
'5 tls-request from=0123456789 POST /server/adminagent?cmd=1');
});
test('scp81LogLine renders handshake and errors', () => {
assert.strictEqual(
scp81LogLine({ seq: 6, kind: 'tls-handshake', cipher: 'PSK-AES128-CBC-SHA256', identity: 'id-1' }),
'6 tls-handshake id=id-1 PSK-AES128-CBC-SHA256');
assert.strictEqual(scp81LogLine({ seq: 7, kind: 'tls-error', error: 'boom' }), '7 tls-error boom');
});
test('scp81 log covers the dump mode kinds', () => {
assert.strictEqual(scp81LogLine({ seq: 1, kind: 'dump-rx', bytes: 71 }), '1 dump-rx 71B');
});
test('scp81LogLine renders script entries', () => {
assert.strictEqual(
scp81LogLine({ seq: 9, kind: 'script-send', index: 1, apdu: '80CAFF2100' }),
'9 script-send #1 80CAFF2100');
assert.strictEqual(
scp81LogLine({ seq: 12, kind: 'script-rapdu', index: 1, sw: '9000', bytes: 14 }),
'12 script-rapdu #1 SW 9000 14B');
assert.strictEqual(
scp81LogLine({ seq: 13, kind: 'script-memory', applets: 4, free_nv: 61600, free_volatile: 2048 }),
'13 script-memory applets=4 free NV=61600 free vol=2048');
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
version = "2.1.2"
version = "2.1.5"
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.
+4 -2
View File
@@ -42,8 +42,10 @@ def main():
help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)')
parser.add_argument('--sms-sm-sc', default='12345678912', metavar='DIGITS',
help='SM-SC address for SMS-SUBMIT routing in PoR-in-submit mode (default: 12345678912)')
parser.add_argument('--terminal-profile', default='7FFFFFFFFF0000CF02', metavar='HEX',
help='TERMINAL PROFILE payload (default: 10-byte profile with SMS-PP download and event list)')
parser.add_argument('--terminal-profile',
default='FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003',
metavar='HEX',
help='TERMINAL PROFILE payload (default: the 33-byte profile of a real BIP-capable handset - the live card only starts HTTP OTA when BIP events/commands are advertised)')
parser.add_argument('--poll-interval', type=int, default=30, metavar='SECS',
help='Idle interval before automatic STATUS polling (1-255 seconds, default: 30). Disable with --poll-interval 0')
parser.add_argument('--no-card-init', action='store_true', default=False,
+95 -7
View File
@@ -92,6 +92,9 @@ class BipChannel:
self.bytes_out = 0
self.opened_at = time.time()
self.peer_closed = False
self.closed_reported = False
self.notified_len = 0
self.last_notify = 0.0
def pump(self, timeout=0.05):
"""Move whatever the network has into the local buffer. Returns bytes moved."""
@@ -157,6 +160,9 @@ class BipTerminal:
self.entries = []
self.seq = 0
self.lock = threading.Lock()
self.pending_events = []
self.on_data = None
self._monitor = None
def log(self, kind, **fields):
with self.lock:
@@ -168,25 +174,94 @@ class BipTerminal:
del self.entries[:len(self.entries) - MAX_LOG]
return entry
def _monitor_loop(self):
"""Watch channels for incoming bytes and ask the card to fetch them.
The card only learns about server data through the Data available
event (TS 102 223 7.5.10), so the socket must be pumped even while
the card is idle."""
while True:
time.sleep(0.25)
with self.lock:
channels = list(self.channels.values())
for ch in channels:
try:
ch.pump()
except OSError:
ch.peer_closed = True
if ch.peer_closed and not ch.closed_reported and not ch.rx:
# Report a dropped link (TS 102 223 7.5.11) only once the
# buffered server data has been fetched: signalling the
# drop while bytes are still waiting makes the card abort
# the fetch and end the session prematurely.
ch.closed_reported = True
self.log('peer-close', channel=ch.id)
self._queue_link_status(ch.id)
if (self.on_data and ch.rx and not ch.peer_closed
and (len(ch.rx) > ch.notified_len
or time.time() - ch.last_notify > 2.0)):
# Re-notify while data stays unfetched: the live card
# sometimes needs the Data available event again to drain
# a partially received TLS record.
if self.on_data(ch):
ch.notified_len = len(ch.rx)
ch.last_notify = time.time()
def _start_monitor(self):
if self._monitor is None or not self._monitor.is_alive():
self._monitor = threading.Thread(target=self._monitor_loop,
name='bip-monitor', daemon=True)
self._monitor.start()
def enable(self, host, port):
self.target = (host, int(port))
self.enabled = True
self.log('enabled', target='%s:%d' % self.target)
self._start_monitor()
def disable(self):
self.enabled = False
self.log('disabled')
self.close_all()
self.close_all(link_lost=True)
self.target = None
def close_all(self):
def close_all(self, link_lost=False):
for ch in list(self.channels.values()):
self._close_channel(ch)
self._close_channel(ch, link_lost=link_lost)
def _close_channel(self, ch):
def _close_channel(self, ch, link_lost=False):
ch.close()
if self.channels.get(ch.id) is ch:
del self.channels[ch.id]
if link_lost:
self._queue_link_status(ch.id)
def _queue_link_status(self, channel_id, status=None, info=0x05):
"""Record a BIP link change that did not result from a proactive
command (TS 102 223 7.5.11). The default is link not established +
info 05 = link dropped; a successful background-mode OPEN CHANNEL
reports link established instead. The server turns these into
ENVELOPE (Channel status)."""
with self.lock:
if any(e['channel'] == channel_id for e in self.pending_events):
return
self.pending_events.append({
'channel': channel_id,
'status': channel_id & 0x07 if status is None else status,
'info': info})
def take_pending_events(self):
with self.lock:
events, self.pending_events = self.pending_events, []
return events
def _check_peer(self, ch):
"""Notify once per channel when the peer closed the connection, after
any buffered data has been fetched (see _monitor_loop)."""
if ch.peer_closed and not ch.closed_reported and not ch.rx:
ch.closed_reported = True
self.log('peer-close', channel=ch.id)
self._queue_link_status(ch.id)
def _alloc_id(self):
for _ in range(7):
@@ -225,7 +300,7 @@ class BipTerminal:
ch.send(data)
except OSError as e:
self.log('send-fail', channel=channel_id, error=str(e))
self._close_channel(ch)
self._close_channel(ch, link_lost=True)
return False
self.log('send', channel=channel_id, bytes=len(data), hex=data.hex().upper()[:2000])
return True
@@ -238,15 +313,28 @@ class BipTerminal:
if data:
self.log('receive', channel=channel_id, bytes=len(data), remaining=len(ch.rx),
hex=data.hex().upper()[:2000])
# The TR announced the remainder via the channel-data-length TLV,
# but the live card still waits for a fresh Data available event
# before fetching it - re-arm the notification for what is left.
ch.notified_len = 0
self._check_peer(ch)
return data
def available(self, channel_id):
ch = self.channels.get(channel_id)
return ch.available() if ch else 0
if not ch:
return 0
n = ch.available()
self._check_peer(ch)
return n
def send_capacity(self, channel_id):
ch = self.channels.get(channel_id)
return ch.send_capacity() if ch else 0
if not ch:
return 0
n = ch.send_capacity()
self._check_peer(ch)
return n
def clear_log(self):
with self.lock:
+402
View File
@@ -0,0 +1,402 @@
"""Phase B: PSK TLS server and HTTP administration session for SCP81.
Implements the Remote Administration Server side of GP RAM over HTTP
(GPC v2.2 Amendment B):
- TLS 1.2 with the PSK cipher suites of clause 4.3.2. The handshake and
record layer are handled by the stdlib ``ssl`` module through OpenSSL's
PSK callbacks (identity -> PSK), so no TLS code lives here.
- The HTTP dialog of clause 4.4: parse the Security Domain's POST
(``X-Admin-*`` headers, optional body with the previous response string)
and answer with 200 + a command string, or 204 No Content to close the
administration session.
The card talks TLS *through* the BIP channel: this server listens on the
local redirect target and the BIP terminal proxies the card's SEND/RECEIVE
DATA records to it.
"""
import socket
import ssl
import threading
import time
MAX_HEAD = 32 * 1024
MAX_BODY = 1 * 1024 * 1024
# TLS_PSK_* suites from GPC v2.2 Amendment B Table 4-2 / RFC 4279/4785/5487.
PSK_CIPHERS = ':'.join([
'PSK-AES128-CBC-SHA256', # TLS_PSK_WITH_AES_128_CBC_SHA256 (0x00AE)
'PSK-AES128-CBC-SHA', # TLS_PSK_WITH_AES_128_CBC_SHA (0x008C)
'PSK-AES256-CBC-SHA', # TLS_PSK_WITH_AES_256_CBC_SHA (0x008D)
'PSK-3DES-EDE-CBC-SHA', # TLS_PSK_WITH_3DES_EDE_CBC_SHA (0x008B)
'PSK-NULL-SHA256', # TLS_PSK_WITH_NULL_SHA256 (0x00B0)
'PSK-NULL-SHA', # TLS_PSK_WITH_NULL_SHA (0x002C)
])
GP_PROTOCOL = 'globalplatform-remote-admin/1.0'
GP_CT_COMMAND = 'application/vnd.globalplatform.card-content-mgt;version=1.0'
GP_CT_RESPONSE = 'application/vnd.globalplatform.card-content-mgt-response;version=1.0'
# OpenSSL SSL_OP_NO_ENCRYPT_THEN_MAC (not exposed by the ssl module). The live
# card offers the encrypt_then_mac extension but aborts the session with
# SSLV3_ALERT_UNEXPECTED_MESSAGE as soon as the server echoes it, so keep the
# extension out of the ServerHello (verified live 2026-09-15).
OP_NO_ENCRYPT_THEN_MAC = 0x00080000
TLS_VERSIONS = {
'1.0': ssl.TLSVersion.TLSv1,
'1.1': ssl.TLSVersion.TLSv1_1,
'1.2': ssl.TLSVersion.TLSv1_2,
}
def parse_http_request(data):
"""Parse an HTTP/1.1 request head (bytes up to CRLFCRLF) into
(method, target, headers dict with lower-case names)."""
head = data.split(b'\r\n\r\n', 1)[0]
lines = head.split(b'\r\n')
parts = lines[0].split(b' ')
if len(parts) < 3:
raise ValueError('malformed request line')
method, target = parts[0].decode('latin-1'), parts[1].decode('latin-1')
headers = {}
for line in lines[1:]:
name, _, value = line.partition(b':')
headers[name.strip().decode('latin-1').lower()] = value.strip().decode('latin-1')
return method, target, headers
def decode_chunked(body):
"""Decode a chunked transfer body (RFC 2616 3.6.1)."""
out = bytearray()
while body:
line, _, rest = body.partition(b'\r\n')
try:
size = int(line.split(b';')[0], 16)
except ValueError:
raise ValueError('bad chunk size %r' % line[:16])
if size == 0:
break
out.extend(rest[:size])
body = rest[size + 2:]
return bytes(out)
def build_http_response(status, reason, headers, body=b'', chunked=False,
compact=False, connection=None):
"""Build an HTTP response. With chunked=True the body is framed as 100-byte
chunks (like the reference admin server); with compact=True header names
and values are separated by ':' without whitespace, which keeps the whole
response inside one card-sized TLS record (<= 256 bytes ciphertext).
connection ('close'/'keep-alive') declares the connection fate: without
it an HTTP/1.1 client assumes the connection persists and tries to reuse
it for the next POST instead of dialing a new one (live card 2026-09-15)."""
lines = ['HTTP/1.1 %d %s' % (status, reason)]
sep = ':' if compact else ': '
for name, value in headers.items():
lines.append('%s%s%s' % (name, sep, value))
if connection:
lines.append('Connection%s%s' % (sep, connection))
has_te = 'transfer-encoding' in [k.lower() for k in headers]
if body and (chunked or has_te):
if not has_te:
lines.append('Transfer-Encoding: chunked')
elif body and 'content-length' not in [k.lower() for k in headers]:
lines.append('Content-Length%s%d' % (sep, len(body)))
head = ('\r\n'.join(lines) + '\r\n\r\n').encode('latin-1')
if not body:
return head
if not chunked:
return head + body
out = bytearray(head)
for i in range(0, len(body), 100):
piece = body[i:i + 100]
out += ('%X\r\n' % len(piece)).encode('latin-1') + piece + b'\r\n'
out += b'0\r\n\r\n'
return bytes(out)
class PskTlsServer:
"""PSK TLS listener speaking the GP remote administration HTTP dialog."""
def __init__(self, host, port, psk, identity=None, on_log=None,
responder=None, timeout=10.0, chunked=False, chunk_size=0,
keep_alive=False, compact_headers=False, tls_version='1.2',
cipher=None, on_before_close=None, keylog=None,
conn_header=None, half_close=False, answer_delay=0.0):
self.psk = psk
self.identity = identity
self.on_log = on_log
self.responder = responder or self._default_responder
self.timeout = timeout
self.chunked = chunked
# chunk_size 0 = one record for the whole response
self.chunk_size = int(chunk_size)
self.keep_alive = keep_alive
self.compact_headers = compact_headers
# The reference traces negotiated TLS 1.0 with PSK-AES128-CBC-SHA;
# some cards only speak the older record layer correctly.
self.tls_version = tls_version if tls_version in TLS_VERSIONS else '1.2'
# Pin one cipher suite (e.g. PSK-AES128-CBC-SHA) if the card's SD only
# maps a specific suite to a usable SCP81 security level.
self.cipher = cipher or None
# Called with the peer address just before closing a non-keep-alive
# connection: the server waits until the card has drained the BIP
# buffer, otherwise the EOF truncates the response fetch.
self.on_before_close = on_before_close
# Debug aid: write the TLS traffic secrets to this file
# (SSLKEYLOGFILE format), so captures of the PSK dialog can be
# decrypted (tshark etc). Contains key material - use a temp path.
self.keylog = keylog or None
# Connection header value: None = auto ('keep-alive'/'close' per the
# keep_alive flag), 'none' = omit the header (Apache-style implicit
# HTTP/1.1 keep-alive, as in the working reference trace).
self.conn_header = conn_header or None
# TLS half-close after a script body. NOTE (live 2026-09-16):
# CPython's SSLSocket.unwrap() poisons the session when the peer does
# not answer with its own close_notify in time, so this cannot be
# implemented with the stdlib ssl module; the flag is kept for the
# option surface and for cards that answer promptly (the exception
# path leaves the session unusable, so it is off by default).
self.half_close = half_close
# Wait before answering a request (the reference Apache/PHP servers
# answer ~1 s after the card's POST; the card may need its BIP
# SEND-DATA conversation to settle before it accepts the response).
self.answer_delay = float(answer_delay or 0)
self.identity_seen = None
self.stopped = False
self.conns = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# A quick Stop -> Start can race the previous listener's close (the
# port stays busy for a moment); retry before giving up.
last_error = None
for _ in range(10):
try:
self.sock.bind((host, int(port)))
last_error = None
break
except OSError as e:
last_error = e
time.sleep(0.3)
if last_error is not None:
self.sock.close()
raise last_error
self.sock.listen(4)
self.host, self.port = self.sock.getsockname()[:2]
self.ctx = self._make_context()
if self.keylog:
try:
self.ctx.keylog_filename = self.keylog
except (AttributeError, OSError):
self.keylog = None
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
self.thread.start()
self.log('tls-listener-start', host=self.host, port=self.port)
def log(self, kind, **fields):
if self.on_log:
try:
self.on_log(kind, **fields)
except Exception:
pass
def _make_context(self):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ver = TLS_VERSIONS[self.tls_version]
ctx.minimum_version = ver
ctx.maximum_version = ver
ciphers = self.cipher or PSK_CIPHERS
if self.tls_version in ('1.0', '1.1'):
# OpenSSL 3.x disables the legacy protocol versions by default.
ciphers += ':@SECLEVEL=0'
ctx.set_ciphers(ciphers)
# Prefer our (AES-first) order over the card's NULL-suite-first list.
ctx.options |= ssl.OP_CIPHER_SERVER_PREFERENCE
ctx.options |= OP_NO_ENCRYPT_THEN_MAC
# No TLS session resumption: the live card aborts with
# SSLV3_ALERT_UNEXPECTED_MESSAGE on the post-handshake
# NewSessionTicket record (verified live 2026-09-15).
ctx.options |= ssl.OP_NO_TICKET
ctx.set_psk_server_callback(self._psk_cb)
return ctx
def _psk_cb(self, identity):
"""OpenSSL asks for the key of the identity the client sent."""
self.identity_seen = identity
if self.identity is not None and identity != self.identity:
self.log('tls-psk-unknown', identity=identity)
# A dummy key keeps the callback type-safe; the handshake then
# fails on the Finished MAC check.
return b'\x00' * 16
return self.psk
@staticmethod
def _default_responder(method, target, headers, body):
"""No script configured: close the administration session (4.4.2)."""
return 204, {'X-Admin-Protocol': GP_PROTOCOL}, b''
def _accept_loop(self):
while not self.stopped:
try:
self.sock.settimeout(0.2)
conn, addr = self.sock.accept()
except socket.timeout:
continue
except OSError:
break
self.conns.append(conn)
peer = '%s:%d' % addr[:2]
threading.Thread(target=self._conn_loop, args=(conn, peer),
daemon=True).start()
def _read_request(self, tls):
buf = b''
while b'\r\n\r\n' not in buf:
chunk = tls.recv(4096)
if not chunk:
return None
buf += chunk
if len(buf) > MAX_HEAD:
raise ValueError('request head too large')
head, _, rest = buf.partition(b'\r\n\r\n')
method, target, headers = parse_http_request(head + b'\r\n\r\n')
body = rest
if 'content-length' in headers:
want = int(headers['content-length'])
while len(body) < want:
chunk = tls.recv(4096)
if not chunk:
break
body += chunk
body = body[:want]
elif headers.get('transfer-encoding', '').lower() == 'chunked':
while not body.endswith(b'0\r\n\r\n'):
chunk = tls.recv(4096)
if not chunk:
break
body += chunk
body = decode_chunked(body)
return method, target, headers, body
def _conn_loop(self, conn, peer):
tls = None
try:
tls = self.ctx.wrap_socket(conn, server_side=True)
self.log('tls-handshake', peer=peer, cipher=tls.cipher()[0],
version=tls.version(), identity=self.identity_seen)
while not self.stopped:
req = self._read_request(tls)
if req is None:
break
method, target, headers, body = req
if self.answer_delay > 0:
time.sleep(self.answer_delay)
self.log('tls-request', peer=peer, method=method, uri=target,
headers=headers,
agent=headers.get('x-admin-from'),
protocol=headers.get('x-admin-protocol'),
script_status=headers.get('x-admin-script-status'),
resume=headers.get('x-admin-resume'),
content_type=headers.get('content-type'),
bytes=len(body), body_hex=body.hex().upper()[:2000] or None)
status, resp_headers, resp_body = self.responder(
method, target, headers, body)
reason = {200: 'OK', 204: 'No Content'}.get(status, 'Status')
conn = self.conn_header
if conn == 'none':
conn = None
elif conn is None:
conn = 'keep-alive' if self.keep_alive else 'close'
response = build_http_response(
status, reason, resp_headers, resp_body,
chunked=self.chunked, compact=self.compact_headers,
connection=conn)
# The card's HTTP client reads its response record-by-record:
# the whole response must arrive in ONE TLS record (chunk_size
# 0), otherwise a split head stalls it and a head-only record
# followed by the body draws an unexpected_message alert. When
# a chunk_size is given, the head goes in one record and the
# body in pieces of that size.
if self.chunk_size <= 0:
tls.sendall(response)
else:
head, sep, rest = response.partition(b'\r\n\r\n')
tls.sendall(head + sep if sep else head)
for off in range(0, len(rest), self.chunk_size):
tls.sendall(rest[off:off + self.chunk_size])
self.log('tls-response', peer=peer, status=status,
bytes=len(resp_body), chunked=self.chunked,
response_hex=response.hex().upper()[:600],
body_hex=resp_body.hex().upper()[:2000] or None)
# 204 always ends the dialog. Without keep-alive every response
# ends it: the card's HTTP client appears to delimit the
# response at connection close (live 2026-09-15) and then
# starts a fresh session for its next POST.
if status == 204 or not resp_body or not self.keep_alive:
peer_name = None
if resp_body and self.on_before_close:
try:
peer_name = tls.getpeername()
except Exception:
peer_name = None
plain = None
if not self.keep_alive:
# Clean TLS shutdown BEFORE the card drains the
# buffer: a bare TCP close leaves the card's TLS stack
# with a truncated session (it then neither processes
# the script nor posts the response), and a
# close_notify sent only after the drain is never
# fetched. Send it while the response still waits, so
# the card reads both, then wait for the buffer to
# drain and only then send the FIN.
try:
tls.settimeout(2.0)
plain = tls.unwrap()
tls = None
except Exception:
plain = None
if peer_name and self.on_before_close:
try:
self.on_before_close(peer_name)
except Exception:
pass
if plain is not None:
try:
plain.close()
except OSError:
pass
break
except ssl.SSLError as e:
self.log('tls-error', peer=peer, error=str(e))
except (OSError, ValueError) as e:
self.log('tls-error', peer=peer, error=str(e))
finally:
if tls is not None:
try:
tls.close()
except OSError:
pass
else:
try:
conn.close()
except OSError:
pass
self.log('tls-close', peer=peer)
if conn in self.conns:
self.conns.remove(conn)
def stop(self):
self.stopped = True
self.log('tls-listener-stop', host=self.host, port=self.port)
try:
self.sock.close()
except OSError:
pass
for conn in list(self.conns):
try:
conn.close()
except OSError:
pass
self.conns = []
+618 -30
View File
@@ -12,6 +12,7 @@ from io import StringIO
from pySim.transport import ApduTracer, ProactiveHandler
from pySim.cards import UiccCardBase
from pysim_otaman_server import httpota
from pysim_otaman_server import scp81
from smartcard.CardMonitoring import CardMonitor, CardObserver
import gsm0338 # registers 'gsm03.38' codec
@@ -20,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
VERSION = '2.1.2'
VERSION = '2.1.5'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -709,14 +710,18 @@ class _DefaultProactiveHandler(ProactiveHandler):
def receive_fetch_raw(self, pcmd, parsed):
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = 1, 0, 0x83, 0x81, None
entry = None
# pySim parses the FETCH response into a command-specific object
# ('parsed'); the 'pcmd' collection stays empty and is only useful as
# a fallback. Use the parsed object for both the log and the response.
cmd_obj = parsed if getattr(parsed, 'children', None) else pcmd
try:
raw = bytes.fromhex(parsed) if parsed else None
raw = cmd_obj.to_tlv()
if raw:
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw)
entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num)
except Exception:
pass
ti_list = self.prepare_response(pcmd, 'performed_successfully')
ti_list = self.prepare_response(cmd_obj, 'performed_successfully')
if cmd_type == 0x26 and cmd_qual is not None:
pli_hex = _PLI_DATA.get(cmd_qual, '')
if pli_hex:
@@ -740,6 +745,9 @@ class _RawBerTlv(BER_TLV_IE):
def to_bytes(self, context={}):
return self._raw
def to_tlv(self):
return self._raw
_STK_DECODE = GsmOrUcs2Adapter(GreedyBytes)
@@ -752,7 +760,7 @@ PROACTIVE_TYPE_NAMES = {
0x13: 'SEND SHORT MESSAGE', 0x20: 'PLAY TONE',
0x21: 'DISPLAY TEXT', 0x22: 'GET INKEY', 0x23: 'GET INPUT',
0x24: 'SELECT ITEM', 0x25: 'SET UP MENU',
0x26: 'PROVIDE LOCAL INFORMATION',
0x26: 'PROVIDE LOCAL INFORMATION', 0x27: 'TIMER MANAGEMENT',
0x15: 'LAUNCH BROWSER', 0x70: 'ACTIVATE',
0x40: 'OPEN CHANNEL', 0x41: 'CLOSE CHANNEL',
0x42: 'RECEIVE DATA', 0x43: 'SEND DATA', 0x44: 'GET CHANNEL STATUS',
@@ -787,6 +795,7 @@ _PLI_DATA = {q: '' for q in PLI_QUALIFIER_NAMES}
_BIP = httpota.BipTerminal()
_SCP81_LISTENER = None
_SCP81_PSK = {}
_POLL_ENABLED = False
_POLL_INTERVAL = 30
@@ -959,6 +968,41 @@ def _dec_imei(hex8):
return s[:15]
def _cmd_tlv(tlvs, tag):
"""Fetch a command TLV, tolerating both the plain tag and its
comprehension-required variant (e.g. 0x24 and 0xA4, TS 101 220 7.1.1)."""
return tlvs.get(tag) or tlvs.get(tag | 0x80) or b''
def _tlv_map(data):
"""Top-level COMPREHENSION-TLV map {tag: value} of a payload without a
D0 wrapper (e.g. the command-specific TLVs of a TERMINAL RESPONSE)."""
out = {}
off = 0
while off + 1 < len(data):
tag, tlen = data[off], data[off + 1]
out.setdefault(tag, data[off + 2: off + 2 + tlen])
off += 2 + tlen
return out
def _bcd_swap(b):
"""Semi-octet BCD digit pair (TS 123 040 TP-SCT): low nibble first."""
return (b & 0x0F) * 10 + ((b >> 4) & 0x0F)
def _hms_bcd(seconds):
"""Encode seconds as hour/minute/second semi-octet BCD (TS 123 040)."""
h, rem = divmod(int(seconds), 3600)
m, s = divmod(rem, 60)
return bytes([((h % 10) << 4) | (h // 10),
((m % 10) << 4) | (m // 10),
((s % 10) << 4) | (s // 10)])
_TIMER_ACTIONS = {0x00: 'Start', 0x01: 'Deactivate', 0x02: 'Get current value'}
def _decode_cmd(cmd_type, raw, qualifier):
"""Decode a fetched proactive command into [{label, value}] pairs."""
if not raw:
@@ -1000,6 +1044,23 @@ def _decode_cmd(cmd_type, raw, qualifier):
if cmd_type == 0x26 and qualifier is not None:
name = PLI_QUALIFIER_NAMES.get(qualifier, 'Unknown')
return [{'label': 'Qualifier', 'value': '%s (0x%02X)' % (name, qualifier)}]
if cmd_type == 0x27:
out = []
if qualifier is not None:
action = _TIMER_ACTIONS.get(qualifier & 0x03)
out.append({'label': 'Action',
'value': action or 'Reserved (0x%02X)' % qualifier})
tlvs = httpota.proactive_tlvs(raw)
timer = _cmd_tlv(tlvs, 0x24)
if timer:
tv = timer[0]
out.append({'label': 'Timer',
'value': str(tv) if 1 <= tv <= 8 else 'Invalid (0x%02X)' % tv})
value = _cmd_tlv(tlvs, 0x25)
if len(value) >= 3:
out.append({'label': 'Value', 'value': '%02d:%02d:%02d' % (
_bcd_swap(value[0]), _bcd_swap(value[1]), _bcd_swap(value[2]))})
return out
return [{'label': 'Data', 'value': raw.hex()}]
@@ -1015,6 +1076,17 @@ def _decode_tr(type_hex, qual_hex, tr_hex):
return [{'label': 'Data', 'value': h}]
if cmd_type == 0x03 and len(h) >= 8 and h[0:2] == '84':
return [{'label': 'Interval', 'value': '%d s' % int(h[6:8], 16)}]
if cmd_type == 0x27:
tlvs = _tlv_map(bytes.fromhex(h))
out = []
tid = _cmd_tlv(tlvs, 0x24)
if tid:
out.append({'label': 'Timer', 'value': str(tid[0])})
val = _cmd_tlv(tlvs, 0x25)
if len(val) >= 3:
out.append({'label': 'Remaining', 'value': '%02d:%02d:%02d' % (
_bcd_swap(val[0]), _bcd_swap(val[1]), _bcd_swap(val[2]))})
return out or [{'label': 'Data', 'value': h}]
if cmd_type == 0x26 and qual_hex:
try:
qual = int(qual_hex, 16)
@@ -1088,28 +1160,31 @@ def _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, result=0x00, info=Non
def _decode_bip_cmd(cmd_type, raw):
tlvs = httpota.proactive_tlvs(raw)
out = []
dev = _cmd_tlv(tlvs, 0x02)
if len(dev) >= 2 and 0x21 <= dev[1] <= 0x27:
out.append({'label': 'Channel', 'value': str(dev[1] & 0x07)})
if cmd_type == 0x40:
bearer = tlvs.get(httpota.TAG_BEARER, b'')
bearer = _cmd_tlv(tlvs, httpota.TAG_BEARER)
if bearer:
out.append({'label': 'Bearer', 'value': '0x%02X' % bearer[0]})
bs = tlvs.get(httpota.TAG_BUFFER_SIZE, b'')
bs = _cmd_tlv(tlvs, httpota.TAG_BUFFER_SIZE)
if len(bs) >= 2:
out.append({'label': 'Buffer size', 'value': str(int.from_bytes(bs[:2], 'big'))})
naa = tlvs.get(httpota.TAG_NAA, b'')
naa = _cmd_tlv(tlvs, httpota.TAG_NAA)
if naa:
out.append({'label': 'APN', 'value': naa[1:].decode('ascii', 'replace')})
addr = httpota.parse_other_address(tlvs.get(httpota.TAG_OTHER_ADDRESS, b''))
addr = httpota.parse_other_address(_cmd_tlv(tlvs, httpota.TAG_OTHER_ADDRESS))
if addr:
out.append({'label': 'Destination', 'value': addr})
proto, port = httpota.parse_transport_level(tlvs.get(httpota.TAG_TRANSPORT_LEVEL, b''))
proto, port = httpota.parse_transport_level(_cmd_tlv(tlvs, httpota.TAG_TRANSPORT_LEVEL))
if port is not None:
out.append({'label': 'Transport', 'value': '%s port %d' % ({0x02: 'TCP client'}.get(proto, 'proto 0x%02X' % (proto or 0)), port)})
elif cmd_type == 0x42:
req = tlvs.get(httpota.TAG_CHANNEL_DATA_LENGTH, b'')
req = _cmd_tlv(tlvs, httpota.TAG_CHANNEL_DATA_LENGTH)
if req:
out.append({'label': 'Requested bytes', 'value': str(req[0])})
elif cmd_type == 0x43:
data = tlvs.get(httpota.TAG_CHANNEL_DATA, b'')
data = _cmd_tlv(tlvs, httpota.TAG_CHANNEL_DATA)
out.append({'label': 'Data bytes', 'value': str(len(data))})
if data:
out.append({'label': 'Data', 'value': data.hex()[:120]})
@@ -1121,33 +1196,41 @@ def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
tlvs = httpota.proactive_tlvs(raw)
channel = _bip_channel_id(dev_dst)
if cmd_type == 0x40:
bs = tlvs.get(httpota.TAG_BUFFER_SIZE, b'\x02\x00')
bs = _cmd_tlv(tlvs, httpota.TAG_BUFFER_SIZE) or b'\x02\x00'
buffer_size = int.from_bytes(bs[:2], 'big') if len(bs) >= 2 else 0x0200
bearer = tlvs.get(httpota.TAG_BEARER, b'\x03')
bearer = _cmd_tlv(tlvs, httpota.TAG_BEARER) or b'\x03'
extra = bytes([httpota.TAG_BEARER, len(bearer)]) + bearer
extra += bytes([httpota.TAG_BUFFER_SIZE, 0x02]) + buffer_size.to_bytes(2, 'big')
if not _BIP.enabled:
_BIP.log('open-unavailable', reason='BIP not enabled')
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00, extra)
addr = httpota.parse_other_address(tlvs.get(httpota.TAG_OTHER_ADDRESS, b''))
proto, port = httpota.parse_transport_level(tlvs.get(httpota.TAG_TRANSPORT_LEVEL, b''))
addr = httpota.parse_other_address(_cmd_tlv(tlvs, httpota.TAG_OTHER_ADDRESS))
proto, port = httpota.parse_transport_level(_cmd_tlv(tlvs, httpota.TAG_TRANSPORT_LEVEL))
if not addr or port is None:
_BIP.log('open-unavailable', reason='missing destination/transport', address=addr, port=port)
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00, extra)
cid, err = _BIP.open(addr, port, buffer_size)
# Emulation is deliberately permissive: the APN, destination and
# transport are informational, the channel always goes to the
# configured local target (the live card emits truncated/empty
# destination TLVs - see the AGENTS.md HTTP OTA notes).
_BIP.log('open-relaxed', address=addr, port=port,
note='destination/transport not fully specified')
cid, err = _BIP.open(addr or '-', port or 0, buffer_size)
if cid is None:
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00, extra)
if cmd_qual and (cmd_qual & 0x04):
# Background mode: the terminal shall inform the UICC that the
# link was established (TS 102 223 7.5.11).
_BIP._queue_link_status(cid, status=0x80 | (cid & 0x07), info=0x00)
status = bytes([httpota.TAG_CHANNEL_STATUS, 0x02, 0x80 | (cid & 0x07), 0x00])
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x00, None, status + extra)
if cmd_type == 0x43:
data = tlvs.get(httpota.TAG_CHANNEL_DATA, b'')
data = _cmd_tlv(tlvs, httpota.TAG_CHANNEL_DATA)
if channel is None or not _BIP.send(channel, data):
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00)
length = _BIP.send_capacity(channel)
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x00, None,
bytes([httpota.TAG_CHANNEL_DATA_LENGTH, 0x01, length & 0xFF]))
if cmd_type == 0x42:
req = tlvs.get(httpota.TAG_CHANNEL_DATA_LENGTH, b'\x00')
req = _cmd_tlv(tlvs, httpota.TAG_CHANNEL_DATA_LENGTH) or b'\x00'
n = req[0] if req else 0
if channel is None:
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00)
@@ -1155,7 +1238,17 @@ def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
if data is None:
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00)
remaining = _BIP.available(channel)
extra = bytes([httpota.TAG_CHANNEL_DATA, len(data)]) + data if data else b''
# The channel data TLV length is BER-encoded: a single byte only up
# to 127, then the 0x81 long form (the reference terminal traces use
# `36 81 ed` for a 237-byte chunk). With a raw length byte >0x7F the
# card reads a malformed TLV and the record bytes never reach its
# TLS layer (it fetches, accepts, and never processes the response).
extra = b''
if data:
if len(data) <= 0x7F:
extra = bytes([httpota.TAG_CHANNEL_DATA, len(data)]) + data
else:
extra = bytes([httpota.TAG_CHANNEL_DATA, 0x81, len(data)]) + data
extra += bytes([httpota.TAG_CHANNEL_DATA_LENGTH, 0x01, 0xFF if remaining > 0xFF else remaining])
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x00, None, extra)
if cmd_type == 0x41:
@@ -1173,11 +1266,258 @@ def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
def _scp81_listener_status():
if not _SCP81_LISTENER:
return None
if isinstance(_SCP81_LISTENER, scp81.PskTlsServer):
return {'mode': 'tls', 'host': _SCP81_LISTENER.host, 'port': _SCP81_LISTENER.port,
'psk_identity': _SCP81_LISTENER.identity,
'identity_seen': _SCP81_LISTENER.identity_seen,
'chunked': _SCP81_LISTENER.chunked,
'chunk_size': _SCP81_LISTENER.chunk_size,
'keep_alive': _SCP81_LISTENER.keep_alive,
'compact_headers': _SCP81_LISTENER.compact_headers,
'tls_version': _SCP81_LISTENER.tls_version,
'cipher': _SCP81_LISTENER.cipher}
return {'mode': 'dump', 'host': _SCP81_LISTENER.host, 'port': _SCP81_LISTENER.port}
def _scp81_wait_drained(peer):
"""Wait until the BIP channel for this TLS connection has delivered its
buffered bytes to the card (matched by the terminal's ephemeral port), so
a connection close does not truncate the response fetch."""
if not peer or len(peer) < 2:
return
port = peer[1]
deadline = time.time() + 5.0
seen_data = False
while time.time() < deadline:
ch = None
for c in list(_BIP.channels.values()):
try:
if c.sock.getsockname()[1] == port:
ch = c
break
except OSError:
continue
if ch is None:
return
if ch.rx:
# Channel pump has picked up the response; wait for the card.
seen_data = True
elif seen_data:
return
time.sleep(0.05)
def _bip_data_available(ch):
"""Monitor-thread callback: tell the card there is server data to fetch.
TS 102 223 7.5.10 - ENVELOPE (Event Download - Data available) carries the
Channel status and the number of bytes waiting; the card then issues
RECEIVE DATA. Returns True when the event was sent (the caller then marks
the bytes as notified)."""
server = _server_ref
if not server or not _CARD_CONNECTED or ch.peer_closed:
return False
ev_list = getattr(server, 'event_list', None) or []
if 0x09 not in ev_list or not ch.rx:
return False
with _CARD_LOCK:
server = _server_ref
if not server or not _CARD_CONNECTED or not getattr(server, 'scc', None):
return False
if _PROACTIVE_BUSY or getattr(server, 'stk_pending', None):
return False
length = min(len(ch.rx), 0xFF)
tlv = bytes([0xB8, 0x02, 0x80 | (ch.id & 0x07), 0x00,
0xB7, 0x01, length])
try:
_send_event_download(server.scc, 0x09, tlv)
except Exception as e:
_BIP.log('data-available-skip', channel=ch.id, reason=str(e))
return False
_BIP.log('data-available', channel=ch.id, length=length)
return True
# ---- SCP81 command scripting (GP RAM over HTTP, TS 102 226 5.2) -----------
# The administration server answers the card's POST with one C-APDU per
# request (Command Scripting template 'AE 80 22 <len> <apdu> 00 00',
# indefinite length as recommended for RAM over HTTPS) and reads the R-APDU
# from the next POST's Response Scripting template ('AB'/'AF', with '80'
# executed-count and '23' R-APDU TLVs whose last two bytes are SW1 SW2).
_SCP81_SCRIPTS = {
# The command sequence of the reference administration server
# (samples/HTTP_OTA/httpota_adminserver_php_v2, get_next_apdu), extended
# with the registries: GET DATA FF21 (extended card resources / free
# memory), GET STATUS P1=80 (Issuer Security Domain), GET DATA 0085,
# GET STATUS P1=40 (executable load files / ELF), GET STATUS P1=10
# (applications/applets); P2=02 with data '4F00' selects the TLV format,
# Le=00 so no GET RESPONSE is needed.
'explore': ['80CAFF2100', '80F28002024F0000', '80CA008500',
'80F24002024F0000', '80F21002024F0000'],
'none': [],
}
_SCP81_SCRIPT = list(_SCP81_SCRIPTS['explore'])
_SCP81_SCRIPT_SENT = 0
_SCP81_SCRIPT_RESULTS = []
_SCP81_SCRIPT_TEMPLATE = 'indefinite'
_SCP81_SCRIPT_CR_TAG = False
# None = short per-command Next-URI ('/N'); '' = omit the header (spec: the
# card executes the script, sends no response string and closes the session).
_SCP81_NEXT_URI = None
# Optional X-Admin-Targeted-Application header (spec syntax //aid/<RID>/<PIX>).
# When it names an application that does not exist on the card, the SD answers
# with X-Admin-Script-Status: unknown-application instead of executing.
_SCP81_TARGETED_APP = None
# Emit Apache-style responses (Date/Server/X-Powered-By, Content-Length before
# Content-Type) exactly like the reference admin servers.
_SCP81_APACHE_HEADERS = False
# The listener's chunked flag (mirrored here for the response headers: a
# chunked response must not carry Content-Length - invalid HTTP, and the
# reference sends Transfer-Encoding before Content-Type).
_SCP81_CHUNKED = False
# Send automatic Channel status (link dropped) events to the card. Suppress
# while testing flows where the terminal closes the connection on purpose:
# the card must drain the buffered response and resume on a new connection.
_SCP81_LINK_EVENTS = True
def _scp81_command_body(apdu_hex, definite=False, cr_tag=False):
"""Command Scripting template with one C-APDU TLV: the indefinite-length
variant ('AE 80 22 <len> <apdu> 00 00', recommended for RAM over HTTPS) or
the definite-length one ('AA <len> 22 <len> <apdu>'). The C-APDU TLV tag
is '22' per TS 101 220 (CR flag 0); some cards expect the CR-set 'A2'
instead, so it is configurable."""
apdu = bytes.fromhex(re.sub(r'\s', '', apdu_hex))
cmd_tlv = bytes([0xA2 if cr_tag else 0x22, len(apdu)]) + apdu
if definite:
return bytes([0xAA, len(cmd_tlv)]) + cmd_tlv
return bytes([0xAE, 0x80]) + cmd_tlv + b'\x00\x00'
def _scp81_parse_response(body):
"""Parse a Response Scripting template (TS 102 226 5.2.2, definite 'AB'
or indefinite 'AF'); returns (executed_count, [(rapdu, sw_hex), ...])."""
if not body:
return 0, []
if body[0] == 0xAF and len(body) >= 2 and body[1] == 0x80:
content = body[2:-2] if body.endswith(b'\x00\x00') else body[2:]
elif body[0] == 0xAB:
ln, off = httpota.ber_len_read(body, 1)
content = body[off:off + ln]
else:
content = body
count, out = 0, []
off = 0
while off + 1 < len(content):
tag, tlen = content[off], content[off + 1]
val = content[off + 2:off + 2 + tlen]
off += 2 + tlen
if tag == 0x80:
count = int.from_bytes(val, 'big') if val else 0
elif tag == 0x23 and len(val) >= 2:
out.append((val[:-2], val[-2:].hex().upper()))
return count, out
def _scp81_decode_memory(rapdu):
"""GET DATA FF21 value: 81 applet count, 82 free NV (3 B), 83 free volatile."""
if len(rapdu) < 5 or rapdu[0] != 0xFF or rapdu[1] != 0x21:
return None
content = rapdu[3:3 + rapdu[2]]
out = {}
off = 0
while off + 1 < len(content):
tag, tlen = content[off], content[off + 1]
val = content[off + 2:off + 2 + tlen]
off += 2 + tlen
if tag == 0x81:
out['applets'] = int.from_bytes(val, 'big')
elif tag == 0x82:
out['free_nv'] = int.from_bytes(val, 'big')
elif tag == 0x83:
out['free_volatile'] = int.from_bytes(val, 'big')
return out or None
def _scp81_script_responder(method, target, headers, body):
"""Remote Administration Server side of the administration session: send
the next scripted C-APDU or close the session (TS 102 226 / GP 4.4.2)."""
global _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS
status = headers.get('x-admin-script-status')
if status is not None:
index = _SCP81_SCRIPT_SENT
if status != 'ok':
_BIP.log('script-status', index=index, status=status)
else:
count, rapdus = _scp81_parse_response(body)
for rapdu, sw in rapdus:
_BIP.log('script-rapdu', index=index, sw=sw, bytes=len(rapdu),
hex=rapdu.hex().upper()[:2000])
_SCP81_SCRIPT_RESULTS.append({'index': index, 'sw': sw,
'rapdu': rapdu.hex().upper()})
if rapdus and _SCP81_SCRIPT and index >= 1:
apdu = _SCP81_SCRIPT[index - 1].upper()
if apdu.startswith('80CAFF21'):
decoded = _scp81_decode_memory(rapdus[-1][0])
if decoded:
_BIP.log('script-memory', **decoded)
else:
# First (or resumed) POST of a session: run the script from the start.
_SCP81_SCRIPT_SENT = 0
_SCP81_SCRIPT_RESULTS = []
if _SCP81_SCRIPT_SENT < len(_SCP81_SCRIPT):
apdu = _SCP81_SCRIPT[_SCP81_SCRIPT_SENT]
_SCP81_SCRIPT_SENT += 1
_BIP.log('script-send', index=_SCP81_SCRIPT_SENT, apdu=apdu)
headers = _scp81_response_headers()
if _SCP81_TARGETED_APP:
headers['X-Admin-Targeted-Application'] = _SCP81_TARGETED_APP
# The working reference session (samples/HTTPOTA_session_3311_success1)
# answers with a relative URI plus a QUERY (/Download?req=N): a
# query-less Next-URI makes the card abort the TLS session. A '%d'
# in the configured/default URI is replaced with the command number.
template = _SCP81_NEXT_URI if _SCP81_NEXT_URI is not None else '/api/scp81?req=%d'
next_uri = template % _SCP81_SCRIPT_SENT if '%d' in template else template
if next_uri:
headers['X-Admin-Next-URI'] = next_uri
body_out = _scp81_command_body(
apdu, definite=(_SCP81_SCRIPT_TEMPLATE == 'definite'),
cr_tag=_SCP81_SCRIPT_CR_TAG)
if _SCP81_APACHE_HEADERS:
if _SCP81_CHUNKED:
headers['Transfer-Encoding'] = 'chunked'
else:
headers['Content-Length'] = str(len(body_out))
headers['Content-Type'] = scp81.GP_CT_COMMAND
return 200, headers, body_out
_BIP.log('script-done', sent=_SCP81_SCRIPT_SENT,
results=len(_SCP81_SCRIPT_RESULTS))
headers = _scp81_response_headers()
if _SCP81_APACHE_HEADERS:
headers['Content-Type'] = 'text/html; charset=UTF-8'
return 204, headers, b''
def _scp81_response_headers():
"""Base response headers, in the reference servers' order (Apache adds
Date/Server/X-Powered-By before the admin headers)."""
headers = {}
if _SCP81_APACHE_HEADERS:
import email.utils
headers['Date'] = email.utils.formatdate(usegmt=True)
headers['Server'] = 'Apache'
headers['X-Powered-By'] = 'PHP/7.0.33'
headers['X-Admin-Protocol'] = scp81.GP_PROTOCOL
return headers
def _scp81_bip_control(body):
global _SCP81_LISTENER
global _SCP81_LISTENER, _SCP81_PSK
global _SCP81_SCRIPT, _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS
global _SCP81_SCRIPT_TEMPLATE, _SCP81_SCRIPT_CR_TAG, _SCP81_NEXT_URI
global _SCP81_LINK_EVENTS, _SCP81_TARGETED_APP, _SCP81_APACHE_HEADERS
global _SCP81_CHUNKED
body = body or {}
action = body.get('action', 'start')
if action == 'stop':
@@ -1187,14 +1527,81 @@ def _scp81_bip_control(body):
_BIP.disable()
return {'ok': True, 'bip': _BIP.status(), 'listener': None}
host = body.get('host') or '127.0.0.1'
port = int(body.get('port') or 8443)
port = body.get('port')
port = int(port) if port not in (None, '') else 8443
mode = body.get('mode', 'dump')
if _SCP81_LISTENER:
_SCP81_LISTENER.stop()
_SCP81_LISTENER = None
_BIP.disable()
if mode == 'tls':
psk_hex = body.get('psk_hex') or _SCP81_PSK.get('psk_hex')
if not psk_hex:
return {'ok': False, 'error': 'psk_hex is required for tls mode'}
try:
psk = bytes.fromhex(re.sub(r'\s', '', psk_hex))
except ValueError:
return {'ok': False, 'error': 'psk_hex is not valid hex'}
if not psk:
return {'ok': False, 'error': 'psk_hex is empty'}
identity = body.get('psk_identity')
if identity is not None:
identity = identity.strip() or None # empty clears the pin
else:
identity = _SCP81_PSK.get('psk_identity')
_SCP81_PSK = {'psk_hex': psk_hex, 'psk_identity': identity}
script = body.get('script', 'explore')
if isinstance(script, list):
_SCP81_SCRIPT = [re.sub(r'\s', '', s) for s in script if s]
elif script in _SCP81_SCRIPTS:
_SCP81_SCRIPT = list(_SCP81_SCRIPTS[script])
else:
return {'ok': False, 'error': 'unknown script preset: %s' % script}
_SCP81_SCRIPT_SENT = 0
_SCP81_SCRIPT_RESULTS = []
template = body.get('script_template', 'indefinite')
if template not in ('indefinite', 'definite'):
return {'ok': False, 'error': 'script_template must be indefinite or definite'}
_SCP81_SCRIPT_TEMPLATE = template
_SCP81_SCRIPT_CR_TAG = bool(body.get('cr_tag', False))
if 'next_uri' in body:
_SCP81_NEXT_URI = body.get('next_uri') or ''
_SCP81_LINK_EVENTS = bool(body.get('link_events', True))
_SCP81_TARGETED_APP = (body.get('targeted_app') or None)
# Defaults reproduce the working reference session (decrypted from
# samples/HTTP_OTA: RAM/HTTPOTA_test5.pcap): one keep-alive connection,
# Apache-style response headers, a chunked body whose script sits in
# one TLS record, no Connection header, and an X-Admin-Next-URI with a
# query whose command id increments. Overrides remain available.
_SCP81_APACHE_HEADERS = bool(body.get('apache_headers', True))
_SCP81_CHUNKED = bool(body.get('chunked', True))
cs = body.get('chunk_size')
chunk_size = int(cs) if cs not in (None, '') else 0
_SCP81_LISTENER = scp81.PskTlsServer(
host, port, psk, identity=identity,
responder=_scp81_script_responder,
chunked=bool(body.get('chunked', True)),
chunk_size=chunk_size,
keep_alive=bool(body.get('keep_alive', True)),
compact_headers=bool(body.get('compact_headers', False)),
tls_version=str(body.get('tls_version') or '1.2'),
cipher=(body.get('cipher') or None),
on_before_close=_scp81_wait_drained,
keylog=(body.get('keylog') or None),
conn_header=(body.get('conn_header') or 'none'),
answer_delay=(body.get('answer_delay') or 0),
on_log=lambda kind, **fields: _BIP.log(kind, **fields))
_BIP.on_data = _bip_data_available
_BIP.enable(host, _SCP81_LISTENER.port)
return {'ok': True, 'bip': _BIP.status(), 'listener': _scp81_listener_status(),
'script': _SCP81_SCRIPT, 'script_template': _SCP81_SCRIPT_TEMPLATE,
'cr_tag': _SCP81_SCRIPT_CR_TAG, 'link_events': _SCP81_LINK_EVENTS,
'targeted_app': _SCP81_TARGETED_APP,
'apache_headers': _SCP81_APACHE_HEADERS,
'chunked': _SCP81_CHUNKED}
if mode != 'dump':
return {'ok': False, 'error': 'unsupported mode: %s' % mode}
_BIP.on_data = _bip_data_available
_SCP81_LISTENER = httpota.TcpDumpServer(
host, port,
on_rx=lambda peer, data: _BIP.log('dump-rx', peer=peer, bytes=len(data), hex=data.hex().upper()[:2000]),
@@ -1232,9 +1639,9 @@ _RESULT_NAMES_BASIC = {
0x21: 'Backward move in the proactive SIM session requested by the user',
0x22: 'No response from user',
0x23: 'Help information required by the user',
0x24: 'USSD or SS transaction terminated by the user',
0x25: 'Proactive SIM session terminated by the user',
0x26: 'Backward move in the proactive SIM session requested by the user',
0x24: 'Action in contradiction with the current timer state',
0x25: 'Interaction with call control by NAA, temporary problem',
0x26: 'Launch browser generic error',
}
_RESULT_NAMES_GENERAL = {
@@ -1309,6 +1716,7 @@ def _handle_card_disconnect():
global _CARD_CONNECTED
_poll_disable()
_cancel_menu_timeout()
_timer_cancel()
_CARD_CONNECTED = False
if _server_ref:
_server_ref.card = None
@@ -1329,6 +1737,7 @@ def _apply_equipped_card(server):
server.stk_pending = None
server.menu_active = False
_cancel_menu_timeout()
_timer_cancel()
server.event_list = None
_reset_proactive_log()
server.card = server.app.card
@@ -1502,6 +1911,39 @@ def _send_event_download(scc, event_type, event_data=None):
return data, sw
_FLUSHING_CHANNEL_EVENTS = False
# True while a FETCH/TERMINAL RESPONSE chain is running: terminal-initiated
# ENVELOPEs must never interleave with it.
_PROACTIVE_BUSY = False
def _bip_flush_channel_events(scc):
"""Inform the UICC about BIP link changes detected outside its proactive
commands (TS 102 223 7.5.11), if the card subscribed to Channel status.
Called when the proactive session is idle, never between FETCH and TR."""
global _FLUSHING_CHANNEL_EVENTS
if _FLUSHING_CHANNEL_EVENTS:
return
if not _SCP81_LINK_EVENTS:
_BIP.take_pending_events()
return
ev_list = getattr(_server_ref, 'event_list', None) or []
if 0x0A not in ev_list:
return
events = _BIP.take_pending_events()
if not events:
return
_FLUSHING_CHANNEL_EVENTS = True
try:
for ev in events:
_send_event_download(scc, 0x0A, bytes([
httpota.TAG_CHANNEL_STATUS | 0x80, 0x02, ev['status'], ev['info']]))
except Exception as e:
sys.stderr.write('Channel status event error: %s\n' % e)
finally:
_FLUSHING_CHANNEL_EVENTS = False
def _skip_ber_len(raw, off):
if off >= len(raw):
return off
@@ -1512,6 +1954,121 @@ def _skip_ber_len(raw, off):
return off + 3
# ---- TIMER MANAGEMENT (TS 102 223 6.6.21, 6.8.13/14, 7.4) -----------------
# The terminal keeps up to 8 timers per card session. On expiry it must send
# ENVELOPE (TIMER EXPIRATION, tag D7) so the card can act (a common OTA retry
# mechanism); a reset or card removal deactivates all timers.
_TIMERS = {}
_TIMER_LOCK = threading.Lock()
def _timer_cancel(timer_id=None):
"""Cancel one timer, or all of them (reset / card removal)."""
with _TIMER_LOCK:
ids = list(_TIMERS) if timer_id is None else [timer_id]
for tid in ids:
entry = _TIMERS.pop(tid, None)
if entry:
entry['timer'].cancel()
def _timer_remaining(timer_id):
with _TIMER_LOCK:
entry = _TIMERS.get(timer_id)
if not entry:
return None
return max(0, int(round(entry['deadline'] - time.time())))
def _timer_start(timer_id, seconds):
"""Start (or restart) a timer; returns False for an invalid identifier."""
if not 1 <= timer_id <= 8:
return False
_timer_cancel(timer_id)
timer = threading.Timer(seconds, _timer_fire, args=(timer_id, seconds))
timer.daemon = True
timer.start()
with _TIMER_LOCK:
_TIMERS[timer_id] = {'timer': timer, 'deadline': time.time() + seconds}
return True
def _timer_fire(timer_id, elapsed):
"""Timer callback: the timer is consumed on expiry (7.4.1); a timer that
was cancelled or restarted in the meantime must not report."""
with _TIMER_LOCK:
entry = _TIMERS.pop(timer_id, None)
if entry is None:
return
_timer_expired(timer_id, elapsed)
def _timer_expired(timer_id, elapsed):
"""Pass an expired timer to the UICC with ENVELOPE (TIMER EXPIRATION)."""
server = _server_ref
if not _CARD_CONNECTED or not server:
return
# Never inject the ENVELOPE while a fetched command awaits its TERMINAL
# RESPONSE (a paused STK menu); wait outside the card lock, then retry.
for _ in range(10):
if not getattr(server, 'stk_pending', None):
break
time.sleep(2)
with _CARD_LOCK:
if server is not _server_ref or not getattr(server, 'scc', None):
return
if getattr(server, 'stk_pending', None):
_timer_start(timer_id, 5)
return
scc = server.scc
inner = bytes([0x82, 0x02, 0x82, 0x81, 0xA4, 0x01, timer_id & 0xFF,
0xA5, 0x03]) + _hms_bcd(elapsed)
tlv = bytes([0xD7, len(inner)]) + inner
apdu = '%sc20000%02x%s' % (scc.cat_cla, len(tlv), tlv.hex())
for _ in range(3):
try:
data, sw = scc._tp.send_apdu(apdu)
except Exception as e:
sys.stderr.write('TIMER-EXPIRATION send error: %s\n' % e)
_handle_card_disconnect()
return
sys.stderr.write('ENVELOPE(Timer Expiration): timer=%d elapsed=%ds -> %s\n'
% (timer_id, elapsed, sw))
if sw == '9300':
# UICC busy: the terminal shall retry until accepted (7.4.1).
time.sleep(1)
continue
if sw.startswith('91'):
_handle_proactive_chain(scc, sw)
break
def _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst):
"""Terminal side of TIMER MANAGEMENT. Returns the TERMINAL RESPONSE payload."""
tlvs = httpota.proactive_tlvs(raw)
tid = _cmd_tlv(tlvs, 0x24)
timer_id = tid[0] if tid else 1
action = (cmd_qual or 0) & 0x03
base = bytes([0x81, 0x03, cmd_num, cmd_type, (cmd_qual or 0) & 0xFF,
0x82, 0x02, dev_dst, dev_src])
if action == 0x00:
value = _cmd_tlv(tlvs, 0x25)
if len(value) >= 3 and 1 <= timer_id <= 8:
secs = (_bcd_swap(value[0]) * 3600 + _bcd_swap(value[1]) * 60
+ _bcd_swap(value[2]))
if secs > 0:
_timer_start(timer_id, secs)
return base + bytes([0x03, 0x01, 0x00])
remaining = _timer_remaining(timer_id)
if remaining is None:
return base + bytes([0x03, 0x01, 0x24])
if action == 0x01:
_timer_cancel(timer_id)
return (base + bytes([0xA4, 0x01, timer_id & 0xFF, 0xA5, 0x03])
+ _hms_bcd(remaining) + bytes([0x03, 0x01, 0x00]))
def _decode_stk_text(raw):
try:
return _STK_DECODE._decode(raw, {}, 'stk')
@@ -1543,9 +2100,12 @@ def _parse_proactive_header(raw):
while off < len(raw) - 1:
tag, tlen = raw[off], raw[off + 1]
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
if tag == 0x81 and tlen >= 3:
# Cards use both the plain (01/02) and comprehension-required
# (81/82) tag variants - TS 101 220 7.1.1 leaves the CR flag to
# the application, and the reference cards switch between them.
if tag in (0x01, 0x81) and tlen >= 3:
cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2]
elif tag == 0x82 and tlen >= 2:
elif tag in (0x02, 0x82) and tlen >= 2:
dev_src, dev_dst = val[0], val[1]
return cmd_num, cmd_type, dev_src, dev_dst, cmd_qual
@@ -1682,8 +2242,20 @@ def _parse_setup_menu_items(raw):
def _handle_proactive_chain(scc, sw91, on_fetch=None):
"""Run a FETCH/TERMINAL RESPONSE chain; marks the card as busy so that
terminal-initiated ENVELOPEs (Data available, Channel status, timers) wait."""
global _PROACTIVE_BUSY
_PROACTIVE_BUSY = True
try:
return _run_proactive_chain(scc, sw91, on_fetch)
finally:
_PROACTIVE_BUSY = False
def _run_proactive_chain(scc, sw91, on_fetch=None):
sys.stderr.write('91XX chain: sw=%s\n' % sw91)
sw = sw91
paused = False
while sw.startswith('91'):
fetch_len = int(sw[2:], 16) if len(sw) == 4 else 0x100
rv = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla, fetch_len))
@@ -1699,10 +2271,13 @@ def _handle_proactive_chain(scc, sw91, on_fetch=None):
entry = None
if on_fetch:
action = on_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst)
if action != 'pause':
paused = action == 'pause'
if not paused:
tr_tlv = None
if raw and cmd_type in (0x40, 0x41, 0x42, 0x43, 0x44):
tr_tlv = _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
if cmd_type == 0x27:
tr_tlv = _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
if tr_tlv is None:
tr_tlv = _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual)
tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr_tlv), tr_tlv.hex()))
@@ -1716,7 +2291,12 @@ def _handle_proactive_chain(scc, sw91, on_fetch=None):
if st_sw.startswith('91'):
sw = st_sw
if action == 'exit':
_bip_flush_channel_events(scc)
return sw
if not paused:
# Never inject an ENVELOPE while a fetched command awaits its
# TERMINAL RESPONSE (the menu browser answers it later).
_bip_flush_channel_events(scc)
def _send_terminal_profile(scc, tp_hex):
@@ -2080,6 +2660,13 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = {'seq': _BIP.seq, 'entries': _BIP.entries_after(after)[-200:]}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/scp81/script':
self._log_req()
resp = {'script': _SCP81_SCRIPT, 'sent': _SCP81_SCRIPT_SENT,
'template': _SCP81_SCRIPT_TEMPLATE, 'cr_tag': _SCP81_SCRIPT_CR_TAG,
'results': _SCP81_SCRIPT_RESULTS}
self._send_json(resp)
self._log_resp(resp)
elif self.path.startswith('/api/'):
self._send_json({'error': _err('not_found', lang)}, 404)
self._log_resp({'error': _err('not_found', lang)})
@@ -2861,7 +3448,8 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp(err)
elif self.path == '/api/scp81/bip':
body = self._read_body()
self._log_req(body)
# Never log the pre-shared key.
self._log_req(dict(body, psk_hex='<redacted>') if isinstance(body, dict) and body.get('psk_hex') else body)
try:
resp = _scp81_bip_control(body)
except Exception as e:
+194
View File
@@ -10,8 +10,10 @@ import socket
import sys
import threading
import time
import types
import unittest
from pathlib import Path
from unittest import mock
PROJECTS = Path(__file__).resolve().parents[2]
PY_SIM = PROJECTS / 'pysim'
@@ -156,6 +158,52 @@ class BipTerminalTest(unittest.TestCase):
self.assertIsNone(cid)
self.assertIn('disabled', err)
def test_peer_close_queues_channel_status_event(self):
# TS 102 223 7.5.11: a link lost outside a proactive command must be
# reported to the UICC (channel id, link not established, info 05).
srv = socket.socket()
srv.bind(('127.0.0.1', 0))
srv.listen(1)
try:
bip = httpota.BipTerminal()
bip.enable('127.0.0.1', srv.getsockname()[1])
cid, err = bip.open('10.9.9.9', 1234, 512)
self.assertIsNone(err)
conn, _ = srv.accept()
conn.close()
events = []
for _ in range(40):
bip.receive(cid, 16)
events = bip.take_pending_events()
if events:
break
time.sleep(0.05)
self.assertEqual(events, [{'channel': cid, 'status': cid, 'info': 0x05}])
finally:
srv.close()
def test_channel_status_queued_once_per_channel(self):
bip = httpota.BipTerminal()
bip._queue_link_status(3)
bip._queue_link_status(3)
self.assertEqual(bip.take_pending_events(),
[{'channel': 3, 'status': 3, 'info': 0x05}])
self.assertEqual(bip.take_pending_events(), [])
def test_proactive_close_does_not_queue_status(self):
# A CLOSE CHANNEL proactive command is not an autonomous link change.
peer = PeerServer()
peer.start()
try:
bip = httpota.BipTerminal()
bip.enable('127.0.0.1', peer.port)
cid, err = bip.open('10.9.9.9', 1234, 512)
self.assertIsNone(err)
self.assertTrue(bip.close(cid))
self.assertEqual(bip.take_pending_events(), [])
finally:
peer.stop()
def test_dump_server_logs_received_bytes(self):
received = []
dump = httpota.TcpDumpServer('127.0.0.1', 0, on_rx=lambda peer, data: received.append(data))
@@ -229,6 +277,152 @@ class BipCommandFlowTest(unittest.TestCase):
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03].hex(), '3a00')
def test_open_channel_cr_set_tlvs(self):
# Live card 2026-09-15: the fallback OPEN CHANNEL uses the CR-set tag
# variants (B5/B9/C7/BC/BE) - the handler must find them too.
raw = bytes.fromhex('d0248103014003820281828500b50103b902058e'
'c70403475042bc03020582be05215bd50502')
tr = server._handle_bip_command(None, 1, 0x40, 0x03, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs) # Channel status
self.assertIn(0x39, tlvs) # Buffer size echo
def test_open_channel_plain_tlvs(self):
# Same command with the plain tag variants (reference phone traces).
raw = bytes.fromhex('d02401030140030202818205003501033902058e'
'4704034750423c030205823e05215bd50502')
tr = server._handle_bip_command(None, 1, 0x40, 0x03, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs)
def test_open_channel_truncated_destination_accepted(self):
# Live card 2026-09-15: '3e 05' with no value (empty buffer quirk,
# same family as the reference openchannel_not_understood_no_apn
# trace). The emulation is permissive and opens the configured target.
raw = bytes.fromhex('d01c810301400c82028182850035010339020200'
'4701003c030227be3e05')
tr = server._handle_bip_command(None, 1, 0x40, 0x0C, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs)
kinds = [(e['kind'], e.get('note')) for e in self.bip.entries_after(0)]
self.assertIn(('open-relaxed', 'destination/transport not fully specified'), kinds)
def test_open_channel_without_transport_accepted(self):
# No transport level at all (bearer-level channel): still accepted.
raw = bytes.fromhex('d00d81030140018202818239020200')
tr = server._handle_bip_command(None, 1, 0x40, 0x01, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs)
kinds = [e['kind'] for e in self.bip.entries_after(0)]
self.assertIn('open-relaxed', kinds)
def test_background_open_queues_link_established(self):
# Qualifier 0x04 (background mode): the terminal must report the
# established link via ENVELOPE (Channel status) - 7.5.11.
raw = bytes.fromhex('d01c810301400c82028182850035010339020200'
'4701003c030227be3e05')
server._handle_bip_command(None, 1, 0x40, 0x0C, raw, 0x81, 0x82)
events = self.bip.take_pending_events()
self.assertEqual(len(events), 1)
self.assertEqual(events[0]['info'], 0x00)
self.assertTrue(events[0]['status'] & 0x80)
def test_flush_channel_events_when_subscribed(self):
sent = []
class Tp:
def send_apdu(self, apdu):
sent.append(apdu)
return '', '9000'
scc = types.SimpleNamespace(cat_cla='80', _tp=Tp())
self.bip._queue_link_status(2)
ref = types.SimpleNamespace(event_list=[0x09, 0x0A])
with mock.patch.object(server, '_server_ref', ref):
server._bip_flush_channel_events(scc)
# D6: event list (ch status), device ids, Channel status B8 02 02 05
# (channel 2, link not established, info 05 = link dropped)
self.assertEqual(sent, ['80c200000dd60b99010a82028281b8020205'])
self.assertEqual(self.bip.take_pending_events(), [])
def test_flush_skipped_without_subscription(self):
sent = []
class Tp:
def send_apdu(self, apdu):
sent.append(apdu)
return '', '9000'
scc = types.SimpleNamespace(cat_cla='80', _tp=Tp())
self.bip._queue_link_status(1)
ref = types.SimpleNamespace(event_list=[0x09])
with mock.patch.object(server, '_server_ref', ref):
server._bip_flush_channel_events(scc)
self.assertEqual(sent, [])
# Not subscribed: the event stays queued for a later card session.
self.assertEqual(self.bip.take_pending_events(),
[{'channel': 1, 'status': 1, 'info': 0x05}])
if __name__ == '__main__':
unittest.main()
def test_peer_close_reported_after_buffer_drained(self):
# A dropped link must not be signalled while server data still waits
# to be fetched: the card would abort the fetch mid-record. Drain
# first, then report.
srv = socket.socket()
srv.bind(('127.0.0.1', 0))
srv.listen(1)
try:
bip = httpota.BipTerminal()
bip.enable('127.0.0.1', srv.getsockname()[1])
cid, err = bip.open('10.9.9.9', 1234, 512)
self.assertIsNone(err)
conn, _ = srv.accept()
conn.sendall(b'response-bytes')
conn.close()
ch = bip.channels[cid]
for _ in range(40):
ch.pump()
if ch.rx and ch.peer_closed:
break
time.sleep(0.05)
self.assertTrue(ch.rx)
self.assertTrue(ch.peer_closed)
# Partial fetch: the link-dropped event must still be withheld.
bip.receive(cid, 5)
self.assertEqual(bip.take_pending_events(), [])
# Remaining bytes fetched: the event is reported now.
bip.receive(cid, 64)
self.assertEqual(bip.take_pending_events(),
[{'channel': cid, 'status': cid, 'info': 0x05}])
bip.close(cid)
finally:
srv.close()
def test_receive_data_tlv_long_form_length(self):
# A >127-byte channel data TLV must use the BER long form (0x81 len),
# as the reference terminal traces do (`36 81 ed` for 237 bytes).
import types
server = __import__('pysim_otaman_server.server', fromlist=['x'])
big = bytes(range(256)) * 1 # 256 bytes; take a slice below
ch = types.SimpleNamespace(rx=bytearray(b'\xAA' * 237))
class FakeBip:
def __init__(self): self.channels = {1: ch}
def receive(self, cid, n):
data = bytes(ch.rx[:min(n, len(ch.rx))]); del ch.rx[:len(data)]; return data
def available(self, cid): return len(ch.rx)
def log(self, *a, **k): pass
old = server._BIP
server._BIP = FakeBip()
try:
raw = bytes.fromhex('d00c8103014200820281213701ed')
tr = server._handle_bip_command(None, 1, 0x42, 0, raw, None, 0x21)
self.assertIn(b'\x36\x81\xed' + b'\xAA' * 237, tr)
finally:
server._BIP = old
+191
View File
@@ -7,6 +7,7 @@ No live/sample card keys and no ICCIDs appear here.
"""
import sys
import types
import unittest
from pathlib import Path
from unittest import mock
@@ -268,6 +269,80 @@ class TestProactiveDecode(unittest.TestCase):
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_timer_management_start(self):
# TS 102 223 6.6.21/8.37/8.38: start timer 3 for 14:07:32
raw = bytes.fromhex('d011810301270082028182a40103a503417023')
self.assertEqual(_decode_cmd(0x27, raw, 0x00), [
{'label': 'Action', 'value': 'Start'},
{'label': 'Timer', 'value': '3'},
{'label': 'Value', 'value': '14:07:32'},
])
def test_decode_cmd_timer_management_plain_tags(self):
# Cards may use the plain (non comprehension-required) tag variant.
raw = bytes.fromhex('d00c010301270102028182240103')
self.assertEqual(_decode_cmd(0x27, raw, 0x01), [
{'label': 'Action', 'value': 'Deactivate'},
{'label': 'Timer', 'value': '3'},
])
def test_decode_cmd_open_channel_cr_tags(self):
# Same OPEN CHANNEL as the reference traces, but with CR-set TLVs.
raw = bytes.fromhex(
'd02b8103014001820281828500b50103b9020200c70b076d656761666f6e2e7275'
'bc03021f90be05217f000001')
r = _decode_cmd(0x40, raw, 0x01)
self.assertIn({'label': 'Bearer', 'value': '0x03'}, r)
self.assertIn({'label': 'Buffer size', 'value': '512'}, r)
self.assertIn({'label': 'APN', 'value': 'megafon.ru'}, r)
self.assertIn({'label': 'Destination', 'value': '127.0.0.1'}, r)
self.assertIn({'label': 'Transport', 'value': 'TCP client port 8080'}, r)
def test_decode_cmd_bip_channel_from_device_ids(self):
# Real trace: SEND DATA carries the channel in the device identities
# (source UICC 0x81, destination Channel 1 0x21).
raw = bytes.fromhex('d00e8103014301820281213701013603aabbcc')
r = _decode_cmd(0x43, raw, 0x01)
self.assertEqual(r[0], {'label': 'Channel', 'value': '1'})
self.assertEqual(r[1], {'label': 'Data bytes', 'value': '3'})
def test_parse_proactive_header_plain_tags(self):
import pysim_otaman_server.server as srv
raw = bytes.fromhex('d00c010301270102028182240103')
self.assertEqual(srv._parse_proactive_header(raw), (1, 0x27, 0x81, 0x82, 0x01))
def test_default_handler_logs_timer_management(self):
# pySim's auto-handler path: the parsed command object (not the empty
# collection) is re-encoded for the log and used for the response.
import pysim_otaman_server.server as srv
from pySim.cat import ProactiveCommand
from pySim.utils import h2b
srv._PROACTIVE_LOG.clear()
handler = srv._DefaultProactiveHandler()
pcmd = ProactiveCommand()
parsed = pcmd.from_tlv(h2b('d011810301270082028182a40103a503417023'))
ti = handler.receive_fetch_raw(pcmd, parsed)
tr = b''.join(x.to_tlv() for x in ti).hex()
self.assertTrue(tr.startswith('810301270082028281830100'), tr)
entry = srv._PROACTIVE_LOG[-1]
self.assertEqual(entry['type_hex'], '27')
self.assertEqual(entry['type_name'], 'TIMER MANAGEMENT')
self.assertEqual(entry['tr_result'], '00')
def test_default_handler_pli_includes_dict_data(self):
import pysim_otaman_server.server as srv
from pySim.cat import ProactiveCommand
from pySim.utils import h2b
srv._PROACTIVE_LOG.clear()
srv._PLI_DATA[0x00] = '93055210011000'
handler = srv._DefaultProactiveHandler()
pcmd = ProactiveCommand()
parsed = pcmd.from_tlv(h2b('d00d810301260082028182'))
ti = handler.receive_fetch_raw(pcmd, parsed)
tr = b''.join(x.to_tlv() for x in ti).hex()
self.assertIn('93055210011000', tr)
self.assertEqual(srv._PROACTIVE_LOG[-1]['tr_hex'], '93055210011000')
def test_decode_cmd_empty_raw(self):
self.assertEqual(_decode_cmd(0x26, b'', None), [])
self.assertEqual(_decode_cmd(0x03, None, None), [])
@@ -360,6 +435,122 @@ class TestProactiveDecode(unittest.TestCase):
self.assertNotIn('tr_result', entry)
class TestEventDownload(unittest.TestCase):
"""ENVELOPE (EVENT DOWNLOAD) assembly, TS 102 223 7.5.11."""
def _send(self, event_type, event_data):
import pysim_otaman_server.server as srv
calls = []
class Tp:
def send_apdu(self, apdu):
calls.append(apdu)
return '', '9000'
class Scc:
cat_cla = '80'
_tp = Tp()
data, sw = srv._send_event_download(Scc(), event_type, event_data)
return calls[0], sw
def test_channel_status_event(self):
# Event list + device identities + Channel status (8.56): channel 2,
# link established, info 05 = link dropped.
apdu, sw = self._send(0x0A, bytes.fromhex('b8028205'))
self.assertEqual(sw, '9000')
self.assertEqual(apdu, '80c200000dd60b99010a82028281b8028205')
def test_event_without_data(self):
apdu, sw = self._send(0x05, None)
self.assertEqual(sw, '9000')
self.assertEqual(apdu, '80c2000009d60799010582028281')
class TestTimerManagement(unittest.TestCase):
"""Terminal side of TIMER MANAGEMENT (TS 102 223 6.6.21, 6.8.13/14, 7.4).
The start vector is the live card's: timer 1, 60 s."""
START = bytes.fromhex('d011810301270082028182a40101a503001000')
def tearDown(self):
import pysim_otaman_server.server as srv
srv._timer_cancel()
def test_hms_bcd_roundtrip(self):
import pysim_otaman_server.server as srv
self.assertEqual(srv._hms_bcd(60).hex(), '001000')
self.assertEqual(srv._hms_bcd(3723).hex(), '102030')
self.assertEqual([srv._bcd_swap(b) for b in srv._hms_bcd(3723)], [1, 2, 3])
def test_start_returns_result_only_and_arms_timer(self):
import pysim_otaman_server.server as srv
tr = srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
self.assertEqual(tr.hex(), '810301270082028281030100')
remaining = srv._timer_remaining(1)
self.assertTrue(55 <= remaining <= 60, remaining)
def test_get_returns_remaining_value(self):
import pysim_otaman_server.server as srv
srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
tr = srv._handle_timer_command(1, 0x27, 0x02, self.START, 0x81, 0x82)
self.assertEqual(tr.hex(), '810301270282028281a40101a503001000030100')
def test_deactivate_stops_and_reports_value(self):
import pysim_otaman_server.server as srv
srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
tr = srv._handle_timer_command(1, 0x27, 0x01, self.START, 0x81, 0x82)
self.assertTrue(tr.hex().startswith('8103012701'), tr.hex())
self.assertIn('a40101a503001000', tr.hex())
self.assertIsNone(srv._timer_remaining(1))
def test_get_on_stopped_timer_is_contradiction(self):
import pysim_otaman_server.server as srv
tr = srv._handle_timer_command(1, 0x27, 0x02, self.START, 0x81, 0x82)
self.assertEqual(tr.hex(), '810301270282028281030124')
def test_timer_expiration_envelope(self):
import pysim_otaman_server.server as srv
calls = []
class Tp:
def send_apdu(self, apdu):
calls.append(apdu)
return '', '9000'
ref = types.SimpleNamespace(
scc=types.SimpleNamespace(cat_cla='80', _tp=Tp()), stk_pending=None)
with mock.patch.object(srv, '_server_ref', ref):
with mock.patch.object(srv, '_CARD_CONNECTED', True):
srv._timer_expired(1, 60)
# D7 0C: device identities (terminal -> UICC), Timer id A4, value A5
self.assertEqual(calls, ['80c200000ed70c82028281a40101a503001000'])
def test_cancelled_timer_does_not_report(self):
import pysim_otaman_server.server as srv
calls = []
class Tp:
def send_apdu(self, apdu):
calls.append(apdu)
return '', '9000'
ref = types.SimpleNamespace(
scc=types.SimpleNamespace(cat_cla='80', _tp=Tp()), stk_pending=None)
with mock.patch.object(srv, '_server_ref', ref):
with mock.patch.object(srv, '_CARD_CONNECTED', True):
srv._timer_fire(1, 60) # never started/cancelled
self.assertEqual(calls, [])
def test_decode_tr_timer(self):
tr = bytes.fromhex('810301270082028281a40101a503001000030100')
data = _tr_data_only(tr).hex()
r = _decode_tr('27', '00', data)
self.assertEqual(r, [{'label': 'Timer', 'value': '1'},
{'label': 'Remaining', 'value': '00:01:00'}])
class TestExpandedRemoteResponse(unittest.TestCase):
"""Expanded Remote Response parsing (TS 102 226 §5.2.2)."""
+681
View File
@@ -0,0 +1,681 @@
#!/usr/bin/env python3
"""Phase B tests: PSK TLS server, GP HTTP administration session, and the
terminal's Data available notification (TS 102 223 7.5.10).
Synthetic PSK only; no live card data.
"""
import socket
import ssl
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))
from pysim_otaman_server import scp81
import pysim_otaman_server.server as server
PSK = bytes.fromhex('00112233445566778899aabbccddeeff')
IDENT = '89012345678901234567'
class HttpParseTest(unittest.TestCase):
def test_parse_request(self):
raw = (b'POST /server/adminagent?cmd=1 HTTP/1.1\r\n'
b'Host: 172.96.0.1\r\n'
b'X-Admin-Protocol: globalplatform-remote-admin/1.0\r\n'
b'X-Admin-From: 0123456789\r\n\r\n')
method, target, headers = scp81.parse_http_request(raw)
self.assertEqual(method, 'POST')
self.assertEqual(target, '/server/adminagent?cmd=1')
self.assertEqual(headers['host'], '172.96.0.1')
self.assertEqual(headers['x-admin-from'], '0123456789')
def test_parse_request_malformed(self):
with self.assertRaises(ValueError):
scp81.parse_http_request(b'GARBAGE\r\n\r\n')
def test_decode_chunked(self):
body = b'4\r\nABCD\r\n5\r\nEFGHI\r\n0\r\n\r\n'
self.assertEqual(scp81.decode_chunked(body), b'ABCDEFGHI')
def test_build_response_sets_content_length(self):
out = scp81.build_http_response(200, 'OK',
{'Content-Type': scp81.GP_CT_COMMAND}, b'\x80\x01\x00')
self.assertTrue(out.startswith(b'HTTP/1.1 200 OK\r\n'))
self.assertIn(b'Content-Length: 3\r\n\r\n\x80\x01\x00', out)
def test_build_response_connection_header(self):
out = scp81.build_http_response(200, 'OK', {}, b'\x01',
connection='close')
self.assertIn(b'Connection: close\r\n', out)
out = scp81.build_http_response(200, 'OK', {}, b'\x01',
compact=True, connection='keep-alive')
self.assertIn(b'Connection:keep-alive\r\n', out)
def test_build_response_chunked(self):
out = scp81.build_http_response(200, 'OK', {},
b'\x80' * 150, chunked=True)
self.assertIn(b'Transfer-Encoding: chunked', out)
self.assertNotIn(b'Content-Length', out)
head, _, body = out.partition(b'\r\n\r\n')
self.assertEqual(scp81.decode_chunked(body), b'\x80' * 150)
# 100-byte chunks like the reference admin server
self.assertTrue(body.startswith(b'64\r\n'))
class PskTlsServerTest(unittest.TestCase):
def _client_ctx(self, identity=IDENT, psk=PSK):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
ctx.set_ciphers('PSK-AES128-CBC-SHA256:PSK-AES128-CBC-SHA')
ctx.set_psk_client_callback(lambda hint: (identity.encode(), psk))
return ctx
def _connect(self, srv, ctx=None):
sock = socket.create_connection(('127.0.0.1', srv.port), timeout=5)
try:
return (ctx or self._client_ctx()).wrap_socket(sock)
except Exception:
sock.close()
raise
def test_handshake_and_204_session(self):
logs = []
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, identity=IDENT,
on_log=lambda k, **f: logs.append((k, f)))
try:
tls = self._connect(srv)
self.assertEqual(tls.version(), 'TLSv1.2')
self.assertEqual(tls.cipher()[0], 'PSK-AES128-CBC-SHA256')
tls.sendall(b'POST /server/adminagent?cmd=1 HTTP/1.1\r\n'
b'Host: 172.96.0.1\r\n'
b'X-Admin-Protocol: globalplatform-remote-admin/1.0\r\n'
b'X-Admin-From: 0123456789\r\n\r\n')
reply = tls.recv(4096)
self.assertTrue(reply.startswith(b'HTTP/1.1 204 No Content\r\n'))
self.assertIn(b'X-Admin-Protocol: globalplatform-remote-admin/1.0', reply)
tls.close()
deadline = time.time() + 3
while time.time() < deadline and srv.identity_seen is None:
time.sleep(0.05)
self.assertEqual(srv.identity_seen, IDENT)
kinds = [k for k, _ in logs]
self.assertIn('tls-handshake', kinds)
self.assertIn('tls-request', kinds)
self.assertIn('tls-response', kinds)
req = [f for k, f in logs if k == 'tls-request'][0]
self.assertEqual(req['uri'], '/server/adminagent?cmd=1')
self.assertEqual(req['agent'], '0123456789')
finally:
srv.stop()
def _recv(self, tls):
try:
return tls.recv(4096)
except (ssl.SSLError, OSError):
return b''
def _read_http(self, tls):
buf = b''
while b'\r\n\r\n' not in buf:
chunk = self._recv(tls)
if not chunk:
return buf
buf += chunk
head, _, rest = buf.partition(b'\r\n\r\n')
if b'transfer-encoding: chunked' in head.lower():
while b'0\r\n\r\n' not in rest:
chunk = self._recv(tls)
if not chunk:
break
rest += chunk
return head + b'\r\n\r\n' + rest
length = 0
for line in head.split(b'\r\n'):
if line.lower().startswith(b'content-length:'):
length = int(line.split(b':')[1])
while len(rest) < length:
chunk = self._recv(tls)
if not chunk:
break
rest += chunk
return head + b'\r\n\r\n' + rest
def test_scripted_session_over_tls(self):
old_bip = server._BIP
server._BIP = mock.Mock()
server._BIP.log = lambda *a, **k: None
server._SCP81_SCRIPT = ['80CAFF2100']
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK,
responder=server._scp81_script_responder,
keep_alive=True)
try:
tls = self._connect(srv)
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\nHost: 127.0.0.1\r\n'
b'X-Admin-Protocol: globalplatform-remote-admin/1.0\r\n'
b'X-Admin-From: otaman\r\n\r\n')
reply = self._read_http(tls)
self.assertIn(b'HTTP/1.1 200 OK', reply)
self.assertIn(b'X-Admin-Next-URI: /api/scp81?req=1', reply)
self.assertIn(bytes.fromhex('ae80220580caff21000000'), reply)
# Respond with the R-APDU (Response Scripting template)
body = bytes.fromhex('af80' '800101' '2304' '93059000' '0000')
tls.sendall(b'POST /api/scp81?step=1 HTTP/1.1\r\n'
b'X-Admin-Script-Status: ok\r\n'
b'Content-Length: %d\r\n\r\n' % len(body) + body)
reply = self._read_http(tls)
self.assertIn(b'HTTP/1.1 204 No Content', reply)
self.assertEqual(server._SCP81_SCRIPT_RESULTS[0]['sw'], '9000')
tls.close()
finally:
server._BIP = old_bip
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
srv.stop()
def test_response_closes_connection_without_keep_alive(self):
# Default (keep_alive=False): the card's HTTP client seems to delimit
# the response at connection close, so the server closes after each
# response and the card starts a fresh session for its next POST.
def responder(method, target, headers, body):
return 200, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b'\x80\x01\x00'
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder)
try:
tls = self._connect(srv)
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\n\r\n')
reply = self._read_http(tls)
self.assertTrue(reply.startswith(b'HTTP/1.1 200 OK'))
self.assertEqual(self._recv(tls), b'') # server closed
tls.close()
finally:
srv.stop()
def test_close_waits_for_drain_callback(self):
# With keep_alive=False and a body, the listener calls on_before_close
# (the server waits for the card to drain the BIP buffer) before
# closing the connection.
seen = []
def responder(method, target, headers, body):
return 200, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b'\x80\x01\x00'
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder,
on_before_close=lambda peer: seen.append(peer))
try:
tls = self._connect(srv)
client_port = tls.getsockname()[1]
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\n\r\n')
reply = self._read_http(tls)
self.assertTrue(reply.startswith(b'HTTP/1.1 200 OK'))
self.assertIn(b'Connection: close', reply)
# The server must send close_notify (clean TLS shutdown) before
# closing: unwrap() succeeds only when the peer's close_notify
# has been received.
tls.settimeout(3.0)
plain = tls.unwrap()
# The close comes after the drain callback: EOF proves it ran.
self.assertEqual(plain.recv(1), b'')
self.assertEqual(len(seen), 1)
self.assertEqual(seen[0][1], client_port)
plain.close()
finally:
srv.stop()
def test_wrong_identity_rejected(self):
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, identity=IDENT)
try:
with self.assertRaises(ssl.SSLError):
self._connect(srv, self._client_ctx(identity='unknown-id'))
finally:
srv.stop()
def test_server_hello_omits_encrypt_then_mac(self):
# The live card offers encrypt_then_mac but aborts with
# SSLV3_ALERT_UNEXPECTED_MESSAGE when the server echoes it.
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK)
try:
sock = socket.create_connection(('127.0.0.1', srv.port), timeout=5)
hello = bytes.fromhex(
'1603010038' '01000034' '0303' + '11' * 32 + '00'
'0002' '00ae' '0100'
'0009' '0001000101' '00160000')
sock.sendall(hello)
data = sock.recv(4096)
sock.close()
idx = data.find(b'\x00\xae')
self.assertGreater(idx, 0, data.hex())
ext_total = int.from_bytes(data[idx + 3:idx + 5], 'big')
ext = data[idx + 5:idx + 5 + ext_total]
seen = set()
off = 0
while off + 4 <= len(ext):
etype = int.from_bytes(ext[off:off + 2], 'big')
elen = int.from_bytes(ext[off + 2:off + 4], 'big')
seen.add(etype)
off += 4 + elen
self.assertNotIn(0x0016, seen) # encrypt_then_mac
self.assertNotIn(0x0023, seen) # session_ticket (no resumption)
finally:
srv.stop()
def test_command_then_close(self):
def responder(method, target, headers, body):
if b'cmd=1' in target.encode():
return (200, {'X-Admin-Protocol': scp81.GP_PROTOCOL,
'X-Admin-Next-URI': '/server/adminagent?cmd=2',
'Content-Type': scp81.GP_CT_COMMAND}, b'\x80\x01\x00')
return 204, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b''
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder,
keep_alive=True)
try:
tls = self._connect(srv)
tls.sendall(b'POST /server/adminagent?cmd=1 HTTP/1.1\r\n\r\n')
reply = self._read_http(tls)
self.assertIn(b'X-Admin-Next-URI: /server/adminagent?cmd=2', reply)
head, _, body = reply.partition(b'\r\n\r\n')
self.assertIn(b'Content-Length: 3', head)
self.assertEqual(body[:3], b'\x80\x01\x00')
tls.sendall(b'POST /server/adminagent?cmd=2 HTTP/1.1\r\n'
b'X-Admin-Script-Status: ok\r\n'
b'Content-Length: 3\r\n\r\n\x80\x02\x00')
reply = self._read_http(tls)
self.assertTrue(reply.startswith(b'HTTP/1.1 204 No Content'))
tls.close()
finally:
srv.stop()
class ScriptResponderTest(unittest.TestCase):
"""RAM over HTTP command scripting (TS 102 226 5.2, GP 4.4.2)."""
def setUp(self):
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
def tearDown(self):
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
def test_command_body_is_indefinite_scripting_template(self):
# Reference admin server: AE 80 22 <len> <apdu> 00 00
body = server._scp81_command_body('80CAFF2100')
self.assertEqual(body.hex(), 'ae80220580caff210000' + '00')
self.assertEqual(body[0:2], b'\xae\x80')
self.assertEqual(body[2], 0x22)
self.assertEqual(body[3], 5)
def test_command_body_definite_scripting_template(self):
# AA <len> 22 <len> <apdu>
body = server._scp81_command_body('80CAFF2100', definite=True)
self.assertEqual(body.hex(), 'aa072205' + '80caff2100')
def test_command_body_cr_set_c_apdu_tag(self):
# Some cards use the comprehension-required tag variant (A2)
self.assertEqual(server._scp81_command_body('80CA004500', definite=True, cr_tag=True).hex(),
'aa07a20580ca004500')
self.assertEqual(server._scp81_command_body('80CA004500', cr_tag=True).hex(),
'ae80a20580ca004500' + '0000')
def test_parse_response_indefinite(self):
# AF 80 (80 01 01) (23 04 93 05 90 00) 00 00
body = bytes.fromhex('af80' '800101' '2304' '9305' '9000' '0000')
count, rapdus = server._scp81_parse_response(body)
self.assertEqual(count, 1)
self.assertEqual(rapdus, [(bytes.fromhex('9305'), '9000')])
def test_parse_response_definite(self):
body = bytes.fromhex('ab08' '800101' '2303' '019000')
count, rapdus = server._scp81_parse_response(body)
self.assertEqual(count, 1)
self.assertEqual(rapdus, [(bytes.fromhex('01'), '9000')])
def test_decode_memory(self):
rapdu = bytes.fromhex('ff210c' '810102' '8203' '00f0a0' '8302' '0800')
self.assertEqual(server._scp81_decode_memory(rapdu),
{'applets': 2, 'free_nv': 0xf0a0, 'free_volatile': 0x0800})
self.assertIsNone(server._scp81_decode_memory(bytes.fromhex('9000')))
def test_responder_sends_script_then_204(self):
# Use an explicit two-command script, independent of the presets.
server._SCP81_SCRIPT = ['80CAFF2100', '80F22002024F0000']
logs = []
old_bip = server._BIP
server._BIP = mock.Mock()
server._BIP.log = lambda kind, **f: logs.append((kind, f))
try:
# First POST: no script status -> first APDU + Next-URI
status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'')
self.assertEqual(status, 200)
self.assertEqual(headers['X-Admin-Next-URI'], '/api/scp81?req=1')
self.assertEqual(headers['Content-Type'],
'application/vnd.globalplatform.card-content-mgt;version=1.0')
self.assertEqual(body.hex(), 'ae80220580caff21000000')
# Response to it -> second APDU
status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81?step=1', {'x-admin-script-status': 'ok'},
bytes.fromhex('af80' '800101' '2304' '93059000' '0000'))
self.assertEqual(status, 200)
self.assertEqual(body.hex(), 'ae802208' + '80f22002024f0000' + '0000')
# Last response -> session closed with 204
status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81?step=2', {'x-admin-script-status': 'ok'},
bytes.fromhex('af80' '800101' '2303' '5f9000' '0000'))
self.assertEqual(status, 204)
kinds = [k for k, _ in logs]
self.assertIn('script-send', kinds)
self.assertIn('script-rapdu', kinds)
self.assertIn('script-done', kinds)
self.assertEqual(len(server._SCP81_SCRIPT_RESULTS), 2)
finally:
server._BIP = old_bip
def test_responder_reports_script_failure(self):
old_bip = server._BIP
server._BIP = mock.Mock()
logs = []
server._BIP.log = lambda kind, **f: logs.append((kind, f))
try:
server._scp81_script_responder('POST', '/x', {}, b'')
status, headers, body = server._scp81_script_responder(
'POST', '/x', {'x-admin-script-status': 'security-error'}, b'')
self.assertEqual(status, 200) # script continues with the next APDU
self.assertIn(('script-status', {'index': 1, 'status': 'security-error'}), logs)
finally:
server._BIP = old_bip
class BipControlTest(unittest.TestCase):
def tearDown(self):
server._scp81_bip_control({'action': 'stop'})
server._SCP81_PSK = {}
def test_tls_mode_requires_psk(self):
server._SCP81_PSK = {}
resp = server._scp81_bip_control({'action': 'start', 'mode': 'tls'})
self.assertFalse(resp['ok'])
self.assertIn('psk_hex', resp['error'])
def test_tls_mode_starts_and_reports_status(self):
resp = server._scp81_bip_control({'action': 'start', 'mode': 'tls',
'host': '127.0.0.1', 'port': 0,
'psk_hex': '0011 2233', 'psk_identity': 'id-1'})
self.assertTrue(resp['ok'], resp)
listener = resp['listener']
self.assertEqual(listener['mode'], 'tls')
self.assertEqual(listener['psk_identity'], 'id-1')
self.assertIsNone(listener['identity_seen'])
self.assertTrue(resp['bip']['enabled'])
# the key never leaves the server
self.assertNotIn('psk_hex', listener)
def test_unknown_mode_rejected(self):
resp = server._scp81_bip_control({'action': 'start', 'mode': 'nope'})
self.assertFalse(resp['ok'])
self.assertIn('unsupported mode', resp['error'])
class DataAvailableTest(unittest.TestCase):
def _channel(self, cid=1, rx=b'\x16\x03\x03'):
ch = types.SimpleNamespace(id=cid, rx=bytearray(rx), peer_closed=False)
return ch
def _state(self, event_list=(0x09,)):
sent = []
class Tp:
def send_apdu(self, apdu):
sent.append(apdu)
return '', '9000'
scc = types.SimpleNamespace(cat_cla='80', _tp=Tp())
ref = types.SimpleNamespace(scc=scc, event_list=list(event_list),
stk_pending=None)
return ref, sent
def test_data_available_event(self):
ref, sent = self._state()
with mock.patch.object(server, '_server_ref', ref):
with mock.patch.object(server, '_CARD_CONNECTED', True):
ok = server._bip_data_available(self._channel())
self.assertTrue(ok)
# ENVELOPE(Event Download - Data available): channel 1 established,
# 3 bytes waiting (B8 status + B7 length)
self.assertEqual(sent, ['80c2000010d60e99010982028281b8028100b70103'])
def test_skipped_without_subscription(self):
ref, sent = self._state(event_list=[0x03, 0x0A])
with mock.patch.object(server, '_server_ref', ref):
with mock.patch.object(server, '_CARD_CONNECTED', True):
ok = server._bip_data_available(self._channel())
self.assertFalse(ok)
self.assertEqual(sent, [])
def test_skipped_while_menu_pending(self):
ref, sent = self._state()
ref.stk_pending = {'type': 'select_item'}
with mock.patch.object(server, '_server_ref', ref):
with mock.patch.object(server, '_CARD_CONNECTED', True):
ok = server._bip_data_available(self._channel())
self.assertFalse(ok)
self.assertEqual(sent, [])
def test_monitor_notifies_once_per_arrival(self):
peer = socket.socket()
peer.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
peer.bind(('127.0.0.1', 0))
peer.listen(1)
bip = server.httpota.BipTerminal()
seen = []
bip.on_data = lambda ch: (seen.append((ch.id, len(ch.rx)))) or True
try:
bip.enable('127.0.0.1', peer.getsockname()[1])
cid, err = bip.open('10.0.0.1', 1, 512)
self.assertIsNone(err)
conn, _ = peer.accept()
conn.sendall(b'HELLO')
deadline = time.time() + 3
while time.time() < deadline and not seen:
time.sleep(0.05)
time.sleep(0.6) # several monitor ticks
self.assertEqual(seen, [(cid, 5)])
finally:
bip.disable()
peer.close()
def test_remaining_bytes_are_re_announced(self):
# The live card waits for a fresh Data available event for the bytes
# left after a partial RECEIVE DATA (announced in the TR length TLV).
peer = socket.socket()
peer.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
peer.bind(('127.0.0.1', 0))
peer.listen(1)
bip = server.httpota.BipTerminal()
seen = []
bip.on_data = lambda ch: (seen.append(len(ch.rx))) or True
try:
bip.enable('127.0.0.1', peer.getsockname()[1])
cid, err = bip.open('10.0.0.1', 1, 512)
self.assertIsNone(err)
conn, _ = peer.accept()
conn.sendall(b'0123456789')
deadline = time.time() + 3
while time.time() < deadline and not seen:
time.sleep(0.05)
self.assertEqual(seen, [10])
self.assertEqual(bip.receive(cid, 4), b'0123')
deadline = time.time() + 3
while time.time() < deadline and len(seen) < 2:
time.sleep(0.05)
self.assertEqual(seen[1], 6)
finally:
bip.disable()
peer.close()
if __name__ == '__main__':
unittest.main()
class WaitDrainedTest(unittest.TestCase):
def test_wait_drained_matches_channel_by_port(self):
ch = types.SimpleNamespace(rx=bytearray(), sock=types.SimpleNamespace(
getsockname=lambda: ('127.0.0.1', 40001)))
old = server._BIP
server._BIP = types.SimpleNamespace(channels={1: ch})
try:
# unknown port / gone channel -> immediate
self.assertIsNone(server._scp81_wait_drained(('127.0.0.1', 40002)))
finally:
server._BIP = old
class WaitDrainedSlowTest(unittest.TestCase):
def test_wait_drained_waits_for_card_fetch(self):
import threading, time as _time
ch = types.SimpleNamespace(rx=bytearray(), sock=types.SimpleNamespace(
getsockname=lambda: ('127.0.0.1', 40003)))
def feed():
_time.sleep(0.15)
ch.rx.extend(b'response-bytes') # pump picks up the response
_time.sleep(0.25)
ch.rx.clear() # card fetches everything
old = server._BIP
server._BIP = types.SimpleNamespace(channels={1: ch})
th = threading.Thread(target=feed)
th.start()
t0 = _time.time()
try:
server._scp81_wait_drained(('127.0.0.1', 40003))
finally:
server._BIP = old
th.join()
self.assertGreater(_time.time() - t0, 0.3)
class KeylogTest(unittest.TestCase):
def test_keylog_filename_set(self):
import tempfile, os
fd, path = tempfile.mkstemp(prefix='scp81keys')
os.close(fd)
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, keylog=path)
try:
self.assertEqual(srv.ctx.keylog_filename, path)
finally:
srv.stop()
os.unlink(path)
class ConnHeaderTest(unittest.TestCase):
def test_conn_header_none_omits_connection(self):
import types
seen = {}
def responder(method, target, headers, body):
return 204, {}, b''
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder,
keep_alive=True, conn_header='none')
try:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.set_ciphers('PSK')
ctx.set_psk_client_callback(lambda hint: (b'id', PSK))
raw = socket.create_connection(('127.0.0.1', srv.port), timeout=5)
tls = ctx.wrap_socket(raw, server_hostname='x')
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\n\r\n')
data = tls.recv(4096)
self.assertNotIn(b'Connection:', data)
tls.close()
finally:
srv.stop()
class TargetedAppTest(unittest.TestCase):
def test_targeted_app_header(self):
server._SCP81_SCRIPT = ['80CAFF2100']
server._SCP81_SCRIPT_SENT = 0
server._SCP81_TARGETED_APP = '//aid/A000000151000000'
try:
status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'')
self.assertEqual(status, 200)
self.assertEqual(headers['X-Admin-Targeted-Application'],
'//aid/A000000151000000')
finally:
server._SCP81_TARGETED_APP = None
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
def test_apache_headers(self):
server._SCP81_SCRIPT = ['80CAFF2100']
server._SCP81_SCRIPT_SENT = 0
server._SCP81_APACHE_HEADERS = True
server._SCP81_CHUNKED = False
try:
status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'')
self.assertEqual(list(headers)[:4],
['Date', 'Server', 'X-Powered-By', 'X-Admin-Protocol'])
self.assertEqual(headers['Content-Length'], str(len(body)))
out = scp81.build_http_response(status, 'OK', headers, body)
self.assertLess(out.index(b'Content-Length'),
out.index(b'Content-Type'))
finally:
server._SCP81_APACHE_HEADERS = False
server._SCP81_CHUNKED = False
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
def test_chunked_apache_has_no_content_length(self):
# The reference (RAM/HTTPOTA_test5.pcap, decryptable) sends chunked
# without Content-Length, Transfer-Encoding before Content-Type.
server._SCP81_SCRIPT = ['80CAFF2100']
server._SCP81_SCRIPT_SENT = 0
server._SCP81_APACHE_HEADERS = True
server._SCP81_CHUNKED = True
try:
status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'')
self.assertNotIn('Content-Length', headers)
self.assertEqual(headers['Transfer-Encoding'], 'chunked')
self.assertLess(list(headers).index('Transfer-Encoding'),
list(headers).index('Content-Type'))
out = scp81.build_http_response(status, 'OK', headers, body,
chunked=True, connection=None)
self.assertNotIn(b'Content-Length', out)
self.assertEqual(out.count(b'Transfer-Encoding'), 1)
finally:
server._SCP81_APACHE_HEADERS = False
server._SCP81_CHUNKED = False
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
def test_exact_wire_bodies_from_reference_log(self):
# De-chunked bodies captured in adminserver.log (2019-09-05).
count, rapdus = server._scp81_parse_response(bytes.fromhex(
'af802319e3154f08a0000001510000009f70010fc5039afe80ea0090000000'))
self.assertEqual(len(rapdus), 1)
self.assertEqual(rapdus[0][1], '9000')
self.assertTrue(rapdus[0][0].startswith(b'\xe3\x15'))
count, rapdus = server._scp81_parse_response(
bytes.fromhex('af8023026a880000'))
self.assertEqual(rapdus[0][1], '6A88')
# A status-only POST (no body, e.g. unknown-application) parses empty.
self.assertEqual(server._scp81_parse_response(b''), (0, []))
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Decrypt the SCP81 PSK-TLS dialog from a server log + keylog.
The server's /api/scp81/log has every TLS record of the dialog (send = card,
receive = card fetch, i.e. the server stream), and the listener can write the
TLS secrets (SSLKEYLOGFILE) when started with a "keylog" path. With the
PSK-AES128-CBC-SHA256 dialog we can derive the record keys (TLS 1.2
PRF/master secret) and decrypt the card's alerts, which are otherwise opaque.
Usage: scp81_decrypt.py <log.json> <keys.log>
"""
import hashlib
import hmac
import json
import subprocess
import sys
def p_sha256(secret, seed, length):
out = b''
a = seed
while len(out) < length:
a = hmac.new(secret, a, hashlib.sha256).digest()
out += hmac.new(secret, a + seed, hashlib.sha256).digest()
return out[:length]
def aes_cbc_decrypt(key, iv, data):
p = subprocess.run(['openssl', 'enc', '-d', '-aes-128-cbc', '-nopad',
'-K', key.hex(), '-iv', iv.hex()],
input=data, capture_output=True)
if p.returncode != 0:
raise RuntimeError(p.stderr.decode())
return p.stdout
def record_payloads(stream):
"""Walk TLS records in a byte stream, yield (type, version, payload)."""
i = 0
while i + 5 <= len(stream):
rtype, ver, ln = stream[i], stream[i + 1:i + 3], int.from_bytes(stream[i + 3:i + 5], 'big')
body = stream[i + 5:i + 5 + ln]
if len(body) < ln:
break
yield rtype, ver, body
i += 5 + ln
def find_random(stream, hs_type):
"""Return the 32-byte random of a ClientHello/ServerHello in the stream."""
for rtype, ver, body in record_payloads(stream):
if rtype != 0x16 or not body or body[0] != hs_type:
continue
hslen = int.from_bytes(body[1:4], 'big')
hs = body[:4 + hslen]
return hs[6:38]
return None
def main():
log_path, keys_path = sys.argv[1], sys.argv[2]
entries = sorted(json.load(open(log_path))['entries'], key=lambda x: x['seq'])
# Only the last TLS session: start at the final OPEN CHANNEL.
start = 0
for i, e in enumerate(entries):
if e.get('kind') == 'open':
start = i
entries = entries[start:]
client = b''
server = b''
for e in entries:
if e.get('kind') == 'send' and e.get('hex'):
client += bytes.fromhex(e['hex'])
elif e.get('kind') == 'receive' and e.get('hex'):
server += bytes.fromhex(e['hex'])
crandom = find_random(client, 0x01)
srandom = find_random(server, 0x02)
print('client_random:', crandom.hex() if crandom else None)
print('server_random:', srandom.hex() if srandom else None)
if not crandom or not srandom:
sys.exit('handshake randoms not found in log')
master = None
for line in open(keys_path):
parts = line.split()
if parts and parts[0] == 'CLIENT_RANDOM' and parts[1] == crandom.hex():
master = bytes.fromhex(parts[2])
if not master:
sys.exit('master secret not found in keylog')
print('master_secret:', master.hex())
kb = p_sha256(master, b'key expansion' + srandom + crandom, 96)
client_mac, server_mac = kb[0:32], kb[32:64]
client_key, server_key = kb[64:80], kb[80:96]
print('client_key: %s server_key: %s' % (client_key.hex(), server_key.hex()))
names = {0x15: 'alert', 0x16: 'handshake', 0x17: 'appdata', 0x14: 'ccs'}
for who, stream, key in (('card', client, client_key),
('server', server, server_key)):
app_seq = 0
for rtype, ver, body in record_payloads(stream):
if rtype not in (0x15, 0x17) or len(body) < 16 + 32:
continue
iv, ct, mac = body[:16], body[16:-32], body[-32:]
try:
pt = aes_cbc_decrypt(key, iv, ct)
except RuntimeError as e:
print('%s seq%d %s: decrypt failed: %s' % (who, app_seq, names.get(rtype), e))
app_seq += 1
continue
# verify the record MAC (seq, type, version, len, plaintext)
h = hmac.new(client_mac if who == 'card' else server_mac,
app_seq.to_bytes(8, 'big') + bytes([rtype]) + ver +
len(pt).to_bytes(2, 'big') + pt, hashlib.sha256).digest()
mac_ok = hmac.compare_digest(h, mac)
desc = ''
if rtype == 0x15 and len(pt) >= 2:
level = {1: 'warning', 2: 'fatal'}.get(pt[0], str(pt[0]))
alerts = {0: 'close_notify', 10: 'unexpected_message',
20: 'bad_record_mac', 40: 'handshake_failure',
46: 'protocol_version', 47: 'illegal_parameter',
48: 'unknown_ca', 49: 'access_denied',
50: 'decode_error', 51: 'decrypt_error',
80: 'internal_error', 90: 'user_canceled',
100: 'no_renegotiation', 110: 'unsupported_extension',
112: 'unrecognized_name'}
desc = 'ALERT %s %s' % (level, alerts.get(pt[1], pt[1]))
print('%s seq%d %-9s mac_ok=%s pt=%s %s'
% (who, app_seq, names.get(rtype), mac_ok, pt[:48].hex(), desc))
app_seq += 1
if __name__ == '__main__':
main()