scp81: UI framing options, permissive TLS, drop the Apache-header mimicry (v2.2.14)
Listener Options block (applied at Start, persisted in localStorage, Reset to defaults): HTTP framing - chunked body, chunk size (0 = one TLS record), keep-alive, Connection header, compact headers, Next-URI (unchecked = omit); script framing - indefinite/definite Command Scripting template, CR tag, targeted app; link events (now parsed on the common start path, so every mode honors them). scp81OptionsFromForm() is unit-tested. TLS is automatic: 'auto' (min 1.0, max 1.2 + :@SECLEVEL=0) is the new default, all six PSK suites are offered and OpenSSL negotiates the highest; the negotiated version/cipher is logged (tls-handshake) and reported as version_seen/cipher_seen in /api/scp81/status, and a handshake failing for a TLS/cipher reason logs tls-handshake-failed (post-handshake record errors stay tls-error). tls_version/cipher/keylog/answer_delay stay as API-only pins. Dropped the Apache-style header mimicry completely: no Date/Server/ X-Powered-By, no Content-Length-before-Content-Type ordering, no Content-Type on 204 - the minimal response set is X-Admin-Protocol (+ X-Admin-Next-URI / Targeted-Application), Content-Type on 200s, and Transfer-Encoding or Content-Length per the chunked flag. Docs, help (EN/RU), READMEs and the AGENTS notes updated; SW cache otaman-v180.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user