scp81: UI framing options, permissive TLS, drop the Apache-header mimicry (v2.2.14)

Listener Options block (applied at Start, persisted in localStorage, Reset to
defaults): HTTP framing - chunked body, chunk size (0 = one TLS record),
keep-alive, Connection header, compact headers, Next-URI (unchecked = omit);
script framing - indefinite/definite Command Scripting template, CR tag,
targeted app; link events (now parsed on the common start path, so every mode
honors them). scp81OptionsFromForm() is unit-tested.

TLS is automatic: 'auto' (min 1.0, max 1.2 + :@SECLEVEL=0) is the new
default, all six PSK suites are offered and OpenSSL negotiates the highest;
the negotiated version/cipher is logged (tls-handshake) and reported as
version_seen/cipher_seen in /api/scp81/status, and a handshake failing for a
TLS/cipher reason logs tls-handshake-failed (post-handshake record errors
stay tls-error). tls_version/cipher/keylog/answer_delay stay as API-only pins.

Dropped the Apache-style header mimicry completely: no Date/Server/
X-Powered-By, no Content-Length-before-Content-Type ordering, no Content-Type
on 204 - the minimal response set is X-Admin-Protocol (+ X-Admin-Next-URI /
Targeted-Application), Content-Type on 200s, and Transfer-Encoding or
Content-Length per the chunked flag. Docs, help (EN/RU), READMEs and the
AGENTS notes updated; SW cache otaman-v180.
This commit is contained in:
2026-09-17 08:50:40 +03:00
parent 404fdf5a1c
commit f4462c14e7
13 changed files with 517 additions and 80 deletions
+2
View File
@@ -597,6 +597,8 @@ The **Listener** starts/stops the target the card's BIP channel is redirected to
- **Pass-through (card destination)** — no listener and no target: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (`Other address` + transport port, TCP client remote only; no spec default port, so an incomplete request fails the channel). TLS is terminated by that platform, so the server's network is used (lab only). - **Pass-through (card destination)** — no listener and no target: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (`Other address` + transport port, TCP client remote only; no spec default port, so an incomplete request fails the channel). TLS is terminated by that platform, so the server's network is used (lab only).
- **Capture (dump)** — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering. - **Capture (dump)** — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering.
The **Options** block (applied at **Start**, remembered in the browser) exposes the HTTP framing (chunked body / chunk size, 0 = one TLS record / keep-alive / Connection header / compact headers / Next-URI), the script framing (indefinite `AE 80 …` or definite `AA` Command Scripting template, comprehension-required tags, targeted application) and link events (all modes, TS 102 223 §7.5.11). TLS has no settings: the listener offers TLS 1.01.2 and all PSK suites and lets the card negotiate; the log and state line report the version/cipher actually used, and a TLS/cipher handshake failure is logged as `tls-handshake-failed`.
**Script** selects the command list served over the session: **None** (leave the server's configured script) or one of the scripts created in the **Scripts** pill; **Restart script** re-queues the selected script with `force`, starting over from the first APDU. **Script** selects the command list served over the session: **None** (leave the server's configured script) or one of the scripts created in the **Scripts** pill; **Restart script** re-queues the selected script with `force`, starting over from the first APDU.
The state line shows the listener, the negotiated identity and live channels (bytes in/out); **Script results (R-APDUs)** lists each served C-APDU with its R-APDU and SW (`done/total` progress); the **HTTP OTA log** records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step (`tls-handshake`, `tls-request`, `script-send`, `script-rapdu`, `script-page`, `script-done`, `data-available`, `peer-close`). The same controls are available through `POST /api/scp81/bip` and `GET /api/scp81/script` (see `docs/api.md`). The state line shows the listener, the negotiated identity and live channels (bytes in/out); **Script results (R-APDUs)** lists each served C-APDU with its R-APDU and SW (`done/total` progress); the **HTTP OTA log** records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step (`tls-handshake`, `tls-request`, `script-send`, `script-rapdu`, `script-page`, `script-done`, `data-available`, `peer-close`). The same controls are available through `POST /api/scp81/bip` and `GET /api/scp81/script` (see `docs/api.md`).
+2
View File
@@ -567,6 +567,8 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
- **Проброс (адрес карты)** — без слушателя и без цели: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (`Other address` + порт transport level, только TCP-клиент, удалённое соединение; порт по умолчанию спецификациями не задан, поэтому неполный запрос отклоняет канал). TLS завершается на той платформе, и используется сеть сервера (только для лаборатории). - **Проброс (адрес карты)** — без слушателя и без цели: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (`Other address` + порт transport level, только TCP-клиент, удалённое соединение; порт по умолчанию спецификациями не задан, поэтому неполный запрос отклоняет канал). TLS завершается на той платформе, и используется сеть сервера (только для лаборатории).
- **Capture (dump)** — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая. - **Capture (dump)** — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая.
Блок **Настройки** (применяются при **Start**, сохраняются в браузере) открывает HTTP-фрейминг (chunked-тело / размер чанка, 0 = одна TLS-запись / keep-alive / заголовок Connection / компактные заголовки / Next-URI), фрейминг скрипта (неопределённая `AE 80 …` или определённая `AA` длина Command Scripting template, теги с обязательным пониманием, целевое приложение) и события канала (во всех режимах, TS 102 223 §7.5.11). У TLS нет настроек: слушатель предлагает TLS 1.0–1.2 и все PSK-наборы, а карта выбирает; журнал и строка состояния показывают фактически использованные версию и набор, а сбой рукопожатия по причине TLS/набора пишется как `tls-handshake-failed`.
**Script** выбирает список команд, отдаваемый в сессии: **None** (оставить настроенный на сервере скрипт) или один из скриптов, созданных на подвкладке **Scripts**; **Restart script** заново ставит выбранный скрипт в очередь с `force`, начиная с первого APDU. **Script** выбирает список команд, отдаваемый в сессии: **None** (оставить настроенный на сервере скрипт) или один из скриптов, созданных на подвкладке **Scripts**; **Restart script** заново ставит выбранный скрипт в очередь с `force`, начиная с первого APDU.
Строка состояния показывает слушатель, согласованный идентификатор и активные каналы (байты in/out); панель **Script results (R-APDUs)** перечисляет каждый отданный C-APDU с его R-APDU и SW (прогресс `done/total`); журнал **HTTP OTA log** фиксирует OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA и каждый шаг TLS/HTTP/скрипта (`tls-handshake`, `tls-request`, `script-send`, `script-rapdu`, `script-page`, `script-done`, `data-available`, `peer-close`). Те же функции доступны через `POST /api/scp81/bip` и `GET /api/scp81/script` (см. `docs/api.md`). Строка состояния показывает слушатель, согласованный идентификатор и активные каналы (байты in/out); панель **Script results (R-APDUs)** перечисляет каждый отданный C-APDU с его R-APDU и SW (прогресс `done/total`); журнал **HTTP OTA log** фиксирует OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA и каждый шаг TLS/HTTP/скрипта (`tls-handshake`, `tls-request`, `script-send`, `script-rapdu`, `script-page`, `script-done`, `data-available`, `peer-close`). Те же функции доступны через `POST /api/scp81/bip` и `GET /api/scp81/script` (см. `docs/api.md`).
+28 -8
View File
@@ -563,7 +563,8 @@ Stop either mode with `{"action": "stop"}` (also disables the BIP terminal).
{"bip": {"enabled": true, "mode": "redirect", "target": "127.0.0.1:8443", "channels": [], "seq": 12}, {"bip": {"enabled": true, "mode": "redirect", "target": "127.0.0.1:8443", "channels": [], "seq": 12},
"listener": {"mode": "tls", "host": "127.0.0.1", "port": 8443, "listener": {"mode": "tls", "host": "127.0.0.1", "port": 8443,
"psk_identities": ["89012345678901234567"], "psk_wildcard": false, "psk_identities": ["89012345678901234567"], "psk_wildcard": false,
"identity_seen": "89012345678901234567", "identity_matched": true}} "identity_seen": "89012345678901234567", "identity_matched": true,
"version_seen": "TLSv1.2", "cipher_seen": "PSK-AES128-CBC-SHA256"}}
``` ```
Listener modes: `tls` (local PSK TLS server), `dump` (capture-only TCP Listener modes: `tls` (local PSK TLS server), `dump` (capture-only TCP
@@ -677,13 +678,32 @@ each response
(after the card drained the BIP buffer, with `close_notify`, so the card (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); processes the script and opens a new connection for its next POST);
`compact_headers` (default `false`) drops the space after each header colon, `compact_headers` (default `false`) drops the space after each header colon,
`apache_headers` (default `true`) adds Date/Server/X-Powered-By like the `conn_header` (default `'none'` = omit the header) declares the connection
reference servers and puts Transfer-Encoding before Content-Type, fate, `tls_version` (default `'auto'` — accept TLS 1.0-1.2 and let OpenSSL
`conn_header` (default `'none'` = omit the header, like the reference) pick the highest the card offers; `'1.0'`/`'1.1'`/`'1.2'` pin a version for
declares the connection fate, `tls_version` pins `1.1`/`1.0` for cards that debugging) selects the protocol window, `cipher` pins one suite, `next_uri`
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 overrides the per-command `X-Admin-Next-URI` (`%d` = command id; empty string
omits the header), `link_events` (default `true`) controls the automatic omits the header), `link_events` (default `true`, all modes) controls the
Channel status events, `answer_delay` waits before answering a request. `keylog` writes the TLS traffic secrets to 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 the given file (SSLKEYLOGFILE format) for debugging captures — it contains key
material, use a temporary path. material, use a temporary path.
The response headers are minimal: `X-Admin-Protocol`, the optional
`X-Admin-Next-URI`/`X-Admin-Targeted-Application`, `Content-Type` on 200s and
`Transfer-Encoding`/`Content-Length` per `chunked`. The Date/Server/X-Powered-By
mimicry (`apache_headers`) was removed in 2.2.14 — the reference server's extra
headers were an unnecessary copy (the real blocker was the BIP TERMINAL
RESPONSE BER length) and the card accepts the minimal set.
`GET /api/scp81/status` echoes the negotiated handshake as `version_seen` /
`cipher_seen` (plus `identity_seen`); a handshake that fails for TLS/cipher
reasons (no shared cipher, unsupported protocol version, card alert) is logged
as `tls-handshake-failed` with the OpenSSL reason, separately from
post-handshake record errors (`tls-error`).
The PWA's Listener **Options** block exposes `chunked`, `chunk_size`,
`keep_alive`, `conn_header`, `compact_headers`, `next_uri`,
`script_template`, `cr_tag`, `targeted_app` and `link_events`
(applied at Start, persisted in `localStorage`); `tls_version`, `cipher`,
`keylog` and `answer_delay` stay API-only.
+3 -2
View File
@@ -174,9 +174,10 @@ All script attempts used the `explore` list, except #8-#17 which used only
failures were caused by the BIP TLV length bug, not by the HTTP/TLS details; 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 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, key working recipe (also now the server default): one keep-alive connection,
Apache-style headers, `Transfer-Encoding: chunked` body with the script in `Transfer-Encoding: chunked` body with the script in
one TLS record, no Connection header, `X-Admin-Next-URI` with a query whose one TLS record, no Connection header, `X-Admin-Next-URI` with a query whose
command id increments. command id increments. (2.2.14: the reference server's Date/Server/X-Powered-By
headers were dropped — they were mimicry, the blocker was the BIP length bug.)
**Also confirmed:** a TLS half-close (close_notify then keep reading for the **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 card's POST which RFC 5246 leaves open in practice) cannot be done with
+1
View File
@@ -327,6 +327,7 @@
<li><strong>Проброс (адрес карты)</strong> — без слушателя и без цели: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (Other address + порт Transport level, только TCP-клиент, удалённое соединение; спецификации не задают порт по умолчанию, поэтому неполный запрос отклоняет канал). TLS завершается на той платформе, и используется сеть сервера (только для лаборатории).</li> <li><strong>Проброс (адрес карты)</strong> — без слушателя и без цели: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (Other address + порт Transport level, только TCP-клиент, удалённое соединение; спецификации не задают порт по умолчанию, поэтому неполный запрос отклоняет канал). TLS завершается на той платформе, и используется сеть сервера (только для лаборатории).</li>
<li><strong>Захват (dump)</strong> — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая.</li> <li><strong>Захват (dump)</strong> — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая.</li>
</ul> </ul>
<p class="mb-2">Блок <strong>Настройки</strong> (применяются при <strong>Start</strong>, сохраняются в браузере) открывает HTTP-фрейминг &mdash; <strong>chunked</strong>-тело / <strong>размер чанка</strong> (0 = весь ответ в одной TLS-записи), keep-alive, заголовок Connection, компактные заголовки и Next-URI (снято — заголовок не отправляется) &mdash; и фрейминг скрипта (неопределённая длина <code class="font-mono text-sm">AE 80 …</code> / определённая <code class="font-mono text-sm">AA</code> в Command Scripting template, теги с обязательным пониманием, целевое приложение). <strong>События канала</strong> (ENVELOPE Channel status, TS 102 223 &sect;7.5.11) действуют во всех режимах. У TLS нет настроек: слушатель предлагает TLS 1.0&ndash;1.2 и все PSK-наборы, а карта выбирает; строка состояния и TLS-журнал показывают фактически использованные версию и набор, а сбой рукопожатия по причине TLS/набора пишется как <code class="font-mono text-sm">tls-handshake-failed</code>.</p>
<p class="mb-2"><strong>Сценарий</strong> выбирает список команд, отдаваемый в сессии — <strong>Нет</strong> (оставить настроенный на сервере скрипт) или один из скриптов подвкладки <strong>&laquo;Скрипты&raquo;</strong>; выбранный список передаётся серверу при старте слушателя.</p> <p class="mb-2"><strong>Сценарий</strong> выбирает список команд, отдаваемый в сессии — <strong>Нет</strong> (оставить настроенный на сервере скрипт) или один из скриптов подвкладки <strong>&laquo;Скрипты&raquo;</strong>; выбранный список передаётся серверу при старте слушателя.</p>
<p class="text-sm mb-3">Строка состояния показывает слушатель, согласованный идентификатор и активные каналы (байты in/out). Панель <strong>Script results (R-APDUs)</strong> перечисляет каждый отданный C-APDU с его R-APDU/SW и прогрессом <em>done/total</em>; журнал <strong>HTTP OTA log</strong> фиксирует OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA и каждый шаг TLS/HTTP/скрипта (<code class="font-mono text-sm">tls-handshake</code>, <code class="font-mono text-sm">tls-request</code>, <code class="font-mono text-sm">script-send</code>, <code class="font-mono text-sm">script-rapdu</code>, <code class="font-mono text-sm">script-page</code>, <code class="font-mono text-sm">script-done</code>, <code class="font-mono text-sm">data-available</code>, <code class="font-mono text-sm">peer-close</code>), кнопка <strong>Clear</strong> очищает его.</p> <p class="text-sm mb-3">Строка состояния показывает слушатель, согласованный идентификатор и активные каналы (байты in/out). Панель <strong>Script results (R-APDUs)</strong> перечисляет каждый отданный C-APDU с его R-APDU/SW и прогрессом <em>done/total</em>; журнал <strong>HTTP OTA log</strong> фиксирует OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA и каждый шаг TLS/HTTP/скрипта (<code class="font-mono text-sm">tls-handshake</code>, <code class="font-mono text-sm">tls-request</code>, <code class="font-mono text-sm">script-send</code>, <code class="font-mono text-sm">script-rapdu</code>, <code class="font-mono text-sm">script-page</code>, <code class="font-mono text-sm">script-done</code>, <code class="font-mono text-sm">data-available</code>, <code class="font-mono text-sm">peer-close</code>), кнопка <strong>Clear</strong> очищает его.</p>
<h3 id="scp81-scripts" class="text-lg font-medium mb-2">5.2 Скрипты</h3> <h3 id="scp81-scripts" class="text-lg font-medium mb-2">5.2 Скрипты</h3>
+1
View File
@@ -327,6 +327,7 @@
<li><strong>Pass-through (card destination)</strong> — no listener and no target: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + Transport level port, TCP client remote only; the specs define no default port, so an incomplete request fails the channel). TLS is terminated by that platform, and the server's network is used (lab only).</li> <li><strong>Pass-through (card destination)</strong> — no listener and no target: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + Transport level port, TCP client remote only; the specs define no default port, so an incomplete request fails the channel). TLS is terminated by that platform, and the server's network is used (lab only).</li>
<li><strong>Capture (dump)</strong> — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering.</li> <li><strong>Capture (dump)</strong> — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering.</li>
</ul> </ul>
<p class="mb-2">The <strong>Options</strong> block (applied at <strong>Start</strong>, remembered in the browser) exposes the HTTP framing &mdash; <strong>chunked</strong> body / <strong>chunk size</strong> (0 = the whole response in one TLS record), keep-alive, the Connection header, compact headers and the Next-URI (unchecked = omit the header) &mdash; and the script framing (indefinite <code class="font-mono text-sm">AE 80 …</code> / definite <code class="font-mono text-sm">AA</code> Command Scripting template, comprehension-required tags, targeted application). <strong>Link events</strong> (Channel status ENVELOPEs, TS 102 223 &sect;7.5.11) apply to every mode. TLS itself has no settings: the listener offers TLS 1.0&ndash;1.2 and all PSK suites and lets the card negotiate; the state line and the TLS log show the version/cipher actually used, and a handshake that fails for a TLS/cipher reason is logged as <code class="font-mono text-sm">tls-handshake-failed</code>.</p>
<p class="mb-2"><strong>Script</strong> selects the command list served over the session — <strong>None</strong> (leave the server's configured script) or one of the scripts from the <strong>Scripts</strong> pill; the chosen list is sent to the server when the listener starts.</p> <p class="mb-2"><strong>Script</strong> selects the command list served over the session — <strong>None</strong> (leave the server's configured script) or one of the scripts from the <strong>Scripts</strong> pill; the chosen list is sent to the server when the listener starts.</p>
<p class="text-sm mb-3">The state line shows the listener, the negotiated identity and live channels (bytes in/out). <strong>Script results (R-APDUs)</strong> lists each served C-APDU with its R-APDU/SW and a <em>done/total</em> progress; the <strong>HTTP OTA log</strong> records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step (<code class="font-mono text-sm">tls-handshake</code>, <code class="font-mono text-sm">tls-request</code>, <code class="font-mono text-sm">script-send</code>, <code class="font-mono text-sm">script-rapdu</code>, <code class="font-mono text-sm">script-page</code>, <code class="font-mono text-sm">script-done</code>, <code class="font-mono text-sm">data-available</code>, <code class="font-mono text-sm">peer-close</code>), with <strong>Clear</strong>.</p> <p class="text-sm mb-3">The state line shows the listener, the negotiated identity and live channels (bytes in/out). <strong>Script results (R-APDUs)</strong> lists each served C-APDU with its R-APDU/SW and a <em>done/total</em> progress; the <strong>HTTP OTA log</strong> records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step (<code class="font-mono text-sm">tls-handshake</code>, <code class="font-mono text-sm">tls-request</code>, <code class="font-mono text-sm">script-send</code>, <code class="font-mono text-sm">script-rapdu</code>, <code class="font-mono text-sm">script-page</code>, <code class="font-mono text-sm">script-done</code>, <code class="font-mono text-sm">data-available</code>, <code class="font-mono text-sm">peer-close</code>), with <strong>Clear</strong>.</p>
<h3 id="scp81-scripts" class="text-lg font-medium mb-2">5.2 Scripts</h3> <h3 id="scp81-scripts" class="text-lg font-medium mb-2">5.2 Scripts</h3>
+142 -1
View File
@@ -18,7 +18,7 @@
<div class="max-w-7xl mx-auto px-6 py-2"> <div class="max-w-7xl mx-auto px-6 py-2">
<div class="flex items-center justify-between mb-3"> <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.2.13</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.2.14</span></h1>
<div class="flex items-center gap-4"> <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" 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> <span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span>
@@ -995,6 +995,44 @@
<div id="scp81-redirect-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.">Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.</div> <div id="scp81-redirect-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.">Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.</div>
<div id="scp81-passthru-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Pass-through: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port above are not used, TLS is terminated by that platform, and the server's network is used (lab only).">Pass-through: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port above are not used, TLS is terminated by that platform, and the server's network is used (lab only).</div> <div id="scp81-passthru-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Pass-through: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port above are not used, TLS is terminated by that platform, and the server's network is used (lab only).">Pass-through: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port above are not used, TLS is terminated by that platform, and the server's network is used (lab only).</div>
</div> </div>
<div class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="Options (applied at Start)">Options (applied at Start)</span>
<button onclick="scp81OptionsReset()" class="ml-auto px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Reset to defaults">Reset to defaults</button>
</div>
<div id="scp81-opts-http" class="space-y-2">
<div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="HTTP framing">HTTP framing</div>
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-chunked" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Chunked body (Transfer-Encoding: chunked)">Chunked body (Transfer-Encoding: chunked)</span></label>
<div class="flex flex-wrap items-center gap-2">
<label class="text-xs text-gray-600 dark:text-slate-400" data-l10n="Chunk size (bytes, 0 = one TLS record)">Chunk size (bytes, 0 = one TLS record)</label>
<input id="opt-chunk-size" type="number" min="0" max="4096" onchange="scp81OptionsPersist()" class="w-24 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-keep-alive" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Keep-alive (one connection until the session ends)">Keep-alive (one connection until the session ends)</span></label>
<div class="flex flex-wrap items-center gap-2">
<label class="text-xs text-gray-600 dark:text-slate-400" data-l10n="Connection header">Connection header</label>
<select id="opt-conn-header" onchange="scp81OptionsPersist()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
<option value="none" data-l10n="omit (implicit keep-alive)">omit (implicit keep-alive)</option>
<option value="close">close</option>
<option value="keep-alive">keep-alive</option>
</select>
</div>
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-compact" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Compact headers (no space after ':')">Compact headers (no space after ':')</span></label>
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-next-uri" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Next-URI (unchecked = omit the header)">Next-URI (unchecked = omit the header)</span></label>
<input id="opt-next-uri-value" onchange="scp81OptionsPersist()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800" placeholder="/api/scp81?req=%d">
</div>
<div id="scp81-opts-script" class="space-y-2 mt-3">
<div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Script framing">Script framing</div>
<div class="flex flex-wrap items-center gap-2">
<select id="opt-script-template" onchange="scp81OptionsPersist()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
<option value="indefinite" data-l10n="Indefinite (AE 80 … 00 00)">Indefinite (AE 80 … 00 00)</option>
<option value="definite" data-l10n="Definite (AA)">Definite (AA)</option>
</select>
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-cr-tag" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Comprehension-required tags">Comprehension-required tags</span></label>
</div>
<input id="opt-targeted-app" onchange="scp81OptionsPersist()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800" placeholder="X-Admin-Targeted-Application, e.g. //aid/A000000151000000">
</div>
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer mt-3"><input type="checkbox" id="opt-link-events" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Link events (Channel status ENVELOPEs, TS 102 223 7.5.11)">Link events (Channel status ENVELOPEs, TS 102 223 7.5.11)</span></label>
</div>
<div class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3"> <div class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="Script results (R-APDUs)">Script results (R-APDUs)</span> <span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="Script results (R-APDUs)">Script results (R-APDUs)</span>
@@ -7770,6 +7808,67 @@ async function pysimProactiveLogRender() {
let _scp81Timer = null; let _scp81Timer = null;
let _scp81LastStatus = null; let _scp81LastStatus = null;
// ===== SCP81 listener options (HTTP framing / script framing / link events) =====
// The API takes these at listener start; they are kept in localStorage so a
// test setup survives reloads. TLS itself has no settings: the listener
// accepts everything it can and reports the negotiated version/cipher.
const SCP81_OPT_DEFAULTS = {
chunked: true, chunkSize: 0, keepAlive: true, connHeader: 'none',
compact: false, nextUri: '/api/scp81?req=%d', linkEvents: true,
scriptTemplate: 'indefinite', crTag: false, targetedApp: ''
};
function scp81OptionsLoad() {
let saved = {};
try { saved = JSON.parse(localStorage.getItem('otaman_scp81_opts') || '{}') || {}; } catch (e) { saved = {}; }
const opt = Object.assign({}, SCP81_OPT_DEFAULTS, saved);
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
const chk = (id, v) => { const el = document.getElementById(id); if (el) el.checked = !!v; };
chk('opt-chunked', opt.chunked);
set('opt-chunk-size', opt.chunkSize);
chk('opt-keep-alive', opt.keepAlive);
set('opt-conn-header', opt.connHeader);
chk('opt-compact', opt.compact);
chk('opt-next-uri', opt.nextUri !== '');
set('opt-next-uri-value', opt.nextUri || SCP81_OPT_DEFAULTS.nextUri);
chk('opt-link-events', opt.linkEvents);
set('opt-script-template', opt.scriptTemplate === 'definite' ? 'definite' : 'indefinite');
chk('opt-cr-tag', opt.crTag);
set('opt-targeted-app', opt.targetedApp);
}
function scp81OptionsFromForm() {
const val = id => { const el = document.getElementById(id); return el ? el.value : ''; };
const on = id => { const el = document.getElementById(id); return !!(el && el.checked); };
return {
chunked: on('opt-chunked'),
chunk_size: Math.max(0, parseInt(val('opt-chunk-size'), 10) || 0),
keep_alive: on('opt-keep-alive'),
conn_header: val('opt-conn-header') || 'none',
compact_headers: on('opt-compact'),
next_uri: on('opt-next-uri') ? (val('opt-next-uri-value').trim() || SCP81_OPT_DEFAULTS.nextUri) : '',
link_events: on('opt-link-events'),
script_template: val('opt-script-template') === 'definite' ? 'definite' : 'indefinite',
cr_tag: on('opt-cr-tag'),
targeted_app: val('opt-targeted-app').trim()
};
}
function scp81OptionsPersist() {
const o = scp81OptionsFromForm();
localStorage.setItem('otaman_scp81_opts', JSON.stringify({
chunked: o.chunked, chunkSize: o.chunk_size, keepAlive: o.keep_alive,
connHeader: o.conn_header, compact: o.compact_headers, nextUri: o.next_uri,
linkEvents: o.link_events, scriptTemplate: o.script_template,
crTag: o.cr_tag, targetedApp: o.targeted_app
}));
}
function scp81OptionsReset() {
localStorage.removeItem('otaman_scp81_opts');
scp81OptionsLoad();
}
function scp81ModeChanged() { function scp81ModeChanged() {
const mode = document.getElementById('scp81-mode').value; const mode = document.getElementById('scp81-mode').value;
const row = document.getElementById('scp81-script-row'); const row = document.getElementById('scp81-script-row');
@@ -7780,6 +7879,11 @@ function scp81ModeChanged() {
if (redirectNote) redirectNote.classList.toggle('hidden', mode !== 'redirect'); if (redirectNote) redirectNote.classList.toggle('hidden', mode !== 'redirect');
const passthruNote = document.getElementById('scp81-passthru-note'); const passthruNote = document.getElementById('scp81-passthru-note');
if (passthruNote) passthruNote.classList.toggle('hidden', mode !== 'passthru'); if (passthruNote) passthruNote.classList.toggle('hidden', mode !== 'passthru');
// HTTP and script framing are properties of our TLS listener
const httpOpts = document.getElementById('scp81-opts-http');
if (httpOpts) httpOpts.classList.toggle('hidden', mode !== 'tls');
const scriptOpts = document.getElementById('scp81-opts-script');
if (scriptOpts) scriptOpts.classList.toggle('hidden', mode !== 'tls');
// passthru dials the destination from the card's OPEN CHANNEL: the // passthru dials the destination from the card's OPEN CHANNEL: the
// configured target fields do not apply. // configured target fields do not apply.
for (const id of ['scp81-host', 'scp81-port']) { for (const id of ['scp81-host', 'scp81-port']) {
@@ -8132,6 +8236,13 @@ async function scp81StatusRefresh() {
? t('matched') + (name ? ': ' + name : '') ? t('matched') + (name ? ': ' + name : '')
: t('unknown identity')) + ']'; : t('unknown identity')) + ']';
} }
if (l.version_seen) {
s += ' | TLS ' + l.version_seen + (l.cipher_seen ? ' ' + l.cipher_seen : '');
}
s += ' | ' + (l.chunked
? ('chunked' + (l.chunk_size ? ' ' + l.chunk_size : ''))
: 'content-length');
if (l.keep_alive) s += ' | keep-alive';
} }
const ch = (bip.channels || []).map(c => 'ch' + c.id + (c.target ? ' → ' + c.target : '') + ' in:' + c.bytes_in + ' out:' + c.bytes_out).join(', '); const ch = (bip.channels || []).map(c => 'ch' + c.id + (c.target ? ' → ' + c.target : '') + ' in:' + c.bytes_in + ' out:' + c.bytes_out).join(', ');
if (ch) s += ' | ' + ch; if (ch) s += ' | ' + ch;
@@ -8173,6 +8284,20 @@ async function scp81Start() {
body.host = hostVal || '127.0.0.1'; body.host = hostVal || '127.0.0.1';
body.port = parseInt(portVal || '8443', 10); body.port = parseInt(portVal || '8443', 10);
} }
const opts = scp81OptionsFromForm();
body.link_events = opts.link_events;
if (mode === 'tls') {
body.chunked = opts.chunked;
body.chunk_size = opts.chunk_size;
body.keep_alive = opts.keep_alive;
body.conn_header = opts.conn_header;
body.compact_headers = opts.compact_headers;
body.next_uri = opts.next_uri;
body.script_template = opts.script_template;
body.cr_tag = opts.cr_tag;
body.targeted_app = opts.targeted_app || null;
}
scp81OptionsPersist();
if (mode === 'tls') { if (mode === 'tls') {
const map = cardsPskMap(); const map = cardsPskMap();
if (!map.length) { if (!map.length) {
@@ -10997,6 +11122,7 @@ function profilerRenderReport(results, labels) {
profilerLoad(); profilerLoad();
snapshotsLoad(); snapshotsLoad();
scp81OptionsLoad();
// Init sub-tab pills // Init sub-tab pills
document.querySelectorAll('.pysim-subtab').forEach(btn => { document.querySelectorAll('.pysim-subtab').forEach(btn => {
@@ -11450,6 +11576,21 @@ const LANG_RU = {
'Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.': 'Перенаправление: каждый открываемый картой BIP-канал подключается к этому Host:Port (внешняя платформа HTTP OTA); TLS завершается там, а запрошенный картой адрес только журналируется.', 'Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.': 'Перенаправление: каждый открываемый картой BIP-канал подключается к этому Host:Port (внешняя платформа HTTP OTA); TLS завершается там, а запрошенный картой адрес только журналируется.',
'Pass-through: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port above are not used, TLS is terminated by that platform, and the server\'s network is used (lab only).': 'Проброс: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (Other address + порт Transport level, только TCP-клиент); поля Host и Port выше не используются, TLS завершается на той платформе, и используется сеть сервера (только для лаборатории).', 'Pass-through: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port above are not used, TLS is terminated by that platform, and the server\'s network is used (lab only).': 'Проброс: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (Other address + порт Transport level, только TCP-клиент); поля Host и Port выше не используются, TLS завершается на той платформе, и используется сеть сервера (только для лаборатории).',
'Redirect requires the target host and port': 'Для перенаправления нужны host и port внешнего сервера', 'Redirect requires the target host and port': 'Для перенаправления нужны host и port внешнего сервера',
'Options (applied at Start)': 'Настройки (применяются при запуске)',
'Reset to defaults': 'Сбросить к умолчаниям',
'HTTP framing': 'HTTP-фрейминг',
'Chunked body (Transfer-Encoding: chunked)': 'Chunked-тело (Transfer-Encoding: chunked)',
'Chunk size (bytes, 0 = one TLS record)': 'Размер чанка (байт, 0 = одна TLS-запись)',
'Keep-alive (one connection until the session ends)': 'Keep-alive (одно соединение до конца сессии)',
'Connection header': 'Заголовок Connection',
'omit (implicit keep-alive)': 'не отправлять (неявный keep-alive)',
"Compact headers (no space after ':')": 'Компактные заголовки (без пробела после «:»)',
'Next-URI (unchecked = omit the header)': 'Next-URI (снято — заголовок не отправляется)',
'Script framing': 'Фрейминг скрипта',
'Indefinite (AE 80 … 00 00)': 'Неопределённая длина (AE 80 … 00 00)',
'Definite (AA)': 'Определённая длина (AA)',
'Comprehension-required tags': 'Теги с обязательным пониманием (CR)',
'Link events (Channel status ENVELOPEs, TS 102 223 7.5.11)': 'События канала (ENVELOPE Channel status, TS 102 223 7.5.11)',
'ADM verified': 'ADM подтверждён', 'ADM verified': 'ADM подтверждён',
'ADM not verified': 'ADM не подтверждён', 'ADM not verified': 'ADM не подтверждён',
'TERMINAL PROFILE': 'TERMINAL PROFILE', 'TERMINAL PROFILE': 'TERMINAL PROFILE',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v179'; const CACHE = 'otaman-v180';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+161
View File
@@ -0,0 +1,161 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = html.match(/const SCP81_OPT_DEFAULTS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
for (const fn of ['scp81OptionsLoad', 'scp81OptionsFromForm', 'scp81OptionsPersist', 'scp81OptionsReset']) {
code += extractFunc(html, fn) + '\n';
}
eval(code);
function setup(opts) {
const spec = Object.assign({
'opt-chunked': true,
'opt-chunk-size': 0,
'opt-keep-alive': true,
'opt-conn-header': 'none',
'opt-compact': false,
'opt-next-uri': true,
'opt-next-uri-value': '/api/scp81?req=%d',
'opt-link-events': true,
'opt-script-template': 'indefinite',
'opt-cr-tag': false,
'opt-targeted-app': ''
}, opts || {});
const els = {};
for (const [id, v] of Object.entries(spec)) {
els[id] = typeof v === 'boolean' ? { checked: v, value: '' } : { checked: true, value: String(v) };
}
globalThis.document = { getElementById: id => els[id] || null };
return els;
}
function fakeStorage(initial) {
const store = Object.assign({}, initial || {});
globalThis.localStorage = {
getItem: k => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
removeItem: k => { delete store[k]; },
_store: store,
};
return store;
}
test('scp81OptionsFromForm maps the reference defaults', () => {
setup();
assert.deepStrictEqual(scp81OptionsFromForm(), {
chunked: true,
chunk_size: 0,
keep_alive: true,
conn_header: 'none',
compact_headers: false,
next_uri: '/api/scp81?req=%d',
link_events: true,
script_template: 'indefinite',
cr_tag: false,
targeted_app: ''
});
});
test('scp81OptionsFromForm reflects a changed setup', () => {
setup({
'opt-chunked': false,
'opt-chunk-size': '100',
'opt-keep-alive': false,
'opt-conn-header': 'close',
'opt-compact': true,
'opt-next-uri-value': '/adminserver?apdu_id=%d',
'opt-link-events': false,
'opt-script-template': 'definite',
'opt-cr-tag': true,
'opt-targeted-app': ' //aid/A000000151000000 '
});
assert.deepStrictEqual(scp81OptionsFromForm(), {
chunked: false,
chunk_size: 100,
keep_alive: false,
conn_header: 'close',
compact_headers: true,
next_uri: '/adminserver?apdu_id=%d',
link_events: false,
script_template: 'definite',
cr_tag: true,
targeted_app: '//aid/A000000151000000'
});
});
test('unchecked Next-URI omits the header; empty text falls back to the template', () => {
setup({ 'opt-next-uri': false });
assert.strictEqual(scp81OptionsFromForm().next_uri, '');
setup({ 'opt-next-uri': true, 'opt-next-uri-value': ' ' });
assert.strictEqual(scp81OptionsFromForm().next_uri, '/api/scp81?req=%d');
});
test('load/persist/reset round-trip through localStorage', () => {
const store = fakeStorage();
setup({
'opt-chunked': false,
'opt-chunk-size': 100,
'opt-keep-alive': false,
'opt-conn-header': 'close',
'opt-compact': true,
'opt-next-uri': false,
'opt-link-events': false,
'opt-script-template': 'definite',
'opt-cr-tag': true,
'opt-targeted-app': '//aid/A000000151000000'
});
scp81OptionsPersist();
assert.ok(store['otaman_scp81_opts'].includes('"chunkSize":100'));
// a reload restores the saved setup
setup({});
scp81OptionsLoad();
assert.strictEqual(globalThis.document.getElementById('opt-chunked').checked, false);
assert.strictEqual(globalThis.document.getElementById('opt-chunk-size').value, 100);
assert.strictEqual(globalThis.document.getElementById('opt-conn-header').value, 'close');
assert.strictEqual(globalThis.document.getElementById('opt-next-uri').checked, false);
assert.strictEqual(globalThis.document.getElementById('opt-script-template').value, 'definite');
assert.strictEqual(globalThis.document.getElementById('opt-cr-tag').checked, true);
assert.strictEqual(globalThis.document.getElementById('opt-targeted-app').value, '//aid/A000000151000000');
// reset clears the saved entry and restores the defaults
scp81OptionsReset();
assert.ok(!('otaman_scp81_opts' in store));
scp81OptionsLoad();
assert.strictEqual(globalThis.document.getElementById('opt-chunked').checked, true);
assert.strictEqual(globalThis.document.getElementById('opt-next-uri').checked, true);
assert.strictEqual(globalThis.document.getElementById('opt-conn-header').value, 'none');
});
test('the Listener UI wires the framing options into Start', () => {
for (const id of ['scp81-opts-http', 'scp81-opts-script', 'opt-chunked',
'opt-chunk-size', 'opt-keep-alive', 'opt-conn-header', 'opt-compact',
'opt-next-uri', 'opt-next-uri-value', 'opt-script-template', 'opt-cr-tag',
'opt-targeted-app', 'opt-link-events']) {
assert.ok(html.includes('id="' + id + '"'), id);
}
assert.ok(html.includes('body.link_events = opts.link_events;'));
assert.ok(html.includes('body.chunk_size = opts.chunk_size;'));
assert.ok(html.includes('body.next_uri = opts.next_uri;'));
assert.ok(html.includes('body.script_template = opts.script_template;'));
assert.ok(html.includes("httpOpts.classList.toggle('hidden', mode !== 'tls')"));
assert.ok(html.includes("scriptOpts.classList.toggle('hidden', mode !== 'tls')"));
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-otaman-server" name = "pysim-otaman-server"
version = "2.2.13" version = "2.2.14"
description = "HTTP REST server wrapping pysim for the OTAMan PWA" description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+37 -16
View File
@@ -132,7 +132,7 @@ class PskTlsServer:
def __init__(self, host, port, psk=None, identity=None, on_log=None, def __init__(self, host, port, psk=None, identity=None, on_log=None,
responder=None, timeout=10.0, chunked=False, chunk_size=0, responder=None, timeout=10.0, chunked=False, chunk_size=0,
keep_alive=False, compact_headers=False, tls_version='1.2', keep_alive=False, compact_headers=False, tls_version='auto',
cipher=None, on_before_close=None, keylog=None, cipher=None, on_before_close=None, keylog=None,
conn_header=None, half_close=False, answer_delay=0.0, conn_header=None, half_close=False, answer_delay=0.0,
psk_map=None): psk_map=None):
@@ -161,9 +161,12 @@ class PskTlsServer:
self.chunk_size = int(chunk_size) self.chunk_size = int(chunk_size)
self.keep_alive = keep_alive self.keep_alive = keep_alive
self.compact_headers = compact_headers self.compact_headers = compact_headers
# The reference traces negotiated TLS 1.0 with PSK-AES128-CBC-SHA; # TLS is permissive by default: 'auto' accepts TLS 1.0-1.2 and lets
# some cards only speak the older record layer correctly. # OpenSSL pick the highest the card offers. The '1.0'/'1.1'/'1.2'
self.tls_version = tls_version if tls_version in TLS_VERSIONS else '1.2' # pins are debugging aids for a card that offers 1.2 but mishandles
# it; no setting is needed for normal use.
self.tls_version = (tls_version if tls_version == 'auto'
or tls_version in TLS_VERSIONS else 'auto')
# Pin one cipher suite (e.g. PSK-AES128-CBC-SHA) if the card's SD only # 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. # maps a specific suite to a usable SCP81 security level.
self.cipher = cipher or None self.cipher = cipher or None
@@ -176,8 +179,8 @@ class PskTlsServer:
# decrypted (tshark etc). Contains key material - use a temp path. # decrypted (tshark etc). Contains key material - use a temp path.
self.keylog = keylog or None self.keylog = keylog or None
# Connection header value: None = auto ('keep-alive'/'close' per the # Connection header value: None = auto ('keep-alive'/'close' per the
# keep_alive flag), 'none' = omit the header (Apache-style implicit # keep_alive flag), 'none' = omit the header (implicit HTTP/1.1
# HTTP/1.1 keep-alive, as in the working reference trace). # keep-alive).
self.conn_header = conn_header or None self.conn_header = conn_header or None
# TLS half-close after a script body. NOTE (live 2026-09-16): # TLS half-close after a script body. NOTE (live 2026-09-16):
# CPython's SSLSocket.unwrap() poisons the session when the peer does # CPython's SSLSocket.unwrap() poisons the session when the peer does
@@ -186,12 +189,14 @@ class PskTlsServer:
# option surface and for cards that answer promptly (the exception # option surface and for cards that answer promptly (the exception
# path leaves the session unusable, so it is off by default). # path leaves the session unusable, so it is off by default).
self.half_close = half_close self.half_close = half_close
# Wait before answering a request (the reference Apache/PHP servers # Wait before answering a request (cards may need their BIP SEND DATA
# answer ~1 s after the card's POST; the card may need its BIP # conversation to settle before they accept the response; 0 = answer
# SEND-DATA conversation to settle before it accepts the response). # immediately).
self.answer_delay = float(answer_delay or 0) self.answer_delay = float(answer_delay or 0)
self.identity_seen = None self.identity_seen = None
self.identity_matched = None self.identity_matched = None
self.version_seen = None
self.cipher_seen = None
self.stopped = False self.stopped = False
self.conns = [] self.conns = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -231,11 +236,17 @@ class PskTlsServer:
def _make_context(self): def _make_context(self):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ver = TLS_VERSIONS[self.tls_version] if self.tls_version == 'auto':
ctx.minimum_version = ver # Accept everything the cards speak; OpenSSL negotiates the
ctx.maximum_version = ver # highest common version.
ctx.minimum_version = ssl.TLSVersion.TLSv1
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
else:
ver = TLS_VERSIONS[self.tls_version]
ctx.minimum_version = ver
ctx.maximum_version = ver
ciphers = self.cipher or PSK_CIPHERS ciphers = self.cipher or PSK_CIPHERS
if self.tls_version in ('1.0', '1.1'): if self.tls_version in ('auto', '1.0', '1.1'):
# OpenSSL 3.x disables the legacy protocol versions by default. # OpenSSL 3.x disables the legacy protocol versions by default.
ciphers += ':@SECLEVEL=0' ciphers += ':@SECLEVEL=0'
ctx.set_ciphers(ciphers) ctx.set_ciphers(ciphers)
@@ -329,10 +340,14 @@ class PskTlsServer:
def _conn_loop(self, conn, peer): def _conn_loop(self, conn, peer):
tls = None tls = None
handshake_done = False
try: try:
tls = self.ctx.wrap_socket(conn, server_side=True) tls = self.ctx.wrap_socket(conn, server_side=True)
self.log('tls-handshake', peer=peer, cipher=tls.cipher()[0], handshake_done = True
version=tls.version(), identity=self.identity_seen, self.version_seen = tls.version()
self.cipher_seen = (tls.cipher() or (None,))[0]
self.log('tls-handshake', peer=peer, cipher=self.cipher_seen,
version=self.version_seen, identity=self.identity_seen,
psk_match=self.identity_matched) psk_match=self.identity_matched)
while not self.stopped: while not self.stopped:
req = self._read_request(tls) req = self._read_request(tls)
@@ -417,7 +432,13 @@ class PskTlsServer:
pass pass
break break
except ssl.SSLError as e: except ssl.SSLError as e:
self.log('tls-error', peer=peer, error=str(e)) if handshake_done:
self.log('tls-error', peer=peer, error=str(e))
else:
# No shared cipher / unsupported protocol version / card
# alert: keep the handshake reason distinguishable from
# post-handshake record errors.
self.log('tls-handshake-failed', peer=peer, error=str(e))
except (OSError, ValueError) as e: except (OSError, ValueError) as e:
self.log('tls-error', peer=peer, error=str(e)) self.log('tls-error', peer=peer, error=str(e))
finally: finally:
+20 -34
View File
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE from osmocom.tlv import BER_TLV_IE
VERSION = '2.2.13' VERSION = '2.2.14'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -1480,6 +1480,8 @@ def _scp81_listener_status():
'psk_wildcard': _SCP81_LISTENER.wildcard_psk is not None, 'psk_wildcard': _SCP81_LISTENER.wildcard_psk is not None,
'identity_seen': _SCP81_LISTENER.identity_seen, 'identity_seen': _SCP81_LISTENER.identity_seen,
'identity_matched': _SCP81_LISTENER.identity_matched, 'identity_matched': _SCP81_LISTENER.identity_matched,
'version_seen': _SCP81_LISTENER.version_seen,
'cipher_seen': _SCP81_LISTENER.cipher_seen,
'chunked': _SCP81_LISTENER.chunked, 'chunked': _SCP81_LISTENER.chunked,
'chunk_size': _SCP81_LISTENER.chunk_size, 'chunk_size': _SCP81_LISTENER.chunk_size,
'keep_alive': _SCP81_LISTENER.keep_alive, 'keep_alive': _SCP81_LISTENER.keep_alive,
@@ -1631,12 +1633,9 @@ _SCP81_NEXT_URI = None
# When it names an application that does not exist on the card, the SD answers # 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. # with X-Admin-Script-Status: unknown-application instead of executing.
_SCP81_TARGETED_APP = None _SCP81_TARGETED_APP = None
# Emit Apache-style responses (Date/Server/X-Powered-By, Content-Length before # The listener's chunked flag (mirrored here: a chunked response must not
# Content-Type) exactly like the reference admin servers. # carry Content-Length - invalid HTTP; the Transfer-Encoding header itself is
_SCP81_APACHE_HEADERS = False # emitted by build_http_response).
# 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 _SCP81_CHUNKED = False
# Send automatic Channel status (link dropped) events to the card. Suppress # Send automatic Channel status (link dropped) events to the card. Suppress
# while testing flows where the terminal closes the connection on purpose: # while testing flows where the terminal closes the connection on purpose:
@@ -1873,32 +1872,18 @@ def _scp81_script_responder(method, target, headers, body):
body_out = _scp81_command_body( body_out = _scp81_command_body(
apdu, definite=(_SCP81_SCRIPT_TEMPLATE == 'definite'), apdu, definite=(_SCP81_SCRIPT_TEMPLATE == 'definite'),
cr_tag=_SCP81_SCRIPT_CR_TAG) cr_tag=_SCP81_SCRIPT_CR_TAG)
if _SCP81_APACHE_HEADERS: # Transfer-Encoding / Content-Length are emitted by the HTTP builder
if _SCP81_CHUNKED: # (chunked never carries a Content-Length).
headers['Transfer-Encoding'] = 'chunked'
else:
headers['Content-Length'] = str(len(body_out))
headers['Content-Type'] = scp81.GP_CT_COMMAND headers['Content-Type'] = scp81.GP_CT_COMMAND
return 200, headers, body_out return 200, headers, body_out
_BIP.log('script-done', sent=_SCP81_SCRIPT_SENT_NO, _BIP.log('script-done', sent=_SCP81_SCRIPT_SENT_NO,
results=len(_SCP81_SCRIPT_RESULTS)) results=len(_SCP81_SCRIPT_RESULTS))
headers = _scp81_response_headers() return 204, _scp81_response_headers(), b''
if _SCP81_APACHE_HEADERS:
headers['Content-Type'] = 'text/html; charset=UTF-8'
return 204, headers, b''
def _scp81_response_headers(): def _scp81_response_headers():
"""Base response headers, in the reference servers' order (Apache adds """Base response headers: only what the administration dialog needs."""
Date/Server/X-Powered-By before the admin headers).""" return {'X-Admin-Protocol': scp81.GP_PROTOCOL}
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 _parse_psk_map(raw): def _parse_psk_map(raw):
@@ -1998,7 +1983,7 @@ def _scp81_bip_control(body):
global _SCP81_LISTENER, _SCP81_PSKS, _SCP81_PSK_LEGACY global _SCP81_LISTENER, _SCP81_PSKS, _SCP81_PSK_LEGACY
global _SCP81_MODE, _SCP81_TARGET global _SCP81_MODE, _SCP81_TARGET
global _SCP81_SCRIPT_TEMPLATE, _SCP81_SCRIPT_CR_TAG, _SCP81_NEXT_URI global _SCP81_SCRIPT_TEMPLATE, _SCP81_SCRIPT_CR_TAG, _SCP81_NEXT_URI
global _SCP81_LINK_EVENTS, _SCP81_TARGETED_APP, _SCP81_APACHE_HEADERS global _SCP81_LINK_EVENTS, _SCP81_TARGETED_APP
global _SCP81_CHUNKED global _SCP81_CHUNKED
body = body or {} body = body or {}
action = body.get('action', 'start') action = body.get('action', 'start')
@@ -2018,6 +2003,9 @@ def _scp81_bip_control(body):
_SCP81_LISTENER.stop() _SCP81_LISTENER.stop()
_SCP81_LISTENER = None _SCP81_LISTENER = None
_BIP.disable() _BIP.disable()
# Channel status events (TS 102 223 7.5.11) apply to every mode: the
# terminal reports BIP link changes it detects outside proactive commands.
_SCP81_LINK_EVENTS = bool(body.get('link_events', True))
if mode == 'redirect': if mode == 'redirect':
# No local listener: the card's BIP channels are redirected straight # No local listener: the card's BIP channels are redirected straight
# to the configured target (e.g. a production HTTP OTA server), which # to the configured target (e.g. a production HTTP OTA server), which
@@ -2091,14 +2079,13 @@ def _scp81_bip_control(body):
_SCP81_SCRIPT_CR_TAG = bool(body.get('cr_tag', False)) _SCP81_SCRIPT_CR_TAG = bool(body.get('cr_tag', False))
if 'next_uri' in body: if 'next_uri' in body:
_SCP81_NEXT_URI = body.get('next_uri') or '' _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) _SCP81_TARGETED_APP = (body.get('targeted_app') or None)
# Defaults reproduce the working reference session (decrypted from # Defaults reproduce the working reference session (decrypted from
# samples/HTTP_OTA: RAM/HTTPOTA_test5.pcap): one keep-alive connection, # samples/HTTP_OTA: RAM/HTTPOTA_test5.pcap): one keep-alive connection,
# Apache-style response headers, a chunked body whose script sits in # a chunked body whose script sits in one TLS record, no Connection
# one TLS record, no Connection header, and an X-Admin-Next-URI with a # header, and an X-Admin-Next-URI with a query whose command id
# query whose command id increments. Overrides remain available. # increments. Overrides remain available; TLS is automatic (all
_SCP81_APACHE_HEADERS = bool(body.get('apache_headers', True)) # versions/ciphers the server can speak, negotiated per card).
_SCP81_CHUNKED = bool(body.get('chunked', True)) _SCP81_CHUNKED = bool(body.get('chunked', True))
cs = body.get('chunk_size') cs = body.get('chunk_size')
chunk_size = int(cs) if cs not in (None, '') else 0 chunk_size = int(cs) if cs not in (None, '') else 0
@@ -2109,7 +2096,7 @@ def _scp81_bip_control(body):
chunk_size=chunk_size, chunk_size=chunk_size,
keep_alive=bool(body.get('keep_alive', True)), keep_alive=bool(body.get('keep_alive', True)),
compact_headers=bool(body.get('compact_headers', False)), compact_headers=bool(body.get('compact_headers', False)),
tls_version=str(body.get('tls_version') or '1.2'), tls_version=str(body.get('tls_version') or 'auto'),
cipher=(body.get('cipher') or None), cipher=(body.get('cipher') or None),
on_before_close=_scp81_wait_drained, on_before_close=_scp81_wait_drained,
keylog=(body.get('keylog') or None), keylog=(body.get('keylog') or None),
@@ -2128,7 +2115,6 @@ def _scp81_bip_control(body):
'script_template': _SCP81_SCRIPT_TEMPLATE, 'script_template': _SCP81_SCRIPT_TEMPLATE,
'cr_tag': _SCP81_SCRIPT_CR_TAG, 'link_events': _SCP81_LINK_EVENTS, 'cr_tag': _SCP81_SCRIPT_CR_TAG, 'link_events': _SCP81_LINK_EVENTS,
'targeted_app': _SCP81_TARGETED_APP, 'targeted_app': _SCP81_TARGETED_APP,
'apache_headers': _SCP81_APACHE_HEADERS,
'chunked': _SCP81_CHUNKED} 'chunked': _SCP81_CHUNKED}
if mode != 'dump': if mode != 'dump':
return {'ok': False, 'error': 'unsupported mode: %s' % mode} return {'ok': False, 'error': 'unsupported mode: %s' % mode}
+118 -17
View File
@@ -99,6 +99,26 @@ class PskTlsServerTest(unittest.TestCase):
sock.close() sock.close()
raise raise
def test_tls_version_auto_is_permissive(self):
# TLS is automatic by default: accept TLS 1.0-1.2, OpenSSL picks the
# highest the card offers.
srv = scp81.PskTlsServer('127.0.0.1', 0, psk_map={IDENT: PSK})
try:
self.assertEqual(srv.tls_version, 'auto')
self.assertEqual(srv.ctx.minimum_version, ssl.TLSVersion.TLSv1)
self.assertEqual(srv.ctx.maximum_version, ssl.TLSVersion.TLSv1_2)
finally:
srv.stop()
def test_tls_version_pin_remains_available(self):
srv = scp81.PskTlsServer('127.0.0.1', 0, psk_map={IDENT: PSK},
tls_version='1.0')
try:
self.assertEqual(srv.ctx.minimum_version, ssl.TLSVersion.TLSv1)
self.assertEqual(srv.ctx.maximum_version, ssl.TLSVersion.TLSv1)
finally:
srv.stop()
def test_handshake_and_204_session(self): def test_handshake_and_204_session(self):
logs = [] logs = []
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, identity=IDENT, srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, identity=IDENT,
@@ -630,6 +650,77 @@ class BipControlTest(unittest.TestCase):
# the key never leaves the server # the key never leaves the server
self.assertNotIn('psk_hex', listener) self.assertNotIn('psk_hex', listener)
def test_tls_defaults_and_framing_options(self):
try:
resp = server._scp81_bip_control({
'action': 'start', 'mode': 'tls', 'host': '127.0.0.1', 'port': 0,
'psk_hex': '00112233', 'psk_identity': 'id-1',
'chunked': False, 'chunk_size': 100, 'keep_alive': False,
'compact_headers': True, 'conn_header': 'close', 'next_uri': '',
'script_template': 'definite', 'cr_tag': True,
'targeted_app': '//aid/A000000151000000', 'link_events': False,
})
self.assertTrue(resp['ok'], resp)
listener = resp['listener']
# TLS is automatic (no version/cipher setting in the PWA)
self.assertEqual(listener['tls_version'], 'auto')
self.assertIn('version_seen', listener)
self.assertIn('cipher_seen', listener)
self.assertEqual((listener['chunked'], listener['chunk_size'],
listener['keep_alive'], listener['compact_headers']),
(False, 100, False, True))
self.assertEqual(server._SCP81_SCRIPT_TEMPLATE, 'definite')
self.assertTrue(server._SCP81_SCRIPT_CR_TAG)
self.assertEqual(server._SCP81_TARGETED_APP, '//aid/A000000151000000')
self.assertEqual(server._SCP81_NEXT_URI, '')
self.assertFalse(server._SCP81_LINK_EVENTS)
self.assertEqual(resp['script_template'], 'definite')
self.assertTrue(resp['cr_tag'])
self.assertFalse(resp['link_events'])
self.assertNotIn('apache_headers', resp)
finally:
server._SCP81_SCRIPT_TEMPLATE = 'indefinite'
server._SCP81_SCRIPT_CR_TAG = False
server._SCP81_TARGETED_APP = None
server._SCP81_NEXT_URI = None
server._SCP81_LINK_EVENTS = True
def test_link_events_apply_to_every_mode(self):
# TS 102 223 7.5.11 events are a BIP-layer feature, not a TLS option.
resp = server._scp81_bip_control({'action': 'start', 'mode': 'redirect',
'host': '10.11.12.13', 'port': 10174,
'link_events': False})
self.assertTrue(resp['ok'], resp)
self.assertFalse(server._SCP81_LINK_EVENTS)
resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru',
'link_events': True})
self.assertTrue(resp['ok'], resp)
self.assertTrue(server._SCP81_LINK_EVENTS)
def test_tls_handshake_failure_is_logged(self):
resp = server._scp81_bip_control({'action': 'start', 'mode': 'tls',
'host': '127.0.0.1', 'port': 0,
'psk_hex': '00112233', 'psk_identity': 'id-1'})
self.assertTrue(resp['ok'], resp)
port = resp['listener']['port']
sock = socket.create_connection(('127.0.0.1', port), timeout=2)
try:
sock.sendall(b'this is not a tls hello')
sock.settimeout(2)
try:
sock.recv(64)
except OSError:
pass
finally:
sock.close()
kinds = []
for _ in range(40):
kinds = [e['kind'] for e in server._BIP.entries_after(0)]
if 'tls-handshake-failed' in kinds:
break
time.sleep(0.05)
self.assertIn('tls-handshake-failed', kinds)
def test_psk_map_start_and_update(self): def test_psk_map_start_and_update(self):
server._SCP81_PSKS = {} server._SCP81_PSKS = {}
server._SCP81_PSK_LEGACY = None server._SCP81_PSK_LEGACY = None
@@ -944,46 +1035,56 @@ class TargetedAppTest(unittest.TestCase):
reset_script() reset_script()
server._SCP81_SCRIPT_NEXT = 0 server._SCP81_SCRIPT_NEXT = 0
def test_apache_headers(self): def test_response_headers_are_minimal(self):
# No Date/Server/X-Powered-By mimicry (dropped 2.2.14 - the reference
# server's extra headers earned nothing); only the dialog headers.
reset_script(['80CAFF2100']) reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_NEXT = 0 server._SCP81_SCRIPT_NEXT = 0
server._SCP81_APACHE_HEADERS = True
server._SCP81_CHUNKED = False server._SCP81_CHUNKED = False
try: try:
status, headers, body = server._scp81_script_responder( status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'') 'POST', '/api/scp81', {}, b'')
self.assertEqual(list(headers)[:4], self.assertEqual(list(headers),
['Date', 'Server', 'X-Powered-By', 'X-Admin-Protocol']) ['X-Admin-Protocol', 'X-Admin-Next-URI', 'Content-Type'])
self.assertEqual(headers['Content-Length'], str(len(body))) self.assertTrue(headers['X-Admin-Next-URI'].startswith('/api/scp81?req='))
out = scp81.build_http_response(status, 'OK', headers, body) out = scp81.build_http_response(status, 'OK', headers, body)
self.assertLess(out.index(b'Content-Length'), self.assertIn(b'Content-Length', out)
out.index(b'Content-Type')) for gone in (b'Date:', b'Server:', b'X-Powered-By'):
self.assertNotIn(gone, out)
finally: finally:
server._SCP81_APACHE_HEADERS = False
server._SCP81_CHUNKED = False server._SCP81_CHUNKED = False
reset_script() reset_script()
server._SCP81_SCRIPT_NEXT = 0 server._SCP81_SCRIPT_NEXT = 0
def test_chunked_apache_has_no_content_length(self): def test_session_end_204_has_only_the_admin_header(self):
# The reference (RAM/HTTPOTA_test5.pcap, decryptable) sends chunked reset_script([])
# without Content-Length, Transfer-Encoding before Content-Type. server._SCP81_SCRIPT_NEXT = 0
try:
status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'')
self.assertEqual(status, 204)
self.assertEqual(list(headers), ['X-Admin-Protocol'])
self.assertEqual(body, b'')
finally:
reset_script()
server._SCP81_SCRIPT_NEXT = 0
def test_chunked_response_has_no_content_length(self):
# A chunked response must not carry Content-Length (invalid HTTP - and
# the card rejects it); the Transfer-Encoding header is emitted by the
# HTTP builder, exactly once.
reset_script(['80CAFF2100']) reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_NEXT = 0 server._SCP81_SCRIPT_NEXT = 0
server._SCP81_APACHE_HEADERS = True
server._SCP81_CHUNKED = True server._SCP81_CHUNKED = True
try: try:
status, headers, body = server._scp81_script_responder( status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'') 'POST', '/api/scp81', {}, b'')
self.assertNotIn('Content-Length', headers) 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, out = scp81.build_http_response(status, 'OK', headers, body,
chunked=True, connection=None) chunked=True, connection=None)
self.assertNotIn(b'Content-Length', out) self.assertNotIn(b'Content-Length', out)
self.assertEqual(out.count(b'Transfer-Encoding'), 1) self.assertEqual(out.count(b'Transfer-Encoding: chunked'), 1)
finally: finally:
server._SCP81_APACHE_HEADERS = False
server._SCP81_CHUNKED = False server._SCP81_CHUNKED = False
reset_script() reset_script()
server._SCP81_SCRIPT_NEXT = 0 server._SCP81_SCRIPT_NEXT = 0