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