scp81: always reuse the connection, collapsible options, label polish (v2.2.15)

- Keep-alive is no longer a setting: the TLS connection stays open between
  POSTs for the whole dialog (the card, as HTTP client, may still dial a new
  one at any time - GP Am. B 4.3.1) and only the 204 ends it, with a clean
  close_notify while the response is still buffered, then FIN. Dropped
  keep_alive/on_before_close/_scp81_wait_drained and the Connection-header
  'close' value (the API, status and UI no longer carry a keep-alive knob).
- The Listener Options block is a collapsed <details> with a 'custom' badge
  when anything differs from the reference defaults; the Reset button moved
  into the body so it cannot toggle the panel.
- X-Admin-Targeted-Application is opt-in (checkbox + //aid/... field, field
  disabled while off); X-Admin-Next-URI has a checkbox + hint explaining the
  one-shot rule of GP Am. B 4.4.2; both dependent fields grey out when
  unchecked.
- Labels/i18n: 'Chunked body (Transfer-Encoding: chunked)' stays English,
  'Show link events (...)', 'Теги comprehension-required'; the compact-header
  wording now spells out that it omits the optional space after ':' (legal
  per RFC 7230 3.2 OWS; saves one byte per header).
- Tests: a 200 keeps the socket for the next POST; the 204 closes with a
  mutual close_notify exchange; options helper/badge unit tests; removed the
  close-per-response and drain-wait tests. SW cache otaman-v181.
This commit is contained in:
2026-09-17 09:25:23 +03:00
parent f4462c14e7
commit a59e374ff9
12 changed files with 287 additions and 268 deletions
+1 -1
View File
@@ -597,7 +597,7 @@ 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`. The **Options** block (collapsed by default; a `custom` marker appears when anything differs from the reference defaults; applied at **Start**, remembered in the browser) exposes the HTTP framing (Chunked body / chunk size, 0 = one TLS record / Connection header / compact headers, legal per RFC 7230 §3.2 / Next-URI checkbox + template, unchecked = one-shot), the script framing (indefinite `AE 80 …` or definite `AA` Command Scripting template, comprehension-required tags, optional `X-Admin-Targeted-Application` with its own checkbox) and **Show link events** (all modes, TS 102 223 §7.5.11). The connection stays open between POSTs and closes cleanly at the 204. 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.
+1 -1
View File
@@ -567,7 +567,7 @@ 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`. Блок **Настройки** (свёрнут по умолчанию, метка «изменены» при отличиях; применяются при **Start**, сохраняются в браузере) открывает HTTP-фрейминг (Chunked body / размер чанка, 0 = одна TLS-запись / заголовок Connection / компактные заголовки, допустимо по RFC 7230 §3.2 / Next-URI: флажок + шаблон, снято — одноразовый режим), фрейминг скрипта (неопределённая `AE 80 …` или определённая `AA` длина Command Scripting template, теги comprehension-required, необязательный `X-Admin-Targeted-Application` со своим флажком) и **Показывать события канала** (во всех режимах, TS 102 223 §7.5.11). Соединение удерживается между POST-запросами и аккуратно закрывается на 204. У 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.
+20 -15
View File
@@ -672,16 +672,20 @@ record, as in the decrypted reference session; a positive value writes the
head and each body piece as its own record). Both are echoed by head and each body piece as its own record). Both are echoed by
`GET /api/scp81/status`. `GET /api/scp81/status`.
`keep_alive` (default `true`, matching the reference session: the card sends The connection stays open between POSTs for the whole dialog (the card is the
all its POSTs on one connection until the 204) ends the TLS connection after HTTP client and may reuse it or dial a new one at will — GP Am. B 4.3.1 leaves
each response connection management to the SD); only the 204 ends the session, and the server
(after the card drained the BIP buffer, with `close_notify`, so the card then shuts the TLS session down cleanly (`close_notify` while the response is
processes the script and opens a new connection for its next POST); still buffered, then FIN). There is no option to close after every response.
`compact_headers` (default `false`) drops the space after each header colon,
`conn_header` (default `'none'` = omit the header) declares the connection `compact_headers` (default `false`) omits the optional space after each header
fate, `tls_version` (default `'auto'` — accept TLS 1.0-1.2 and let OpenSSL colon — legal per RFC 7230 3.2 (OWS), though a single SP is preferred, and it
pick the highest the card offers; `'1.0'`/`'1.1'`/`'1.2'` pin a version for saves one byte per header (useful to keep a response within one card-sized TLS
debugging) selects the protocol window, `cipher` pins one suite, `next_uri` record); `conn_header` (default `'none'` = omit the header; `'keep-alive'` adds
it) declares the connection fate, `tls_version` (default `'auto'` — accept TLS
1.0-1.2 and let OpenSSL pick the highest the card offers; `'1.0'`/`'1.1'`/`'1.2'`
pin a version for debugging) selects the protocol window, `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`, all modes) controls the omits the header), `link_events` (default `true`, all modes) controls the
automatic Channel status events, `answer_delay` waits before answering a automatic Channel status events, `answer_delay` waits before answering a
@@ -702,8 +706,9 @@ reasons (no shared cipher, unsupported protocol version, card alert) is logged
as `tls-handshake-failed` with the OpenSSL reason, separately from as `tls-handshake-failed` with the OpenSSL reason, separately from
post-handshake record errors (`tls-error`). post-handshake record errors (`tls-error`).
The PWA's Listener **Options** block exposes `chunked`, `chunk_size`, The PWA's Listener **Options** block (collapsed by default; a `custom` marker
`keep_alive`, `conn_header`, `compact_headers`, `next_uri`, appears when anything differs from the reference defaults) exposes `chunked`,
`script_template`, `cr_tag`, `targeted_app` and `link_events` `chunk_size`, `conn_header`, `compact_headers`, `next_uri` (checkbox + template),
(applied at Start, persisted in `localStorage`); `tls_version`, `cipher`, `script_template`, `cr_tag`, `targeted_app` (checkbox + `//aid/...` field) and
`keylog` and `answer_delay` stay API-only. `link_events` (applied at Start, persisted in `localStorage`); `tls_version`,
`cipher`, `keylog` and `answer_delay` stay API-only.
+1 -1
View File
@@ -327,7 +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> (по умолчанию свёрнут; при отличии от эталонных значений показывается метка <em>изменены</em>; применяются при <strong>Start</strong>, сохраняются в браузере) открывает HTTP-фрейминг &mdash; <strong>Chunked body</strong> / <strong>размер чанка</strong> (0 = весь ответ в одной TLS-записи), заголовок Connection, компактные заголовки (без необязательного пробела после <code class="font-mono text-sm">:</code>, допустимо по RFC 7230 &sect;3.2) и Next-URI (флажок + шаблон; снято — одноразовый режим: карта выполнит команду, не вернёт строку ответа и закроет сессию) &mdash; и фрейминг скрипта (неопределённая длина <code class="font-mono text-sm">AE 80 …</code> / определённая <code class="font-mono text-sm">AA</code> в Command Scripting template, теги comprehension-required и необязательный <code class="font-mono text-sm">X-Admin-Targeted-Application</code> со своим флажком). <strong>Показывать события канала</strong> (ENVELOPE Channel status, TS 102 223 &sect;7.5.11) действует во всех режимах. Соединение удерживается между POST-запросами и аккуратно закрывается на 204. У 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 -1
View File
@@ -327,7 +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">The <strong>Options</strong> block (collapsed by default; a <em>custom</em> marker appears when anything differs from the reference defaults; applied at <strong>Start</strong>, remembered in the browser) exposes the HTTP framing &mdash; <strong>Chunked body</strong> / <strong>chunk size</strong> (0 = the whole response in one TLS record), the Connection header, compact headers (omitting the optional space after <code class="font-mono text-sm">:</code>, legal per RFC 7230 &sect;3.2) and the Next-URI (checkbox + template; unchecked = one-shot: the card executes the command, returns no response string and closes the session) &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, and the optional <code class="font-mono text-sm">X-Admin-Targeted-Application</code> with its own checkbox). <strong>Show link events</strong> (Channel status ENVELOPEs, TS 102 223 &sect;7.5.11) applies to every mode. The connection is kept open between POSTs and closed cleanly at the 204. 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>
+134 -75
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.14</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.15</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,44 +995,49 @@
<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"> <details id="scp81-opts" class="border border-gray-200 dark:border-slate-700 rounded mb-3">
<div class="flex flex-wrap items-center gap-2 mb-2"> <summary class="p-3 cursor-pointer select-none text-sm text-gray-500 dark:text-slate-400">
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="Options (applied at Start)">Options (applied at Start)</span> <span 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> <span id="scp81-opts-badge" class="ml-1 text-xs text-amber-500 dark:text-amber-400"></span>
</div> </summary>
<div id="scp81-opts-http" class="space-y-2"> <div class="px-3 pb-3">
<div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="HTTP framing">HTTP framing</div> <div class="flex items-center gap-2 mb-2">
<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> <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 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> </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 id="scp81-opts-http" class="space-y-2">
<div class="flex flex-wrap items-center gap-2"> <div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="HTTP framing">HTTP framing</div>
<label class="text-xs text-gray-600 dark:text-slate-400" data-l10n="Connection header">Connection header</label> <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>
<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"> <div class="flex flex-wrap items-center gap-2">
<option value="none" data-l10n="omit (implicit keep-alive)">omit (implicit keep-alive)</option> <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>
<option value="close">close</option> <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">
<option value="keep-alive">keep-alive</option> </div>
</select> <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="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 (omit the optional space after ':')">Compact headers (omit the optional 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="scp81NextUriToggle()" class="rounded"> <span data-l10n="Send X-Admin-Next-URI (required to continue the session)">Send X-Admin-Next-URI (required to continue the session)</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 class="text-xs text-gray-400 dark:text-slate-500" data-l10n="Unchecked = one-shot: per GP Am. B §4.4.2 the card executes the command, returns no response string and closes the session.">Unchecked = one-shot: per GP Am. B §4.4.2 the card executes the command, returns no response string and closes the session.</div>
</div> </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> <div id="scp81-opts-script" class="space-y-2 mt-3">
<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> <div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Script framing">Script framing</div>
<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 class="flex flex-wrap items-center gap-2">
</div> <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">
<div id="scp81-opts-script" class="space-y-2 mt-3"> <option value="indefinite" data-l10n="Indefinite (AE 80 … 00 00)">Indefinite (AE 80 … 00 00)</option>
<div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Script framing">Script framing</div> <option value="definite" data-l10n="Definite (AA)">Definite (AA)</option>
<div class="flex flex-wrap items-center gap-2"> </select>
<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"> <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>
<option value="indefinite" data-l10n="Indefinite (AE 80 … 00 00)">Indefinite (AE 80 … 00 00)</option> </div>
<option value="definite" data-l10n="Definite (AA)">Definite (AA)</option> <label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-targeted-app-on" onchange="scp81TargetedAppToggle()" class="rounded"> <span data-l10n="Send X-Admin-Targeted-Application">Send X-Admin-Targeted-Application</span></label>
</select> <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="//aid/A000000151000000">
<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> </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"> <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="Show link events (Channel status ENVELOPEs, TS 102 223 7.5.11)">Show link events (Channel status ENVELOPEs, TS 102 223 7.5.11)</span></label>
</div> </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> </details>
</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>
@@ -7810,58 +7815,102 @@ let _scp81LastStatus = null;
// ===== SCP81 listener options (HTTP framing / script framing / link events) ===== // ===== SCP81 listener options (HTTP framing / script framing / link events) =====
// The API takes these at listener start; they are kept in localStorage so a // 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 // test setup survives reloads. The block is collapsed by default and shows a
// accepts everything it can and reports the negotiated version/cipher. // 'custom' marker when anything differs from the reference defaults. TLS
// itself has no settings: the listener accepts everything it can and reports
// the negotiated version/cipher.
const SCP81_OPT_DEFAULTS = { const SCP81_OPT_DEFAULTS = {
chunked: true, chunkSize: 0, keepAlive: true, connHeader: 'none', chunked: true, chunkSize: 0, connHeader: 'none', compact: false,
compact: false, nextUri: '/api/scp81?req=%d', linkEvents: true, nextUriOn: true, nextUri: '/api/scp81?req=%d', linkEvents: true,
scriptTemplate: 'indefinite', crTag: false, targetedApp: '' scriptTemplate: 'indefinite', crTag: false,
targetedAppOn: false, targetedApp: ''
}; };
function scp81OptionsState() {
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'),
chunkSize: Math.max(0, parseInt(val('opt-chunk-size'), 10) || 0),
connHeader: val('opt-conn-header') || 'none',
compact: on('opt-compact'),
nextUriOn: on('opt-next-uri'),
nextUri: val('opt-next-uri-value').trim(),
linkEvents: on('opt-link-events'),
scriptTemplate: val('opt-script-template') === 'definite' ? 'definite' : 'indefinite',
crTag: on('opt-cr-tag'),
targetedAppOn: on('opt-targeted-app-on'),
targetedApp: val('opt-targeted-app').trim()
};
}
function scp81OptionsFromForm() {
const s = scp81OptionsState();
return {
chunked: s.chunked,
chunk_size: s.chunkSize,
conn_header: s.connHeader,
compact_headers: s.compact,
next_uri: s.nextUriOn ? (s.nextUri || SCP81_OPT_DEFAULTS.nextUri) : '',
link_events: s.linkEvents,
script_template: s.scriptTemplate,
cr_tag: s.crTag,
targeted_app: s.targetedAppOn ? s.targetedApp : ''
};
}
function scp81OptionsSyncDisabled() {
const on = id => { const el = document.getElementById(id); return !!(el && el.checked); };
const sync = (id, enabled) => {
const el = document.getElementById(id);
if (!el) return;
el.disabled = !enabled;
el.classList.toggle('opacity-40', !enabled);
};
sync('opt-next-uri-value', on('opt-next-uri'));
sync('opt-targeted-app', on('opt-targeted-app-on'));
}
function scp81OptionsBadge() {
const el = document.getElementById('scp81-opts-badge');
if (!el) return;
const s = scp81OptionsState();
const d = SCP81_OPT_DEFAULTS;
const same = s.chunked === d.chunked && s.chunkSize === d.chunkSize &&
s.connHeader === d.connHeader && s.compact === d.compact &&
s.nextUriOn === d.nextUriOn && (s.nextUri || d.nextUri) === d.nextUri &&
s.linkEvents === d.linkEvents && s.scriptTemplate === d.scriptTemplate &&
s.crTag === d.crTag && s.targetedAppOn === d.targetedAppOn &&
s.targetedApp === d.targetedApp;
el.textContent = same ? '' : t('custom');
}
function scp81OptionsLoad() { function scp81OptionsLoad() {
let saved = {}; let saved = {};
try { saved = JSON.parse(localStorage.getItem('otaman_scp81_opts') || '{}') || {}; } catch (e) { saved = {}; } try { saved = JSON.parse(localStorage.getItem('otaman_scp81_opts') || '{}') || {}; } catch (e) { saved = {}; }
const opt = Object.assign({}, SCP81_OPT_DEFAULTS, saved); const opt = Object.assign({}, SCP81_OPT_DEFAULTS, saved);
if (saved.nextUriOn === undefined) opt.nextUriOn = (opt.nextUri || '') !== '';
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; }; 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; }; const chk = (id, v) => { const el = document.getElementById(id); if (el) el.checked = !!v; };
chk('opt-chunked', opt.chunked); chk('opt-chunked', opt.chunked);
set('opt-chunk-size', opt.chunkSize); set('opt-chunk-size', opt.chunkSize);
chk('opt-keep-alive', opt.keepAlive); set('opt-conn-header', opt.connHeader === 'keep-alive' ? 'keep-alive' : 'none');
set('opt-conn-header', opt.connHeader);
chk('opt-compact', opt.compact); chk('opt-compact', opt.compact);
chk('opt-next-uri', opt.nextUri !== ''); chk('opt-next-uri', opt.nextUriOn);
set('opt-next-uri-value', opt.nextUri || SCP81_OPT_DEFAULTS.nextUri); set('opt-next-uri-value', opt.nextUri || SCP81_OPT_DEFAULTS.nextUri);
chk('opt-link-events', opt.linkEvents); chk('opt-link-events', opt.linkEvents);
set('opt-script-template', opt.scriptTemplate === 'definite' ? 'definite' : 'indefinite'); set('opt-script-template', opt.scriptTemplate === 'definite' ? 'definite' : 'indefinite');
chk('opt-cr-tag', opt.crTag); chk('opt-cr-tag', opt.crTag);
chk('opt-targeted-app-on', opt.targetedAppOn);
set('opt-targeted-app', opt.targetedApp); set('opt-targeted-app', opt.targetedApp);
} scp81OptionsSyncDisabled();
scp81OptionsBadge();
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() { function scp81OptionsPersist() {
const o = scp81OptionsFromForm(); localStorage.setItem('otaman_scp81_opts', JSON.stringify(scp81OptionsState()));
localStorage.setItem('otaman_scp81_opts', JSON.stringify({ scp81OptionsSyncDisabled();
chunked: o.chunked, chunkSize: o.chunk_size, keepAlive: o.keep_alive, scp81OptionsBadge();
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() { function scp81OptionsReset() {
@@ -7869,6 +7918,16 @@ function scp81OptionsReset() {
scp81OptionsLoad(); scp81OptionsLoad();
} }
function scp81NextUriToggle() {
scp81OptionsSyncDisabled();
scp81OptionsPersist();
}
function scp81TargetedAppToggle() {
scp81OptionsSyncDisabled();
scp81OptionsPersist();
}
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');
@@ -8242,7 +8301,6 @@ async function scp81StatusRefresh() {
s += ' | ' + (l.chunked s += ' | ' + (l.chunked
? ('chunked' + (l.chunk_size ? ' ' + l.chunk_size : '')) ? ('chunked' + (l.chunk_size ? ' ' + l.chunk_size : ''))
: 'content-length'); : '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;
@@ -8289,7 +8347,6 @@ async function scp81Start() {
if (mode === 'tls') { if (mode === 'tls') {
body.chunked = opts.chunked; body.chunked = opts.chunked;
body.chunk_size = opts.chunk_size; body.chunk_size = opts.chunk_size;
body.keep_alive = opts.keep_alive;
body.conn_header = opts.conn_header; body.conn_header = opts.conn_header;
body.compact_headers = opts.compact_headers; body.compact_headers = opts.compact_headers;
body.next_uri = opts.next_uri; body.next_uri = opts.next_uri;
@@ -11579,18 +11636,20 @@ const LANG_RU = {
'Options (applied at Start)': 'Настройки (применяются при запуске)', 'Options (applied at Start)': 'Настройки (применяются при запуске)',
'Reset to defaults': 'Сбросить к умолчаниям', 'Reset to defaults': 'Сбросить к умолчаниям',
'HTTP framing': 'HTTP-фрейминг', 'HTTP framing': 'HTTP-фрейминг',
'Chunked body (Transfer-Encoding: chunked)': 'Chunked-тело (Transfer-Encoding: chunked)', 'Chunked body (Transfer-Encoding: chunked)': 'Chunked body (Transfer-Encoding: chunked)',
'Chunk size (bytes, 0 = one TLS record)': 'Размер чанка (байт, 0 = одна TLS-запись)', 'Chunk size (bytes, 0 = one TLS record)': 'Размер чанка (байт, 0 = одна TLS-запись)',
'Keep-alive (one connection until the session ends)': 'Keep-alive (одно соединение до конца сессии)',
'Connection header': 'Заголовок Connection', 'Connection header': 'Заголовок Connection',
'omit (implicit keep-alive)': 'не отправлять (неявный keep-alive)', 'omit (implicit keep-alive)': 'не отправлять (неявный keep-alive)',
"Compact headers (no space after ':')": 'Компактные заголовки (без пробела после «:»)', "Compact headers (omit the optional space after ':')": 'Компактные заголовки (без необязательного пробела после «:»)',
'Next-URI (unchecked = omit the header)': 'Next-URI (снято — заголовок не отправляется)', 'Send X-Admin-Next-URI (required to continue the session)': 'Отправлять X-Admin-Next-URI (нужен для продолжения сессии)',
'Unchecked = one-shot: per GP Am. B §4.4.2 the card executes the command, returns no response string and closes the session.': 'Снято — одноразовый режим: по GP Am. B §4.4.2 карта выполнит команду, не вернёт строку ответа и закроет сессию.',
'Send X-Admin-Targeted-Application': 'Отправлять X-Admin-Targeted-Application',
'custom': 'изменены',
'Script framing': 'Фрейминг скрипта', 'Script framing': 'Фрейминг скрипта',
'Indefinite (AE 80 … 00 00)': 'Неопределённая длина (AE 80 … 00 00)', 'Indefinite (AE 80 … 00 00)': 'Неопределённая длина (AE 80 … 00 00)',
'Definite (AA)': 'Определённая длина (AA)', 'Definite (AA)': 'Определённая длина (AA)',
'Comprehension-required tags': 'Теги с обязательным пониманием (CR)', 'Comprehension-required tags': 'Теги comprehension-required',
'Link events (Channel status ENVELOPEs, TS 102 223 7.5.11)': 'События канала (ENVELOPE Channel status, TS 102 223 7.5.11)', 'Show 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-v180'; const CACHE = 'otaman-v181';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+78 -25
View File
@@ -22,16 +22,18 @@ function extractFunc(src, name) {
} }
let code = html.match(/const SCP81_OPT_DEFAULTS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n'; let code = html.match(/const SCP81_OPT_DEFAULTS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
for (const fn of ['scp81OptionsLoad', 'scp81OptionsFromForm', 'scp81OptionsPersist', 'scp81OptionsReset']) { for (const fn of ['scp81OptionsState', 'scp81OptionsFromForm', 'scp81OptionsSyncDisabled',
'scp81OptionsBadge', 'scp81OptionsLoad', 'scp81OptionsPersist', 'scp81OptionsReset',
'scp81NextUriToggle', 'scp81TargetedAppToggle']) {
code += extractFunc(html, fn) + '\n'; code += extractFunc(html, fn) + '\n';
} }
code += 'globalThis.t = s => s;\n';
eval(code); eval(code);
function setup(opts) { function setup(opts) {
const spec = Object.assign({ const spec = Object.assign({
'opt-chunked': true, 'opt-chunked': true,
'opt-chunk-size': 0, 'opt-chunk-size': 0,
'opt-keep-alive': true,
'opt-conn-header': 'none', 'opt-conn-header': 'none',
'opt-compact': false, 'opt-compact': false,
'opt-next-uri': true, 'opt-next-uri': true,
@@ -39,11 +41,20 @@ function setup(opts) {
'opt-link-events': true, 'opt-link-events': true,
'opt-script-template': 'indefinite', 'opt-script-template': 'indefinite',
'opt-cr-tag': false, 'opt-cr-tag': false,
'opt-targeted-app': '' 'opt-targeted-app-on': false,
'opt-targeted-app': '',
'scp81-opts-badge': ''
}, opts || {}); }, opts || {});
const els = {}; const els = {};
for (const [id, v] of Object.entries(spec)) { for (const [id, v] of Object.entries(spec)) {
els[id] = typeof v === 'boolean' ? { checked: v, value: '' } : { checked: true, value: String(v) }; const checkbox = typeof v === 'boolean';
els[id] = {
checked: checkbox ? v : true,
value: checkbox ? '' : String(v),
disabled: false,
textContent: '',
classList: { toggle() {} },
};
} }
globalThis.document = { getElementById: id => els[id] || null }; globalThis.document = { getElementById: id => els[id] || null };
return els; return els;
@@ -65,7 +76,6 @@ test('scp81OptionsFromForm maps the reference defaults', () => {
assert.deepStrictEqual(scp81OptionsFromForm(), { assert.deepStrictEqual(scp81OptionsFromForm(), {
chunked: true, chunked: true,
chunk_size: 0, chunk_size: 0,
keep_alive: true,
conn_header: 'none', conn_header: 'none',
compact_headers: false, compact_headers: false,
next_uri: '/api/scp81?req=%d', next_uri: '/api/scp81?req=%d',
@@ -80,20 +90,19 @@ test('scp81OptionsFromForm reflects a changed setup', () => {
setup({ setup({
'opt-chunked': false, 'opt-chunked': false,
'opt-chunk-size': '100', 'opt-chunk-size': '100',
'opt-keep-alive': false, 'opt-conn-header': 'keep-alive',
'opt-conn-header': 'close',
'opt-compact': true, 'opt-compact': true,
'opt-next-uri-value': '/adminserver?apdu_id=%d', 'opt-next-uri-value': '/adminserver?apdu_id=%d',
'opt-link-events': false, 'opt-link-events': false,
'opt-script-template': 'definite', 'opt-script-template': 'definite',
'opt-cr-tag': true, 'opt-cr-tag': true,
'opt-targeted-app-on': true,
'opt-targeted-app': ' //aid/A000000151000000 ' 'opt-targeted-app': ' //aid/A000000151000000 '
}); });
assert.deepStrictEqual(scp81OptionsFromForm(), { assert.deepStrictEqual(scp81OptionsFromForm(), {
chunked: false, chunked: false,
chunk_size: 100, chunk_size: 100,
keep_alive: false, conn_header: 'keep-alive',
conn_header: 'close',
compact_headers: true, compact_headers: true,
next_uri: '/adminserver?apdu_id=%d', next_uri: '/adminserver?apdu_id=%d',
link_events: false, link_events: false,
@@ -110,51 +119,95 @@ test('unchecked Next-URI omits the header; empty text falls back to the template
assert.strictEqual(scp81OptionsFromForm().next_uri, '/api/scp81?req=%d'); assert.strictEqual(scp81OptionsFromForm().next_uri, '/api/scp81?req=%d');
}); });
test('X-Admin-Targeted-Application is only sent when its checkbox is ticked', () => {
// text left in the box with the checkbox off must never leak out
setup({ 'opt-targeted-app-on': false, 'opt-targeted-app': '//aid/A000000151000000' });
assert.strictEqual(scp81OptionsFromForm().targeted_app, '');
setup({ 'opt-targeted-app-on': true, 'opt-targeted-app': ' //aid/A000000151000000 ' });
assert.strictEqual(scp81OptionsFromForm().targeted_app, '//aid/A000000151000000');
});
test('the dependent fields are disabled while their checkbox is off', () => {
const els = setup({ 'opt-next-uri': false, 'opt-targeted-app-on': false });
scp81OptionsSyncDisabled();
assert.strictEqual(els['opt-next-uri-value'].disabled, true);
assert.strictEqual(els['opt-targeted-app'].disabled, true);
els['opt-next-uri'].checked = true;
els['opt-targeted-app-on'].checked = true;
scp81OptionsSyncDisabled();
assert.strictEqual(els['opt-next-uri-value'].disabled, false);
assert.strictEqual(els['opt-targeted-app'].disabled, false);
});
test('the custom badge flags anything that differs from the defaults', () => {
const els = setup();
scp81OptionsBadge();
assert.strictEqual(els['scp81-opts-badge'].textContent, '');
setup({ 'opt-compact': true, 'scp81-opts-badge': '' });
scp81OptionsBadge();
assert.strictEqual(globalThis.document.getElementById('scp81-opts-badge').textContent, 'custom');
// an empty Next-URI text is not a change (the default template applies)
setup({ 'opt-next-uri-value': '', 'scp81-opts-badge': '' });
scp81OptionsBadge();
assert.strictEqual(globalThis.document.getElementById('scp81-opts-badge').textContent, '');
});
test('load/persist/reset round-trip through localStorage', () => { test('load/persist/reset round-trip through localStorage', () => {
const store = fakeStorage(); const store = fakeStorage();
setup({ setup({
'opt-chunked': false, 'opt-chunked': false,
'opt-chunk-size': 100, 'opt-chunk-size': 100,
'opt-keep-alive': false, 'opt-conn-header': 'keep-alive',
'opt-conn-header': 'close',
'opt-compact': true, 'opt-compact': true,
'opt-next-uri': false, 'opt-next-uri': false,
'opt-link-events': false, 'opt-link-events': false,
'opt-script-template': 'definite', 'opt-script-template': 'definite',
'opt-cr-tag': true, 'opt-cr-tag': true,
'opt-targeted-app-on': true,
'opt-targeted-app': '//aid/A000000151000000' 'opt-targeted-app': '//aid/A000000151000000'
}); });
scp81OptionsPersist(); scp81OptionsPersist();
assert.ok(store['otaman_scp81_opts'].includes('"chunkSize":100')); assert.ok(store['otaman_scp81_opts'].includes('"chunkSize":100'));
assert.ok(store['otaman_scp81_opts'].includes('"targetedAppOn":true'));
assert.ok(!store['otaman_scp81_opts'].includes('keepAlive'));
// a reload restores the saved setup // a reload restores the saved setup
setup({}); setup({});
scp81OptionsLoad(); scp81OptionsLoad();
assert.strictEqual(globalThis.document.getElementById('opt-chunked').checked, false); const get = id => globalThis.document.getElementById(id);
assert.strictEqual(globalThis.document.getElementById('opt-chunk-size').value, 100); assert.strictEqual(get('opt-chunked').checked, false);
assert.strictEqual(globalThis.document.getElementById('opt-conn-header').value, 'close'); assert.strictEqual(get('opt-chunk-size').value, 100);
assert.strictEqual(globalThis.document.getElementById('opt-next-uri').checked, false); assert.strictEqual(get('opt-conn-header').value, 'keep-alive');
assert.strictEqual(globalThis.document.getElementById('opt-script-template').value, 'definite'); assert.strictEqual(get('opt-next-uri').checked, false);
assert.strictEqual(globalThis.document.getElementById('opt-cr-tag').checked, true); assert.strictEqual(get('opt-script-template').value, 'definite');
assert.strictEqual(globalThis.document.getElementById('opt-targeted-app').value, '//aid/A000000151000000'); assert.strictEqual(get('opt-cr-tag').checked, true);
assert.strictEqual(get('opt-targeted-app-on').checked, true);
assert.strictEqual(get('opt-targeted-app').value, '//aid/A000000151000000');
// reset clears the saved entry and restores the defaults // reset clears the saved entry and restores the defaults
scp81OptionsReset(); scp81OptionsReset();
assert.ok(!('otaman_scp81_opts' in store)); assert.ok(!('otaman_scp81_opts' in store));
scp81OptionsLoad(); scp81OptionsLoad();
assert.strictEqual(globalThis.document.getElementById('opt-chunked').checked, true); assert.strictEqual(get('opt-chunked').checked, true);
assert.strictEqual(globalThis.document.getElementById('opt-next-uri').checked, true); assert.strictEqual(get('opt-next-uri').checked, true);
assert.strictEqual(globalThis.document.getElementById('opt-conn-header').value, 'none'); assert.strictEqual(get('opt-conn-header').value, 'none');
assert.strictEqual(get('opt-targeted-app-on').checked, false);
}); });
test('the Listener UI wires the framing options into Start', () => { test('the Listener UI wires the framing options into Start', () => {
for (const id of ['scp81-opts-http', 'scp81-opts-script', 'opt-chunked', for (const id of ['scp81-opts', 'scp81-opts-badge', 'scp81-opts-http', 'scp81-opts-script',
'opt-chunk-size', 'opt-keep-alive', 'opt-conn-header', 'opt-compact', 'opt-chunked', 'opt-chunk-size', 'opt-conn-header', 'opt-compact',
'opt-next-uri', 'opt-next-uri-value', 'opt-script-template', 'opt-cr-tag', 'opt-next-uri', 'opt-next-uri-value', 'opt-script-template', 'opt-cr-tag',
'opt-targeted-app', 'opt-link-events']) { 'opt-targeted-app-on', 'opt-targeted-app', 'opt-link-events']) {
assert.ok(html.includes('id="' + id + '"'), id); assert.ok(html.includes('id="' + id + '"'), id);
} }
assert.ok(html.includes('body.link_events = opts.link_events;')); // collapsed by default: a <details> without the open attribute
assert.match(html, /<details id="scp81-opts" class="[^"]*">/);
assert.ok(!/<details id="scp81-opts"[^>]*\sopen/.test(html));
// the removed switch must not come back
assert.ok(!html.includes('opt-keep-alive'));
// the options travel in the start body
assert.ok(html.includes('body.chunk_size = opts.chunk_size;')); 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.next_uri = opts.next_uri;'));
assert.ok(html.includes('body.targeted_app = opts.targeted_app || null;'));
assert.ok(html.includes('body.script_template = opts.script_template;')); assert.ok(html.includes('body.script_template = opts.script_template;'));
assert.ok(html.includes("httpOpts.classList.toggle('hidden', mode !== 'tls')")); assert.ok(html.includes("httpOpts.classList.toggle('hidden', mode !== 'tls')"));
assert.ok(html.includes("scriptOpts.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.14" version = "2.2.15"
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.
+22 -46
View File
@@ -132,8 +132,8 @@ 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='auto', compact_headers=False, tls_version='auto',
cipher=None, on_before_close=None, keylog=None, cipher=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):
# PSK lookup table: identity -> key. With an explicit psk_map a # PSK lookup table: identity -> key. With an explicit psk_map a
@@ -159,7 +159,6 @@ class PskTlsServer:
self.chunked = chunked self.chunked = chunked
# chunk_size 0 = one record for the whole response # chunk_size 0 = one record for the whole response
self.chunk_size = int(chunk_size) self.chunk_size = int(chunk_size)
self.keep_alive = keep_alive
self.compact_headers = compact_headers self.compact_headers = compact_headers
# TLS is permissive by default: 'auto' accepts TLS 1.0-1.2 and lets # TLS is permissive by default: 'auto' accepts TLS 1.0-1.2 and lets
# OpenSSL pick the highest the card offers. The '1.0'/'1.1'/'1.2' # OpenSSL pick the highest the card offers. The '1.0'/'1.1'/'1.2'
@@ -170,17 +169,13 @@ class PskTlsServer:
# 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
# Called with the peer address just before closing a non-keep-alive
# connection: the server waits until the card has drained the BIP
# buffer, otherwise the EOF truncates the response fetch.
self.on_before_close = on_before_close
# Debug aid: write the TLS traffic secrets to this file # Debug aid: write the TLS traffic secrets to this file
# (SSLKEYLOGFILE format), so captures of the PSK dialog can be # (SSLKEYLOGFILE format), so captures of the PSK dialog can be
# 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/'none' = omit the header (implicit
# keep_alive flag), 'none' = omit the header (implicit HTTP/1.1 # HTTP/1.1 keep-alive); 'keep-alive' adds it explicitly. The server
# keep-alive). # never closes mid-session - only the 204 ends the dialog.
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
@@ -367,11 +362,7 @@ class PskTlsServer:
status, resp_headers, resp_body = self.responder( status, resp_headers, resp_body = self.responder(
method, target, headers, body) method, target, headers, body)
reason = {200: 'OK', 204: 'No Content'}.get(status, 'Status') reason = {200: 'OK', 204: 'No Content'}.get(status, 'Status')
conn_hdr = self.conn_header conn_hdr = None if self.conn_header in (None, 'none') else self.conn_header
if conn_hdr == 'none':
conn_hdr = None
elif conn_hdr is None:
conn_hdr = 'keep-alive' if self.keep_alive else 'close'
response = build_http_response( response = build_http_response(
status, reason, resp_headers, resp_body, status, reason, resp_headers, resp_body,
chunked=self.chunked, compact=self.compact_headers, chunked=self.chunked, compact=self.compact_headers,
@@ -393,38 +384,23 @@ class PskTlsServer:
bytes=len(resp_body), chunked=self.chunked, bytes=len(resp_body), chunked=self.chunked,
response_hex=response.hex().upper()[:600], response_hex=response.hex().upper()[:600],
body_hex=resp_body.hex().upper()[:2000] or None) body_hex=resp_body.hex().upper()[:2000] or None)
# 204 always ends the dialog. Without keep-alive every response # Only the end of the dialog closes the connection: 204 (or
# ends it: the card's HTTP client appears to delimit the # an empty body) ends the session; every other response
# response at connection close (live 2026-09-15) and then # leaves the TLS connection open for the card's next POST.
# starts a fresh session for its next POST. # Reusing it - or dialing a fresh one - is the card's call
if status == 204 or not resp_body or not self.keep_alive: # (GP Am. B 4.3.1: the SD manages connection establishment).
peer_name = None if status == 204 or not resp_body:
if resp_body and self.on_before_close: # Clean TLS shutdown with the response still in the BIP
try: # buffer: the card fetches the 204 and the close_notify
peer_name = tls.getpeername() # together, then the FIN. A bare close here makes the
except Exception: # card abort the session with a fatal alert.
peer_name = None
plain = None plain = None
if not self.keep_alive: try:
# Clean TLS shutdown BEFORE the card drains the tls.settimeout(2.0)
# buffer: a bare TCP close leaves the card's TLS stack plain = tls.unwrap()
# with a truncated session (it then neither processes tls = None
# the script nor posts the response), and a except Exception:
# close_notify sent only after the drain is never plain = None
# fetched. Send it while the response still waits, so
# the card reads both, then wait for the buffer to
# drain and only then send the FIN.
try:
tls.settimeout(2.0)
plain = tls.unwrap()
tls = None
except Exception:
plain = None
if peer_name and self.on_before_close:
try:
self.on_before_close(peer_name)
except Exception:
pass
if plain is not None: if plain is not None:
try: try:
plain.close() plain.close()
+1 -32
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.14' VERSION = '2.2.15'
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
@@ -1484,41 +1484,12 @@ def _scp81_listener_status():
'cipher_seen': _SCP81_LISTENER.cipher_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,
'compact_headers': _SCP81_LISTENER.compact_headers, 'compact_headers': _SCP81_LISTENER.compact_headers,
'tls_version': _SCP81_LISTENER.tls_version, 'tls_version': _SCP81_LISTENER.tls_version,
'cipher': _SCP81_LISTENER.cipher} 'cipher': _SCP81_LISTENER.cipher}
return {'mode': 'dump', 'host': _SCP81_LISTENER.host, 'port': _SCP81_LISTENER.port} return {'mode': 'dump', 'host': _SCP81_LISTENER.host, 'port': _SCP81_LISTENER.port}
def _scp81_wait_drained(peer):
"""Wait until the BIP channel for this TLS connection has delivered its
buffered bytes to the card (matched by the terminal's ephemeral port), so
a connection close does not truncate the response fetch."""
if not peer or len(peer) < 2:
return
port = peer[1]
deadline = time.time() + 5.0
seen_data = False
while time.time() < deadline:
ch = None
for c in list(_BIP.channels.values()):
try:
if c.sock.getsockname()[1] == port:
ch = c
break
except OSError:
continue
if ch is None:
return
if ch.rx:
# Channel pump has picked up the response; wait for the card.
seen_data = True
elif seen_data:
return
time.sleep(0.05)
def _bip_data_available(ch): def _bip_data_available(ch):
"""Monitor-thread callback: tell the card there is server data to fetch. """Monitor-thread callback: tell the card there is server data to fetch.
@@ -2094,11 +2065,9 @@ def _scp81_bip_control(body):
responder=_scp81_script_responder, responder=_scp81_script_responder,
chunked=bool(body.get('chunked', True)), chunked=bool(body.get('chunked', True)),
chunk_size=chunk_size, chunk_size=chunk_size,
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 'auto'), 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,
keylog=(body.get('keylog') or None), keylog=(body.get('keylog') or None),
conn_header=(body.get('conn_header') or 'none'), conn_header=(body.get('conn_header') or 'none'),
answer_delay=(body.get('answer_delay') or 0), answer_delay=(body.get('answer_delay') or 0),
+26 -69
View File
@@ -189,8 +189,7 @@ class PskTlsServerTest(unittest.TestCase):
server._SCP81_SCRIPT_NEXT = 0 server._SCP81_SCRIPT_NEXT = 0
server._SCP81_SCRIPT_RESULTS = [] server._SCP81_SCRIPT_RESULTS = []
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, srv = scp81.PskTlsServer('127.0.0.1', 0, PSK,
responder=server._scp81_script_responder, responder=server._scp81_script_responder)
keep_alive=True)
try: try:
tls = self._connect(srv) tls = self._connect(srv)
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\nHost: 127.0.0.1\r\n' tls.sendall(b'POST /api/scp81 HTTP/1.1\r\nHost: 127.0.0.1\r\n'
@@ -216,51 +215,46 @@ class PskTlsServerTest(unittest.TestCase):
server._SCP81_SCRIPT_RESULTS = [] server._SCP81_SCRIPT_RESULTS = []
srv.stop() srv.stop()
def test_response_closes_connection_without_keep_alive(self): def test_200_keeps_the_connection_for_the_next_post(self):
# Default (keep_alive=False): the card's HTTP client seems to delimit # The card is the HTTP client and may reuse the connection for its
# the response at connection close, so the server closes after each # next POST (GP Am. B 4.3.1: connection management is the SD's job);
# response and the card starts a fresh session for its next POST. # the server never closes between requests.
def responder(method, target, headers, body): def responder(method, target, headers, body):
return 200, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b'\x80\x01\x00' return 200, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b'\x80\x01\x00'
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder) srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder)
try: try:
tls = self._connect(srv) tls = self._connect(srv)
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\n\r\n') tls.sendall(b'POST /api/scp81?req=1 HTTP/1.1\r\n\r\n')
reply = self._read_http(tls)
self.assertTrue(reply.startswith(b'HTTP/1.1 200 OK'))
# same TLS session, second request
tls.sendall(b'POST /api/scp81?req=2 HTTP/1.1\r\n\r\n')
reply = self._read_http(tls) reply = self._read_http(tls)
self.assertTrue(reply.startswith(b'HTTP/1.1 200 OK')) self.assertTrue(reply.startswith(b'HTTP/1.1 200 OK'))
self.assertEqual(self._recv(tls), b'') # server closed
tls.close() tls.close()
finally: finally:
srv.stop() srv.stop()
def test_close_waits_for_drain_callback(self): def test_session_end_closes_with_close_notify(self):
# With keep_alive=False and a body, the listener calls on_before_close # Only the 204 ends the dialog; the server shuts the TLS session down
# (the server waits for the card to drain the BIP buffer) before # cleanly (close_notify while the response is still buffered) and
# closing the connection. # then closes the socket.
seen = []
def responder(method, target, headers, body): def responder(method, target, headers, body):
return 200, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b'\x80\x01\x00' return 204, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b''
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder, srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder)
on_before_close=lambda peer: seen.append(peer))
try: try:
tls = self._connect(srv) tls = self._connect(srv)
client_port = tls.getsockname()[1]
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\n\r\n') tls.sendall(b'POST /api/scp81 HTTP/1.1\r\n\r\n')
reply = self._read_http(tls) reply = self._read_http(tls)
self.assertTrue(reply.startswith(b'HTTP/1.1 200 OK')) self.assertIn(b'HTTP/1.1 204 No Content', reply)
self.assertIn(b'Connection: close', reply) # answer the server's close_notify: a mutual clean shutdown means
# The server must send close_notify (clean TLS shutdown) before # unwrap() completes instead of timing out
# closing: unwrap() succeeds only when the peer's close_notify
# has been received.
tls.settimeout(3.0) tls.settimeout(3.0)
plain = tls.unwrap() plain = tls.unwrap()
# The close comes after the drain callback: EOF proves it ran. plain.settimeout(3.0)
self.assertEqual(plain.recv(1), b'') self.assertEqual(plain.recv(64), b'')
self.assertEqual(len(seen), 1)
self.assertEqual(seen[0][1], client_port)
plain.close() plain.close()
finally: finally:
srv.stop() srv.stop()
@@ -379,8 +373,7 @@ class PskTlsServerTest(unittest.TestCase):
'Content-Type': scp81.GP_CT_COMMAND}, b'\x80\x01\x00') 'Content-Type': scp81.GP_CT_COMMAND}, b'\x80\x01\x00')
return 204, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b'' return 204, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b''
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder, srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder)
keep_alive=True)
try: try:
tls = self._connect(srv) tls = self._connect(srv)
tls.sendall(b'POST /server/adminagent?cmd=1 HTTP/1.1\r\n\r\n') tls.sendall(b'POST /server/adminagent?cmd=1 HTTP/1.1\r\n\r\n')
@@ -655,7 +648,7 @@ class BipControlTest(unittest.TestCase):
resp = server._scp81_bip_control({ resp = server._scp81_bip_control({
'action': 'start', 'mode': 'tls', 'host': '127.0.0.1', 'port': 0, 'action': 'start', 'mode': 'tls', 'host': '127.0.0.1', 'port': 0,
'psk_hex': '00112233', 'psk_identity': 'id-1', 'psk_hex': '00112233', 'psk_identity': 'id-1',
'chunked': False, 'chunk_size': 100, 'keep_alive': False, 'chunked': False, 'chunk_size': 100,
'compact_headers': True, 'conn_header': 'close', 'next_uri': '', 'compact_headers': True, 'conn_header': 'close', 'next_uri': '',
'script_template': 'definite', 'cr_tag': True, 'script_template': 'definite', 'cr_tag': True,
'targeted_app': '//aid/A000000151000000', 'link_events': False, 'targeted_app': '//aid/A000000151000000', 'link_events': False,
@@ -667,8 +660,8 @@ class BipControlTest(unittest.TestCase):
self.assertIn('version_seen', listener) self.assertIn('version_seen', listener)
self.assertIn('cipher_seen', listener) self.assertIn('cipher_seen', listener)
self.assertEqual((listener['chunked'], listener['chunk_size'], self.assertEqual((listener['chunked'], listener['chunk_size'],
listener['keep_alive'], listener['compact_headers']), listener['compact_headers']),
(False, 100, False, True)) (False, 100, True))
self.assertEqual(server._SCP81_SCRIPT_TEMPLATE, 'definite') self.assertEqual(server._SCP81_SCRIPT_TEMPLATE, 'definite')
self.assertTrue(server._SCP81_SCRIPT_CR_TAG) self.assertTrue(server._SCP81_SCRIPT_CR_TAG)
self.assertEqual(server._SCP81_TARGETED_APP, '//aid/A000000151000000') self.assertEqual(server._SCP81_TARGETED_APP, '//aid/A000000151000000')
@@ -946,42 +939,6 @@ class DataAvailableTest(unittest.TestCase):
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
class WaitDrainedTest(unittest.TestCase):
def test_wait_drained_matches_channel_by_port(self):
ch = types.SimpleNamespace(rx=bytearray(), sock=types.SimpleNamespace(
getsockname=lambda: ('127.0.0.1', 40001)))
old = server._BIP
server._BIP = types.SimpleNamespace(channels={1: ch})
try:
# unknown port / gone channel -> immediate
self.assertIsNone(server._scp81_wait_drained(('127.0.0.1', 40002)))
finally:
server._BIP = old
class WaitDrainedSlowTest(unittest.TestCase):
def test_wait_drained_waits_for_card_fetch(self):
import threading, time as _time
ch = types.SimpleNamespace(rx=bytearray(), sock=types.SimpleNamespace(
getsockname=lambda: ('127.0.0.1', 40003)))
def feed():
_time.sleep(0.15)
ch.rx.extend(b'response-bytes') # pump picks up the response
_time.sleep(0.25)
ch.rx.clear() # card fetches everything
old = server._BIP
server._BIP = types.SimpleNamespace(channels={1: ch})
th = threading.Thread(target=feed)
th.start()
t0 = _time.time()
try:
server._scp81_wait_drained(('127.0.0.1', 40003))
finally:
server._BIP = old
th.join()
self.assertGreater(_time.time() - t0, 0.3)
class KeylogTest(unittest.TestCase): class KeylogTest(unittest.TestCase):
def test_keylog_filename_set(self): def test_keylog_filename_set(self):
import tempfile, os import tempfile, os
@@ -1001,7 +958,7 @@ class ConnHeaderTest(unittest.TestCase):
def responder(method, target, headers, body): def responder(method, target, headers, body):
return 204, {}, b'' return 204, {}, b''
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder, srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder,
keep_alive=True, conn_header='none') conn_header='none')
try: try:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False ctx.check_hostname = False