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:
@@ -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.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`.
|
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.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.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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-фрейминг — <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> (по умолчанию свёрнут; при отличии от эталонных значений показывается метка <em>изменены</em>; применяются при <strong>Start</strong>, сохраняются в браузере) открывает HTTP-фрейминг — <strong>Chunked body</strong> / <strong>размер чанка</strong> (0 = весь ответ в одной TLS-записи), заголовок Connection, компактные заголовки (без необязательного пробела после <code class="font-mono text-sm">:</code>, допустимо по RFC 7230 §3.2) и Next-URI (флажок + шаблон; снято — одноразовый режим: карта выполнит команду, не вернёт строку ответа и закроет сессию) — и фрейминг скрипта (неопределённая длина <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 §7.5.11) действует во всех режимах. Соединение удерживается между POST-запросами и аккуратно закрывается на 204. У 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>
|
||||||
|
|||||||
+1
-1
@@ -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 — <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">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 — <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 §3.2) and the Next-URI (checkbox + template; unchecked = one-shot: the card executes the command, returns no response string and closes the session) — 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 §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–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>
|
||||||
|
|||||||
+109
-50
@@ -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,9 +995,13 @@
|
|||||||
<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>
|
||||||
|
<span id="scp81-opts-badge" class="ml-1 text-xs text-amber-500 dark:text-amber-400"></span>
|
||||||
|
</summary>
|
||||||
|
<div class="px-3 pb-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<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>
|
<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>
|
||||||
<div id="scp81-opts-http" class="space-y-2">
|
<div id="scp81-opts-http" class="space-y-2">
|
||||||
@@ -1007,18 +1011,17 @@
|
|||||||
<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>
|
<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">
|
<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 class="flex flex-wrap items-center gap-2">
|
<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>
|
<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">
|
<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="none" data-l10n="omit (implicit keep-alive)">omit (implicit keep-alive)</option>
|
||||||
<option value="close">close</option>
|
|
||||||
<option value="keep-alive">keep-alive</option>
|
<option value="keep-alive">keep-alive</option>
|
||||||
</select>
|
</select>
|
||||||
</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>
|
<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="scp81OptionsPersist()" class="rounded"> <span data-l10n="Next-URI (unchecked = omit the header)">Next-URI (unchecked = omit the header)</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">
|
<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>
|
||||||
<div id="scp81-opts-script" class="space-y-2 mt-3">
|
<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="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Script framing">Script framing</div>
|
||||||
@@ -1029,10 +1032,12 @@
|
|||||||
</select>
|
</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>
|
<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"><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>
|
||||||
|
<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">
|
||||||
</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>
|
<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>
|
||||||
|
</details>
|
||||||
<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
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'otaman-v180';
|
const CACHE = 'otaman-v181';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
|
||||||
# Clean TLS shutdown BEFORE the card drains the
|
|
||||||
# buffer: a bare TCP close leaves the card's TLS stack
|
|
||||||
# with a truncated session (it then neither processes
|
|
||||||
# the script nor posts the response), and a
|
|
||||||
# close_notify sent only after the drain is never
|
|
||||||
# fetched. Send it while the response still waits, so
|
|
||||||
# the card reads both, then wait for the buffer to
|
|
||||||
# drain and only then send the FIN.
|
|
||||||
try:
|
try:
|
||||||
tls.settimeout(2.0)
|
tls.settimeout(2.0)
|
||||||
plain = tls.unwrap()
|
plain = tls.unwrap()
|
||||||
tls = None
|
tls = None
|
||||||
except Exception:
|
except Exception:
|
||||||
plain = None
|
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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user