From f4462c14e713fb6203a2f1afe06ff21ec752d917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Thu, 17 Sep 2026 08:50:40 +0300 Subject: [PATCH] scp81: UI framing options, permissive TLS, drop the Apache-header mimicry (v2.2.14) Listener Options block (applied at Start, persisted in localStorage, Reset to defaults): HTTP framing - chunked body, chunk size (0 = one TLS record), keep-alive, Connection header, compact headers, Next-URI (unchecked = omit); script framing - indefinite/definite Command Scripting template, CR tag, targeted app; link events (now parsed on the common start path, so every mode honors them). scp81OptionsFromForm() is unit-tested. TLS is automatic: 'auto' (min 1.0, max 1.2 + :@SECLEVEL=0) is the new default, all six PSK suites are offered and OpenSSL negotiates the highest; the negotiated version/cipher is logged (tls-handshake) and reported as version_seen/cipher_seen in /api/scp81/status, and a handshake failing for a TLS/cipher reason logs tls-handshake-failed (post-handshake record errors stay tls-error). tls_version/cipher/keylog/answer_delay stay as API-only pins. Dropped the Apache-style header mimicry completely: no Date/Server/ X-Powered-By, no Content-Length-before-Content-Type ordering, no Content-Type on 204 - the minimal response set is X-Admin-Protocol (+ X-Admin-Next-URI / Targeted-Application), Content-Type on 200s, and Transfer-Encoding or Content-Length per the chunked flag. Docs, help (EN/RU), READMEs and the AGENTS notes updated; SW cache otaman-v180. --- README.md | 2 + README_RUS.md | 2 + docs/api.md | 36 ++++-- docs/scp81-findings.md | 5 +- frontend/help-ru.html | 1 + frontend/help.html | 1 + frontend/index.html | 143 +++++++++++++++++++++++- frontend/sw.js | 2 +- frontend/tests/scp81_options.test.js | 161 +++++++++++++++++++++++++++ pyproject.toml | 2 +- pysim_otaman_server/scp81.py | 53 ++++++--- pysim_otaman_server/server.py | 54 ++++----- tests/test_scp81.py | 135 +++++++++++++++++++--- 13 files changed, 517 insertions(+), 80 deletions(-) create mode 100644 frontend/tests/scp81_options.test.js diff --git a/README.md b/README.md index 3726bd0..a8a0219 100644 --- a/README.md +++ b/README.md @@ -597,6 +597,8 @@ The **Listener** starts/stops the target the card's BIP channel is redirected to - **Pass-through (card destination)** — no listener and no target: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (`Other address` + transport port, TCP client remote only; no spec default port, so an incomplete request fails the channel). TLS is terminated by that platform, so the server's network is used (lab only). - **Capture (dump)** — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering. +The **Options** block (applied at **Start**, remembered in the browser) exposes the HTTP framing (chunked body / chunk size, 0 = one TLS record / keep-alive / Connection header / compact headers / Next-URI), the script framing (indefinite `AE 80 …` or definite `AA` Command Scripting template, comprehension-required tags, targeted application) and link events (all modes, TS 102 223 §7.5.11). TLS has no settings: the listener offers TLS 1.0–1.2 and all PSK suites and lets the card negotiate; the log and state line report the version/cipher actually used, and a TLS/cipher handshake failure is logged as `tls-handshake-failed`. + **Script** selects the command list served over the session: **None** (leave the server's configured script) or one of the scripts created in the **Scripts** pill; **Restart script** re-queues the selected script with `force`, starting over from the first APDU. The state line shows the listener, the negotiated identity and live channels (bytes in/out); **Script results (R-APDUs)** lists each served C-APDU with its R-APDU and SW (`done/total` progress); the **HTTP OTA log** records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step (`tls-handshake`, `tls-request`, `script-send`, `script-rapdu`, `script-page`, `script-done`, `data-available`, `peer-close`). The same controls are available through `POST /api/scp81/bip` and `GET /api/scp81/script` (see `docs/api.md`). diff --git a/README_RUS.md b/README_RUS.md index 0d1232b..21a3bc5 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -567,6 +567,8 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на - **Проброс (адрес карты)** — без слушателя и без цели: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (`Other address` + порт transport level, только TCP-клиент, удалённое соединение; порт по умолчанию спецификациями не задан, поэтому неполный запрос отклоняет канал). TLS завершается на той платформе, и используется сеть сервера (только для лаборатории). - **Capture (dump)** — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая. +Блок **Настройки** (применяются при **Start**, сохраняются в браузере) открывает HTTP-фрейминг (chunked-тело / размер чанка, 0 = одна TLS-запись / keep-alive / заголовок Connection / компактные заголовки / Next-URI), фрейминг скрипта (неопределённая `AE 80 …` или определённая `AA` длина Command Scripting template, теги с обязательным пониманием, целевое приложение) и события канала (во всех режимах, TS 102 223 §7.5.11). У TLS нет настроек: слушатель предлагает TLS 1.0–1.2 и все PSK-наборы, а карта выбирает; журнал и строка состояния показывают фактически использованные версию и набор, а сбой рукопожатия по причине TLS/набора пишется как `tls-handshake-failed`. + **Script** выбирает список команд, отдаваемый в сессии: **None** (оставить настроенный на сервере скрипт) или один из скриптов, созданных на подвкладке **Scripts**; **Restart script** заново ставит выбранный скрипт в очередь с `force`, начиная с первого APDU. Строка состояния показывает слушатель, согласованный идентификатор и активные каналы (байты in/out); панель **Script results (R-APDUs)** перечисляет каждый отданный C-APDU с его R-APDU и SW (прогресс `done/total`); журнал **HTTP OTA log** фиксирует OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA и каждый шаг TLS/HTTP/скрипта (`tls-handshake`, `tls-request`, `script-send`, `script-rapdu`, `script-page`, `script-done`, `data-available`, `peer-close`). Те же функции доступны через `POST /api/scp81/bip` и `GET /api/scp81/script` (см. `docs/api.md`). diff --git a/docs/api.md b/docs/api.md index c95fadd..4197e9b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -563,7 +563,8 @@ Stop either mode with `{"action": "stop"}` (also disables the BIP terminal). {"bip": {"enabled": true, "mode": "redirect", "target": "127.0.0.1:8443", "channels": [], "seq": 12}, "listener": {"mode": "tls", "host": "127.0.0.1", "port": 8443, "psk_identities": ["89012345678901234567"], "psk_wildcard": false, - "identity_seen": "89012345678901234567", "identity_matched": true}} + "identity_seen": "89012345678901234567", "identity_matched": true, + "version_seen": "TLSv1.2", "cipher_seen": "PSK-AES128-CBC-SHA256"}} ``` Listener modes: `tls` (local PSK TLS server), `dump` (capture-only TCP @@ -677,13 +678,32 @@ each response (after the card drained the BIP buffer, with `close_notify`, so the card processes the script and opens a new connection for its next POST); `compact_headers` (default `false`) drops the space after each header colon, -`apache_headers` (default `true`) adds Date/Server/X-Powered-By like the -reference servers and puts Transfer-Encoding before Content-Type, -`conn_header` (default `'none'` = omit the header, like the reference) -declares the connection fate, `tls_version` pins `1.1`/`1.0` for cards that -only speak the older record layer, `cipher` pins one suite, `next_uri` +`conn_header` (default `'none'` = omit the header) 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 -omits the header), `link_events` (default `true`) controls the automatic -Channel status events, `answer_delay` waits before answering a request. `keylog` writes the TLS traffic secrets to +omits the header), `link_events` (default `true`, all modes) controls the +automatic Channel status events, `answer_delay` waits before answering a +request. `keylog` writes the TLS traffic secrets to the given file (SSLKEYLOGFILE format) for debugging captures — it contains key material, use a temporary path. + +The response headers are minimal: `X-Admin-Protocol`, the optional +`X-Admin-Next-URI`/`X-Admin-Targeted-Application`, `Content-Type` on 200s and +`Transfer-Encoding`/`Content-Length` per `chunked`. The Date/Server/X-Powered-By +mimicry (`apache_headers`) was removed in 2.2.14 — the reference server's extra +headers were an unnecessary copy (the real blocker was the BIP TERMINAL +RESPONSE BER length) and the card accepts the minimal set. + +`GET /api/scp81/status` echoes the negotiated handshake as `version_seen` / +`cipher_seen` (plus `identity_seen`); a handshake that fails for TLS/cipher +reasons (no shared cipher, unsupported protocol version, card alert) is logged +as `tls-handshake-failed` with the OpenSSL reason, separately from +post-handshake record errors (`tls-error`). + +The PWA's Listener **Options** block exposes `chunked`, `chunk_size`, +`keep_alive`, `conn_header`, `compact_headers`, `next_uri`, +`script_template`, `cr_tag`, `targeted_app` and `link_events` +(applied at Start, persisted in `localStorage`); `tls_version`, `cipher`, +`keylog` and `answer_delay` stay API-only. diff --git a/docs/scp81-findings.md b/docs/scp81-findings.md index 72c6fcf..d3788cb 100644 --- a/docs/scp81-findings.md +++ b/docs/scp81-findings.md @@ -174,9 +174,10 @@ All script attempts used the `explore` list, except #8-#17 which used only failures were caused by the BIP TLV length bug, not by the HTTP/TLS details; resume mode was a symptom (the working session even started as a resume). The key working recipe (also now the server default): one keep-alive connection, -Apache-style headers, `Transfer-Encoding: chunked` body with the script in +`Transfer-Encoding: chunked` body with the script in one TLS record, no Connection header, `X-Admin-Next-URI` with a query whose -command id increments. +command id increments. (2.2.14: the reference server's Date/Server/X-Powered-By +headers were dropped — they were mimicry, the blocker was the BIP length bug.) **Also confirmed:** a TLS half-close (close_notify then keep reading for the card's POST which RFC 5246 leaves open in practice) cannot be done with diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 2743e09..5b7c022 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -327,6 +327,7 @@
  • Проброс (адрес карты) — без слушателя и без цели: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (Other address + порт Transport level, только TCP-клиент, удалённое соединение; спецификации не задают порт по умолчанию, поэтому неполный запрос отклоняет канал). TLS завершается на той платформе, и используется сеть сервера (только для лаборатории).
  • Захват (dump) — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая.
  • +

    Блок Настройки (применяются при Start, сохраняются в браузере) открывает HTTP-фрейминг — chunked-тело / размер чанка (0 = весь ответ в одной TLS-записи), keep-alive, заголовок Connection, компактные заголовки и Next-URI (снято — заголовок не отправляется) — и фрейминг скрипта (неопределённая длина AE 80 … / определённая AA в Command Scripting template, теги с обязательным пониманием, целевое приложение). События канала (ENVELOPE Channel status, TS 102 223 §7.5.11) действуют во всех режимах. У TLS нет настроек: слушатель предлагает TLS 1.0–1.2 и все PSK-наборы, а карта выбирает; строка состояния и TLS-журнал показывают фактически использованные версию и набор, а сбой рукопожатия по причине TLS/набора пишется как tls-handshake-failed.

    Сценарий выбирает список команд, отдаваемый в сессии — Нет (оставить настроенный на сервере скрипт) или один из скриптов подвкладки «Скрипты»; выбранный список передаётся серверу при старте слушателя.

    Строка состояния показывает слушатель, согласованный идентификатор и активные каналы (байты in/out). Панель Script results (R-APDUs) перечисляет каждый отданный C-APDU с его R-APDU/SW и прогрессом done/total; журнал HTTP OTA log фиксирует OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA и каждый шаг TLS/HTTP/скрипта (tls-handshake, tls-request, script-send, script-rapdu, script-page, script-done, data-available, peer-close), кнопка Clear очищает его.

    5.2 Скрипты

    diff --git a/frontend/help.html b/frontend/help.html index 9646025..a39d90b 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -327,6 +327,7 @@
  • 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 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).
  • 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 = 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 AE 80 … / definite AA Command Scripting template, comprehension-required tags, targeted application). Link events (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 tls-handshake-failed.

    Script selects the command list served over the session — None (leave the server's configured script) or one of the scripts from the Scripts pill; the chosen list is sent to the server when the listener starts.

    The state line shows the listener, the negotiated identity and live channels (bytes in/out). Script results (R-APDUs) lists each served C-APDU with its R-APDU/SW and a done/total progress; the HTTP OTA log records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step (tls-handshake, tls-request, script-send, script-rapdu, script-page, script-done, data-available, peer-close), with Clear.

    5.2 Scripts

    diff --git a/frontend/index.html b/frontend/index.html index ec6c7b1..1b2cd41 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
    -

    OTAMan SIM OTA with a Human Face v2.2.13

    +

    OTAMan SIM OTA with a Human Face v2.2.14

    @@ -995,6 +995,44 @@
    +
    +
    + Options (applied at Start) + +
    +
    +
    HTTP framing
    + +
    + + +
    + +
    + + +
    + + + +
    +
    +
    Script framing
    +
    + + +
    + +
    + +
    Script results (R-APDUs) @@ -7770,6 +7808,67 @@ async function pysimProactiveLogRender() { let _scp81Timer = null; let _scp81LastStatus = null; +// ===== SCP81 listener options (HTTP framing / script framing / link events) ===== +// The API takes these at listener start; they are kept in localStorage so a +// test setup survives reloads. TLS itself has no settings: the listener +// accepts everything it can and reports the negotiated version/cipher. +const SCP81_OPT_DEFAULTS = { + chunked: true, chunkSize: 0, keepAlive: true, connHeader: 'none', + compact: false, nextUri: '/api/scp81?req=%d', linkEvents: true, + scriptTemplate: 'indefinite', crTag: false, targetedApp: '' +}; + +function scp81OptionsLoad() { + let saved = {}; + try { saved = JSON.parse(localStorage.getItem('otaman_scp81_opts') || '{}') || {}; } catch (e) { saved = {}; } + const opt = Object.assign({}, SCP81_OPT_DEFAULTS, saved); + const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; }; + const chk = (id, v) => { const el = document.getElementById(id); if (el) el.checked = !!v; }; + chk('opt-chunked', opt.chunked); + set('opt-chunk-size', opt.chunkSize); + chk('opt-keep-alive', opt.keepAlive); + set('opt-conn-header', opt.connHeader); + chk('opt-compact', opt.compact); + chk('opt-next-uri', opt.nextUri !== ''); + set('opt-next-uri-value', opt.nextUri || SCP81_OPT_DEFAULTS.nextUri); + chk('opt-link-events', opt.linkEvents); + set('opt-script-template', opt.scriptTemplate === 'definite' ? 'definite' : 'indefinite'); + chk('opt-cr-tag', opt.crTag); + set('opt-targeted-app', opt.targetedApp); +} + +function scp81OptionsFromForm() { + const val = id => { const el = document.getElementById(id); return el ? el.value : ''; }; + const on = id => { const el = document.getElementById(id); return !!(el && el.checked); }; + return { + chunked: on('opt-chunked'), + chunk_size: Math.max(0, parseInt(val('opt-chunk-size'), 10) || 0), + keep_alive: on('opt-keep-alive'), + conn_header: val('opt-conn-header') || 'none', + compact_headers: on('opt-compact'), + next_uri: on('opt-next-uri') ? (val('opt-next-uri-value').trim() || SCP81_OPT_DEFAULTS.nextUri) : '', + link_events: on('opt-link-events'), + script_template: val('opt-script-template') === 'definite' ? 'definite' : 'indefinite', + cr_tag: on('opt-cr-tag'), + targeted_app: val('opt-targeted-app').trim() + }; +} + +function scp81OptionsPersist() { + const o = scp81OptionsFromForm(); + localStorage.setItem('otaman_scp81_opts', JSON.stringify({ + chunked: o.chunked, chunkSize: o.chunk_size, keepAlive: o.keep_alive, + connHeader: o.conn_header, compact: o.compact_headers, nextUri: o.next_uri, + linkEvents: o.link_events, scriptTemplate: o.script_template, + crTag: o.cr_tag, targetedApp: o.targeted_app + })); +} + +function scp81OptionsReset() { + localStorage.removeItem('otaman_scp81_opts'); + scp81OptionsLoad(); +} + function scp81ModeChanged() { const mode = document.getElementById('scp81-mode').value; const row = document.getElementById('scp81-script-row'); @@ -7780,6 +7879,11 @@ function scp81ModeChanged() { if (redirectNote) redirectNote.classList.toggle('hidden', mode !== 'redirect'); const passthruNote = document.getElementById('scp81-passthru-note'); if (passthruNote) passthruNote.classList.toggle('hidden', mode !== 'passthru'); + // HTTP and script framing are properties of our TLS listener + const httpOpts = document.getElementById('scp81-opts-http'); + if (httpOpts) httpOpts.classList.toggle('hidden', mode !== 'tls'); + const scriptOpts = document.getElementById('scp81-opts-script'); + if (scriptOpts) scriptOpts.classList.toggle('hidden', mode !== 'tls'); // passthru dials the destination from the card's OPEN CHANNEL: the // configured target fields do not apply. for (const id of ['scp81-host', 'scp81-port']) { @@ -8132,6 +8236,13 @@ async function scp81StatusRefresh() { ? t('matched') + (name ? ': ' + name : '') : t('unknown identity')) + ']'; } + if (l.version_seen) { + s += ' | TLS ' + l.version_seen + (l.cipher_seen ? ' ' + l.cipher_seen : ''); + } + s += ' | ' + (l.chunked + ? ('chunked' + (l.chunk_size ? ' ' + l.chunk_size : '')) + : 'content-length'); + if (l.keep_alive) s += ' | keep-alive'; } const ch = (bip.channels || []).map(c => 'ch' + c.id + (c.target ? ' → ' + c.target : '') + ' in:' + c.bytes_in + ' out:' + c.bytes_out).join(', '); if (ch) s += ' | ' + ch; @@ -8173,6 +8284,20 @@ async function scp81Start() { body.host = hostVal || '127.0.0.1'; body.port = parseInt(portVal || '8443', 10); } + const opts = scp81OptionsFromForm(); + body.link_events = opts.link_events; + if (mode === 'tls') { + body.chunked = opts.chunked; + body.chunk_size = opts.chunk_size; + body.keep_alive = opts.keep_alive; + body.conn_header = opts.conn_header; + body.compact_headers = opts.compact_headers; + body.next_uri = opts.next_uri; + body.script_template = opts.script_template; + body.cr_tag = opts.cr_tag; + body.targeted_app = opts.targeted_app || null; + } + scp81OptionsPersist(); if (mode === 'tls') { const map = cardsPskMap(); if (!map.length) { @@ -10997,6 +11122,7 @@ function profilerRenderReport(results, labels) { profilerLoad(); snapshotsLoad(); +scp81OptionsLoad(); // Init sub-tab pills document.querySelectorAll('.pysim-subtab').forEach(btn => { @@ -11450,6 +11576,21 @@ const LANG_RU = { 'Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.': 'Перенаправление: каждый открываемый картой BIP-канал подключается к этому Host:Port (внешняя платформа HTTP OTA); TLS завершается там, а запрошенный картой адрес только журналируется.', 'Pass-through: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port above are not used, TLS is terminated by that platform, and the server\'s network is used (lab only).': 'Проброс: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (Other address + порт Transport level, только TCP-клиент); поля Host и Port выше не используются, TLS завершается на той платформе, и используется сеть сервера (только для лаборатории).', 'Redirect requires the target host and port': 'Для перенаправления нужны host и port внешнего сервера', + 'Options (applied at Start)': 'Настройки (применяются при запуске)', + 'Reset to defaults': 'Сбросить к умолчаниям', + 'HTTP framing': 'HTTP-фрейминг', + 'Chunked body (Transfer-Encoding: chunked)': 'Chunked-тело (Transfer-Encoding: chunked)', + 'Chunk size (bytes, 0 = one TLS record)': 'Размер чанка (байт, 0 = одна TLS-запись)', + 'Keep-alive (one connection until the session ends)': 'Keep-alive (одно соединение до конца сессии)', + 'Connection header': 'Заголовок Connection', + 'omit (implicit keep-alive)': 'не отправлять (неявный keep-alive)', + "Compact headers (no space after ':')": 'Компактные заголовки (без пробела после «:»)', + 'Next-URI (unchecked = omit the header)': 'Next-URI (снято — заголовок не отправляется)', + 'Script framing': 'Фрейминг скрипта', + 'Indefinite (AE 80 … 00 00)': 'Неопределённая длина (AE 80 … 00 00)', + 'Definite (AA)': 'Определённая длина (AA)', + 'Comprehension-required tags': 'Теги с обязательным пониманием (CR)', + 'Link events (Channel status ENVELOPEs, TS 102 223 7.5.11)': 'События канала (ENVELOPE Channel status, TS 102 223 7.5.11)', 'ADM verified': 'ADM подтверждён', 'ADM not verified': 'ADM не подтверждён', 'TERMINAL PROFILE': 'TERMINAL PROFILE', diff --git a/frontend/sw.js b/frontend/sw.js index 1c93ae5..4fb9e9a 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v179'; +const CACHE = 'otaman-v180'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/scp81_options.test.js b/frontend/tests/scp81_options.test.js new file mode 100644 index 0000000..a6e330b --- /dev/null +++ b/frontend/tests/scp81_options.test.js @@ -0,0 +1,161 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); + +function extractFunc(src, name) { + const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); + const m = re.exec(src); + if (!m) throw new Error('function ' + name + ' not found'); + let i = m.index + m[0].length - 1; + let depth = 0; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) break; + } + } + return src.slice(m.index, i + 1); +} + +let code = html.match(/const SCP81_OPT_DEFAULTS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n'; +for (const fn of ['scp81OptionsLoad', 'scp81OptionsFromForm', 'scp81OptionsPersist', 'scp81OptionsReset']) { + code += extractFunc(html, fn) + '\n'; +} +eval(code); + +function setup(opts) { + const spec = Object.assign({ + 'opt-chunked': true, + 'opt-chunk-size': 0, + 'opt-keep-alive': true, + 'opt-conn-header': 'none', + 'opt-compact': false, + 'opt-next-uri': true, + 'opt-next-uri-value': '/api/scp81?req=%d', + 'opt-link-events': true, + 'opt-script-template': 'indefinite', + 'opt-cr-tag': false, + 'opt-targeted-app': '' + }, opts || {}); + const els = {}; + for (const [id, v] of Object.entries(spec)) { + els[id] = typeof v === 'boolean' ? { checked: v, value: '' } : { checked: true, value: String(v) }; + } + globalThis.document = { getElementById: id => els[id] || null }; + return els; +} + +function fakeStorage(initial) { + const store = Object.assign({}, initial || {}); + globalThis.localStorage = { + getItem: k => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + removeItem: k => { delete store[k]; }, + _store: store, + }; + return store; +} + +test('scp81OptionsFromForm maps the reference defaults', () => { + setup(); + assert.deepStrictEqual(scp81OptionsFromForm(), { + chunked: true, + chunk_size: 0, + keep_alive: true, + conn_header: 'none', + compact_headers: false, + next_uri: '/api/scp81?req=%d', + link_events: true, + script_template: 'indefinite', + cr_tag: false, + targeted_app: '' + }); +}); + +test('scp81OptionsFromForm reflects a changed setup', () => { + setup({ + 'opt-chunked': false, + 'opt-chunk-size': '100', + 'opt-keep-alive': false, + 'opt-conn-header': 'close', + 'opt-compact': true, + 'opt-next-uri-value': '/adminserver?apdu_id=%d', + 'opt-link-events': false, + 'opt-script-template': 'definite', + 'opt-cr-tag': true, + 'opt-targeted-app': ' //aid/A000000151000000 ' + }); + assert.deepStrictEqual(scp81OptionsFromForm(), { + chunked: false, + chunk_size: 100, + keep_alive: false, + conn_header: 'close', + compact_headers: true, + next_uri: '/adminserver?apdu_id=%d', + link_events: false, + script_template: 'definite', + cr_tag: true, + targeted_app: '//aid/A000000151000000' + }); +}); + +test('unchecked Next-URI omits the header; empty text falls back to the template', () => { + setup({ 'opt-next-uri': false }); + assert.strictEqual(scp81OptionsFromForm().next_uri, ''); + setup({ 'opt-next-uri': true, 'opt-next-uri-value': ' ' }); + assert.strictEqual(scp81OptionsFromForm().next_uri, '/api/scp81?req=%d'); +}); + +test('load/persist/reset round-trip through localStorage', () => { + const store = fakeStorage(); + setup({ + 'opt-chunked': false, + 'opt-chunk-size': 100, + 'opt-keep-alive': false, + 'opt-conn-header': 'close', + 'opt-compact': true, + 'opt-next-uri': false, + 'opt-link-events': false, + 'opt-script-template': 'definite', + 'opt-cr-tag': true, + 'opt-targeted-app': '//aid/A000000151000000' + }); + scp81OptionsPersist(); + assert.ok(store['otaman_scp81_opts'].includes('"chunkSize":100')); + // a reload restores the saved setup + setup({}); + scp81OptionsLoad(); + assert.strictEqual(globalThis.document.getElementById('opt-chunked').checked, false); + assert.strictEqual(globalThis.document.getElementById('opt-chunk-size').value, 100); + assert.strictEqual(globalThis.document.getElementById('opt-conn-header').value, 'close'); + assert.strictEqual(globalThis.document.getElementById('opt-next-uri').checked, false); + assert.strictEqual(globalThis.document.getElementById('opt-script-template').value, 'definite'); + assert.strictEqual(globalThis.document.getElementById('opt-cr-tag').checked, true); + assert.strictEqual(globalThis.document.getElementById('opt-targeted-app').value, '//aid/A000000151000000'); + // reset clears the saved entry and restores the defaults + scp81OptionsReset(); + assert.ok(!('otaman_scp81_opts' in store)); + scp81OptionsLoad(); + assert.strictEqual(globalThis.document.getElementById('opt-chunked').checked, true); + assert.strictEqual(globalThis.document.getElementById('opt-next-uri').checked, true); + assert.strictEqual(globalThis.document.getElementById('opt-conn-header').value, 'none'); +}); + +test('the Listener UI wires the framing options into Start', () => { + for (const id of ['scp81-opts-http', 'scp81-opts-script', 'opt-chunked', + 'opt-chunk-size', 'opt-keep-alive', 'opt-conn-header', 'opt-compact', + 'opt-next-uri', 'opt-next-uri-value', 'opt-script-template', 'opt-cr-tag', + 'opt-targeted-app', 'opt-link-events']) { + assert.ok(html.includes('id="' + id + '"'), id); + } + assert.ok(html.includes('body.link_events = opts.link_events;')); + assert.ok(html.includes('body.chunk_size = opts.chunk_size;')); + assert.ok(html.includes('body.next_uri = opts.next_uri;')); + assert.ok(html.includes('body.script_template = opts.script_template;')); + assert.ok(html.includes("httpOpts.classList.toggle('hidden', mode !== 'tls')")); + assert.ok(html.includes("scriptOpts.classList.toggle('hidden', mode !== 'tls')")); +}); diff --git a/pyproject.toml b/pyproject.toml index 6794c51..f8fdd37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "2.2.13" +version = "2.2.14" description = "HTTP REST server wrapping pysim for the OTAMan PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_otaman_server/scp81.py b/pysim_otaman_server/scp81.py index 795d446..7aaa999 100644 --- a/pysim_otaman_server/scp81.py +++ b/pysim_otaman_server/scp81.py @@ -132,7 +132,7 @@ class PskTlsServer: def __init__(self, host, port, psk=None, identity=None, on_log=None, responder=None, timeout=10.0, chunked=False, chunk_size=0, - keep_alive=False, compact_headers=False, tls_version='1.2', + keep_alive=False, compact_headers=False, tls_version='auto', cipher=None, on_before_close=None, keylog=None, conn_header=None, half_close=False, answer_delay=0.0, psk_map=None): @@ -161,9 +161,12 @@ class PskTlsServer: self.chunk_size = int(chunk_size) self.keep_alive = keep_alive self.compact_headers = compact_headers - # The reference traces negotiated TLS 1.0 with PSK-AES128-CBC-SHA; - # some cards only speak the older record layer correctly. - self.tls_version = tls_version if tls_version in TLS_VERSIONS else '1.2' + # 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' + # pins are debugging aids for a card that offers 1.2 but mishandles + # it; no setting is needed for normal use. + self.tls_version = (tls_version if tls_version == 'auto' + or tls_version in TLS_VERSIONS else 'auto') # Pin one cipher suite (e.g. PSK-AES128-CBC-SHA) if the card's SD only # maps a specific suite to a usable SCP81 security level. self.cipher = cipher or None @@ -176,8 +179,8 @@ class PskTlsServer: # decrypted (tshark etc). Contains key material - use a temp path. self.keylog = keylog or None # Connection header value: None = auto ('keep-alive'/'close' per the - # keep_alive flag), 'none' = omit the header (Apache-style implicit - # HTTP/1.1 keep-alive, as in the working reference trace). + # keep_alive flag), 'none' = omit the header (implicit HTTP/1.1 + # keep-alive). self.conn_header = conn_header or None # TLS half-close after a script body. NOTE (live 2026-09-16): # CPython's SSLSocket.unwrap() poisons the session when the peer does @@ -186,12 +189,14 @@ class PskTlsServer: # option surface and for cards that answer promptly (the exception # path leaves the session unusable, so it is off by default). self.half_close = half_close - # Wait before answering a request (the reference Apache/PHP servers - # answer ~1 s after the card's POST; the card may need its BIP - # SEND-DATA conversation to settle before it accepts the response). + # Wait before answering a request (cards may need their BIP SEND DATA + # conversation to settle before they accept the response; 0 = answer + # immediately). self.answer_delay = float(answer_delay or 0) self.identity_seen = None self.identity_matched = None + self.version_seen = None + self.cipher_seen = None self.stopped = False self.conns = [] self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -231,11 +236,17 @@ class PskTlsServer: def _make_context(self): ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ver = TLS_VERSIONS[self.tls_version] - ctx.minimum_version = ver - ctx.maximum_version = ver + if self.tls_version == 'auto': + # Accept everything the cards speak; OpenSSL negotiates the + # highest common version. + ctx.minimum_version = ssl.TLSVersion.TLSv1 + ctx.maximum_version = ssl.TLSVersion.TLSv1_2 + else: + ver = TLS_VERSIONS[self.tls_version] + ctx.minimum_version = ver + ctx.maximum_version = ver ciphers = self.cipher or PSK_CIPHERS - if self.tls_version in ('1.0', '1.1'): + if self.tls_version in ('auto', '1.0', '1.1'): # OpenSSL 3.x disables the legacy protocol versions by default. ciphers += ':@SECLEVEL=0' ctx.set_ciphers(ciphers) @@ -329,10 +340,14 @@ class PskTlsServer: def _conn_loop(self, conn, peer): tls = None + handshake_done = False try: tls = self.ctx.wrap_socket(conn, server_side=True) - self.log('tls-handshake', peer=peer, cipher=tls.cipher()[0], - version=tls.version(), identity=self.identity_seen, + handshake_done = True + self.version_seen = tls.version() + self.cipher_seen = (tls.cipher() or (None,))[0] + self.log('tls-handshake', peer=peer, cipher=self.cipher_seen, + version=self.version_seen, identity=self.identity_seen, psk_match=self.identity_matched) while not self.stopped: req = self._read_request(tls) @@ -417,7 +432,13 @@ class PskTlsServer: pass break except ssl.SSLError as e: - self.log('tls-error', peer=peer, error=str(e)) + if handshake_done: + self.log('tls-error', peer=peer, error=str(e)) + else: + # No shared cipher / unsupported protocol version / card + # alert: keep the handshake reason distinguishable from + # post-handshake record errors. + self.log('tls-handshake-failed', peer=peer, error=str(e)) except (OSError, ValueError) as e: self.log('tls-error', peer=peer, error=str(e)) finally: diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 44e1d95..73a864a 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter from osmocom.tlv import BER_TLV_IE -VERSION = '2.2.13' +VERSION = '2.2.14' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1480,6 +1480,8 @@ def _scp81_listener_status(): 'psk_wildcard': _SCP81_LISTENER.wildcard_psk is not None, 'identity_seen': _SCP81_LISTENER.identity_seen, 'identity_matched': _SCP81_LISTENER.identity_matched, + 'version_seen': _SCP81_LISTENER.version_seen, + 'cipher_seen': _SCP81_LISTENER.cipher_seen, 'chunked': _SCP81_LISTENER.chunked, 'chunk_size': _SCP81_LISTENER.chunk_size, 'keep_alive': _SCP81_LISTENER.keep_alive, @@ -1631,12 +1633,9 @@ _SCP81_NEXT_URI = None # When it names an application that does not exist on the card, the SD answers # with X-Admin-Script-Status: unknown-application instead of executing. _SCP81_TARGETED_APP = None -# Emit Apache-style responses (Date/Server/X-Powered-By, Content-Length before -# Content-Type) exactly like the reference admin servers. -_SCP81_APACHE_HEADERS = False -# The listener's chunked flag (mirrored here for the response headers: a -# chunked response must not carry Content-Length - invalid HTTP, and the -# reference sends Transfer-Encoding before Content-Type). +# The listener's chunked flag (mirrored here: a chunked response must not +# carry Content-Length - invalid HTTP; the Transfer-Encoding header itself is +# emitted by build_http_response). _SCP81_CHUNKED = False # Send automatic Channel status (link dropped) events to the card. Suppress # while testing flows where the terminal closes the connection on purpose: @@ -1873,32 +1872,18 @@ def _scp81_script_responder(method, target, headers, body): body_out = _scp81_command_body( apdu, definite=(_SCP81_SCRIPT_TEMPLATE == 'definite'), cr_tag=_SCP81_SCRIPT_CR_TAG) - if _SCP81_APACHE_HEADERS: - if _SCP81_CHUNKED: - headers['Transfer-Encoding'] = 'chunked' - else: - headers['Content-Length'] = str(len(body_out)) + # Transfer-Encoding / Content-Length are emitted by the HTTP builder + # (chunked never carries a Content-Length). headers['Content-Type'] = scp81.GP_CT_COMMAND return 200, headers, body_out _BIP.log('script-done', sent=_SCP81_SCRIPT_SENT_NO, results=len(_SCP81_SCRIPT_RESULTS)) - headers = _scp81_response_headers() - if _SCP81_APACHE_HEADERS: - headers['Content-Type'] = 'text/html; charset=UTF-8' - return 204, headers, b'' + return 204, _scp81_response_headers(), b'' def _scp81_response_headers(): - """Base response headers, in the reference servers' order (Apache adds - Date/Server/X-Powered-By before the admin headers).""" - headers = {} - if _SCP81_APACHE_HEADERS: - import email.utils - headers['Date'] = email.utils.formatdate(usegmt=True) - headers['Server'] = 'Apache' - headers['X-Powered-By'] = 'PHP/7.0.33' - headers['X-Admin-Protocol'] = scp81.GP_PROTOCOL - return headers + """Base response headers: only what the administration dialog needs.""" + return {'X-Admin-Protocol': scp81.GP_PROTOCOL} def _parse_psk_map(raw): @@ -1998,7 +1983,7 @@ def _scp81_bip_control(body): global _SCP81_LISTENER, _SCP81_PSKS, _SCP81_PSK_LEGACY global _SCP81_MODE, _SCP81_TARGET global _SCP81_SCRIPT_TEMPLATE, _SCP81_SCRIPT_CR_TAG, _SCP81_NEXT_URI - global _SCP81_LINK_EVENTS, _SCP81_TARGETED_APP, _SCP81_APACHE_HEADERS + global _SCP81_LINK_EVENTS, _SCP81_TARGETED_APP global _SCP81_CHUNKED body = body or {} action = body.get('action', 'start') @@ -2018,6 +2003,9 @@ def _scp81_bip_control(body): _SCP81_LISTENER.stop() _SCP81_LISTENER = None _BIP.disable() + # Channel status events (TS 102 223 7.5.11) apply to every mode: the + # terminal reports BIP link changes it detects outside proactive commands. + _SCP81_LINK_EVENTS = bool(body.get('link_events', True)) if mode == 'redirect': # No local listener: the card's BIP channels are redirected straight # to the configured target (e.g. a production HTTP OTA server), which @@ -2091,14 +2079,13 @@ def _scp81_bip_control(body): _SCP81_SCRIPT_CR_TAG = bool(body.get('cr_tag', False)) if 'next_uri' in body: _SCP81_NEXT_URI = body.get('next_uri') or '' - _SCP81_LINK_EVENTS = bool(body.get('link_events', True)) _SCP81_TARGETED_APP = (body.get('targeted_app') or None) # Defaults reproduce the working reference session (decrypted from # samples/HTTP_OTA: RAM/HTTPOTA_test5.pcap): one keep-alive connection, - # Apache-style response headers, a chunked body whose script sits in - # one TLS record, no Connection header, and an X-Admin-Next-URI with a - # query whose command id increments. Overrides remain available. - _SCP81_APACHE_HEADERS = bool(body.get('apache_headers', True)) + # a chunked body whose script sits in one TLS record, no Connection + # header, and an X-Admin-Next-URI with a query whose command id + # increments. Overrides remain available; TLS is automatic (all + # versions/ciphers the server can speak, negotiated per card). _SCP81_CHUNKED = bool(body.get('chunked', True)) cs = body.get('chunk_size') chunk_size = int(cs) if cs not in (None, '') else 0 @@ -2109,7 +2096,7 @@ def _scp81_bip_control(body): chunk_size=chunk_size, keep_alive=bool(body.get('keep_alive', True)), compact_headers=bool(body.get('compact_headers', False)), - tls_version=str(body.get('tls_version') or '1.2'), + tls_version=str(body.get('tls_version') or 'auto'), cipher=(body.get('cipher') or None), on_before_close=_scp81_wait_drained, keylog=(body.get('keylog') or None), @@ -2128,7 +2115,6 @@ def _scp81_bip_control(body): 'script_template': _SCP81_SCRIPT_TEMPLATE, 'cr_tag': _SCP81_SCRIPT_CR_TAG, 'link_events': _SCP81_LINK_EVENTS, 'targeted_app': _SCP81_TARGETED_APP, - 'apache_headers': _SCP81_APACHE_HEADERS, 'chunked': _SCP81_CHUNKED} if mode != 'dump': return {'ok': False, 'error': 'unsupported mode: %s' % mode} diff --git a/tests/test_scp81.py b/tests/test_scp81.py index cee311a..426a537 100644 --- a/tests/test_scp81.py +++ b/tests/test_scp81.py @@ -99,6 +99,26 @@ class PskTlsServerTest(unittest.TestCase): sock.close() raise + def test_tls_version_auto_is_permissive(self): + # TLS is automatic by default: accept TLS 1.0-1.2, OpenSSL picks the + # highest the card offers. + srv = scp81.PskTlsServer('127.0.0.1', 0, psk_map={IDENT: PSK}) + try: + self.assertEqual(srv.tls_version, 'auto') + self.assertEqual(srv.ctx.minimum_version, ssl.TLSVersion.TLSv1) + self.assertEqual(srv.ctx.maximum_version, ssl.TLSVersion.TLSv1_2) + finally: + srv.stop() + + def test_tls_version_pin_remains_available(self): + srv = scp81.PskTlsServer('127.0.0.1', 0, psk_map={IDENT: PSK}, + tls_version='1.0') + try: + self.assertEqual(srv.ctx.minimum_version, ssl.TLSVersion.TLSv1) + self.assertEqual(srv.ctx.maximum_version, ssl.TLSVersion.TLSv1) + finally: + srv.stop() + def test_handshake_and_204_session(self): logs = [] srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, identity=IDENT, @@ -630,6 +650,77 @@ class BipControlTest(unittest.TestCase): # the key never leaves the server self.assertNotIn('psk_hex', listener) + def test_tls_defaults_and_framing_options(self): + try: + resp = server._scp81_bip_control({ + 'action': 'start', 'mode': 'tls', 'host': '127.0.0.1', 'port': 0, + 'psk_hex': '00112233', 'psk_identity': 'id-1', + 'chunked': False, 'chunk_size': 100, 'keep_alive': False, + 'compact_headers': True, 'conn_header': 'close', 'next_uri': '', + 'script_template': 'definite', 'cr_tag': True, + 'targeted_app': '//aid/A000000151000000', 'link_events': False, + }) + self.assertTrue(resp['ok'], resp) + listener = resp['listener'] + # TLS is automatic (no version/cipher setting in the PWA) + self.assertEqual(listener['tls_version'], 'auto') + self.assertIn('version_seen', listener) + self.assertIn('cipher_seen', listener) + self.assertEqual((listener['chunked'], listener['chunk_size'], + listener['keep_alive'], listener['compact_headers']), + (False, 100, False, True)) + self.assertEqual(server._SCP81_SCRIPT_TEMPLATE, 'definite') + self.assertTrue(server._SCP81_SCRIPT_CR_TAG) + self.assertEqual(server._SCP81_TARGETED_APP, '//aid/A000000151000000') + self.assertEqual(server._SCP81_NEXT_URI, '') + self.assertFalse(server._SCP81_LINK_EVENTS) + self.assertEqual(resp['script_template'], 'definite') + self.assertTrue(resp['cr_tag']) + self.assertFalse(resp['link_events']) + self.assertNotIn('apache_headers', resp) + finally: + server._SCP81_SCRIPT_TEMPLATE = 'indefinite' + server._SCP81_SCRIPT_CR_TAG = False + server._SCP81_TARGETED_APP = None + server._SCP81_NEXT_URI = None + server._SCP81_LINK_EVENTS = True + + def test_link_events_apply_to_every_mode(self): + # TS 102 223 7.5.11 events are a BIP-layer feature, not a TLS option. + resp = server._scp81_bip_control({'action': 'start', 'mode': 'redirect', + 'host': '10.11.12.13', 'port': 10174, + 'link_events': False}) + self.assertTrue(resp['ok'], resp) + self.assertFalse(server._SCP81_LINK_EVENTS) + resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru', + 'link_events': True}) + self.assertTrue(resp['ok'], resp) + self.assertTrue(server._SCP81_LINK_EVENTS) + + def test_tls_handshake_failure_is_logged(self): + resp = server._scp81_bip_control({'action': 'start', 'mode': 'tls', + 'host': '127.0.0.1', 'port': 0, + 'psk_hex': '00112233', 'psk_identity': 'id-1'}) + self.assertTrue(resp['ok'], resp) + port = resp['listener']['port'] + sock = socket.create_connection(('127.0.0.1', port), timeout=2) + try: + sock.sendall(b'this is not a tls hello') + sock.settimeout(2) + try: + sock.recv(64) + except OSError: + pass + finally: + sock.close() + kinds = [] + for _ in range(40): + kinds = [e['kind'] for e in server._BIP.entries_after(0)] + if 'tls-handshake-failed' in kinds: + break + time.sleep(0.05) + self.assertIn('tls-handshake-failed', kinds) + def test_psk_map_start_and_update(self): server._SCP81_PSKS = {} server._SCP81_PSK_LEGACY = None @@ -944,46 +1035,56 @@ class TargetedAppTest(unittest.TestCase): reset_script() server._SCP81_SCRIPT_NEXT = 0 - def test_apache_headers(self): + def test_response_headers_are_minimal(self): + # No Date/Server/X-Powered-By mimicry (dropped 2.2.14 - the reference + # server's extra headers earned nothing); only the dialog headers. reset_script(['80CAFF2100']) server._SCP81_SCRIPT_NEXT = 0 - server._SCP81_APACHE_HEADERS = True server._SCP81_CHUNKED = False try: status, headers, body = server._scp81_script_responder( 'POST', '/api/scp81', {}, b'') - self.assertEqual(list(headers)[:4], - ['Date', 'Server', 'X-Powered-By', 'X-Admin-Protocol']) - self.assertEqual(headers['Content-Length'], str(len(body))) + self.assertEqual(list(headers), + ['X-Admin-Protocol', 'X-Admin-Next-URI', 'Content-Type']) + self.assertTrue(headers['X-Admin-Next-URI'].startswith('/api/scp81?req=')) out = scp81.build_http_response(status, 'OK', headers, body) - self.assertLess(out.index(b'Content-Length'), - out.index(b'Content-Type')) + self.assertIn(b'Content-Length', out) + for gone in (b'Date:', b'Server:', b'X-Powered-By'): + self.assertNotIn(gone, out) finally: - server._SCP81_APACHE_HEADERS = False server._SCP81_CHUNKED = False reset_script() server._SCP81_SCRIPT_NEXT = 0 - def test_chunked_apache_has_no_content_length(self): - # The reference (RAM/HTTPOTA_test5.pcap, decryptable) sends chunked - # without Content-Length, Transfer-Encoding before Content-Type. + def test_session_end_204_has_only_the_admin_header(self): + reset_script([]) + server._SCP81_SCRIPT_NEXT = 0 + try: + status, headers, body = server._scp81_script_responder( + 'POST', '/api/scp81', {}, b'') + self.assertEqual(status, 204) + self.assertEqual(list(headers), ['X-Admin-Protocol']) + self.assertEqual(body, b'') + finally: + reset_script() + server._SCP81_SCRIPT_NEXT = 0 + + def test_chunked_response_has_no_content_length(self): + # A chunked response must not carry Content-Length (invalid HTTP - and + # the card rejects it); the Transfer-Encoding header is emitted by the + # HTTP builder, exactly once. reset_script(['80CAFF2100']) server._SCP81_SCRIPT_NEXT = 0 - server._SCP81_APACHE_HEADERS = True server._SCP81_CHUNKED = True try: status, headers, body = server._scp81_script_responder( 'POST', '/api/scp81', {}, b'') self.assertNotIn('Content-Length', headers) - self.assertEqual(headers['Transfer-Encoding'], 'chunked') - self.assertLess(list(headers).index('Transfer-Encoding'), - list(headers).index('Content-Type')) out = scp81.build_http_response(status, 'OK', headers, body, chunked=True, connection=None) self.assertNotIn(b'Content-Length', out) - self.assertEqual(out.count(b'Transfer-Encoding'), 1) + self.assertEqual(out.count(b'Transfer-Encoding: chunked'), 1) finally: - server._SCP81_APACHE_HEADERS = False server._SCP81_CHUNKED = False reset_script() server._SCP81_SCRIPT_NEXT = 0