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:
@@ -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.0–1.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`).
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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-фрейминг — <strong>chunked</strong>-тело / <strong>размер чанка</strong> (0 = весь ответ в одной TLS-записи), keep-alive, заголовок Connection, компактные заголовки и Next-URI (снято — заголовок не отправляется) — и фрейминг скрипта (неопределённая длина <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 §7.5.11) действуют во всех режимах. У TLS нет настроек: слушатель предлагает TLS 1.0–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>«Скрипты»</strong>; выбранный список передаётся серверу при старте слушателя.</p>
|
<p class="mb-2"><strong>Сценарий</strong> выбирает список команд, отдаваемый в сессии — <strong>Нет</strong> (оставить настроенный на сервере скрипт) или один из скриптов подвкладки <strong>«Скрипты»</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>
|
||||||
|
|||||||
@@ -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 — <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) — 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 §7.5.11) apply to every mode. TLS itself has no settings: the listener offers TLS 1.0–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
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'otaman-v179';
|
const CACHE = 'otaman-v180';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
if self.tls_version == 'auto':
|
||||||
|
# Accept everything the cards speak; OpenSSL negotiates the
|
||||||
|
# highest common version.
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1
|
||||||
|
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
|
||||||
|
else:
|
||||||
ver = TLS_VERSIONS[self.tls_version]
|
ver = TLS_VERSIONS[self.tls_version]
|
||||||
ctx.minimum_version = ver
|
ctx.minimum_version = ver
|
||||||
ctx.maximum_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:
|
||||||
|
if handshake_done:
|
||||||
self.log('tls-error', peer=peer, error=str(e))
|
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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user