scp81: PSK by identity, scripts page, exact snapshots, SCP80 LOAD fit (v2.2.0)

Cards / SCP81:
- Cards is a top-level tab; presets gain PSK identity + key, HTTP-OTA
  column, Edit/Update and a live PSK-map push into a running listener.
- SCP81 has Listener/Scripts pills; scripts are named local APDU lists
  (Empty / Explore / Install from .cap / Delete templates), sent to the
  server explicitly at start. The listener takes mode/host/port/script
  only; PSK inputs and the .cap row are gone.
- Multi-PSK TLS listener: identity -> key lookup from the card presets
  (POST /api/scp81/psk-map updates a running listener), unknown
  identities log tls-psk-unknown and fail; handshake logs carry psk_match.
- Script engine: execution tracking (next/done/pending/results), a
  resumed dialog sends only the unexecuted tail (unreported APDU is
  resent), a fresh dialog restarts, listing continuation pages are
  tracked separately (pending.pages/complete). Restart script button.
- POST /api/scp81/gen-install replaces the SCP81 ram-install queueing
  (generation only; the .cap is never stored).

Profiler / snapshots:
- Snapshot comparison is always exact (mask checkboxes removed; the
  first-4-bytes mask remains a profile-creation option).
- "matching records" line shows count + #record numbers.
- New Clone action: copy named "Copy of <profile>", opened in the editor.
- Matched-record count/numbers fix ("1 из 8 (#8)").

SCP80:
- Configurable / auto-fitted LOAD block size: each LOAD APDU encodes into
  one SMS (pySim rejects secured packets above 140 octets, so a 240-byte
  block could never be sent). Response reports the effective size and
  clamps; encode failures are reported per step with the pySim message.

SW cache otaman-v161; docs/api.md, scp81-findings and help EN/RU updated.
Tests: 226 Python + 356 frontend.
This commit is contained in:
2026-09-16 13:32:26 +03:00
parent ef8b651f28
commit 8c90958718
14 changed files with 1998 additions and 564 deletions
+65 -17
View File
@@ -51,6 +51,16 @@ TLS_VERSIONS = {
}
def _norm_identity(identity):
"""Normalize a PSK identity to the str OpenSSL reports (CPython hands it
to the PSK callback as a str; bytes are decoded byte-exact)."""
if identity is None:
return None
if isinstance(identity, (bytes, bytearray)):
return bytes(identity).decode('latin-1')
return str(identity)
def parse_http_request(data):
"""Parse an HTTP/1.1 request head (bytes up to CRLFCRLF) into
(method, target, headers dict with lower-case names)."""
@@ -120,13 +130,29 @@ def build_http_response(status, reason, headers, body=b'', chunked=False,
class PskTlsServer:
"""PSK TLS listener speaking the GP remote administration HTTP dialog."""
def __init__(self, host, port, psk, identity=None, on_log=None,
def __init__(self, host, port, psk=None, identity=None, on_log=None,
responder=None, timeout=10.0, chunked=False, chunk_size=0,
keep_alive=False, compact_headers=False, tls_version='1.2',
cipher=None, on_before_close=None, keylog=None,
conn_header=None, half_close=False, answer_delay=0.0):
conn_header=None, half_close=False, answer_delay=0.0,
psk_map=None):
# PSK lookup table: identity -> key. With an explicit psk_map a
# handshake is accepted only for a listed identity; the legacy
# single-key form (psk + optional identity pin, pin None = accept any
# identity) remains for scripts and tests.
self.wildcard_psk = None
self.psk_map = {}
if psk_map is not None:
self.psk_map = {_norm_identity(k): bytes(v)
for k, v in dict(psk_map).items() if v}
elif psk is not None:
pin = _norm_identity(identity)
if pin is None:
self.wildcard_psk = psk
else:
self.psk_map = {pin: bytes(psk)}
self.psk = psk
self.identity = identity
self.identity = _norm_identity(identity)
self.on_log = on_log
self.responder = responder or self._default_responder
self.timeout = timeout
@@ -165,6 +191,7 @@ class PskTlsServer:
# SEND-DATA conversation to settle before it accepts the response).
self.answer_delay = float(answer_delay or 0)
self.identity_seen = None
self.identity_matched = None
self.stopped = False
self.conns = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -223,14 +250,34 @@ class PskTlsServer:
return ctx
def _psk_cb(self, identity):
"""OpenSSL asks for the key of the identity the client sent."""
self.identity_seen = identity
if self.identity is not None and identity != self.identity:
self.log('tls-psk-unknown', identity=identity)
# A dummy key keeps the callback type-safe; the handshake then
# fails on the Finished MAC check.
"""OpenSSL asks for the key of the identity the client sent.
The identity is looked up in the configured table (identity -> key);
without a match the handshake fails on the Finished MAC check with a
dummy key, and the attempt is logged as 'tls-psk-unknown'."""
ident = _norm_identity(identity)
self.identity_seen = ident
key = self.psk_map.get(ident) if ident is not None else None
if key is None:
# Legacy single-key mode: no identity pin accepts any identity.
key = self.wildcard_psk
self.identity_matched = key is not None
if key is None:
self.log('tls-psk-unknown', identity=ident)
return b'\x00' * 16
return self.psk
return key
@property
def psk_identities(self):
"""Identities the listener looks up (keys are never exposed)."""
return sorted(self.psk_map)
def set_psk_map(self, psk_map):
"""Replace the identity -> key table of a running listener."""
self.psk_map = {_norm_identity(k): bytes(v)
for k, v in dict(psk_map).items() if v}
self.wildcard_psk = None
return self.psk_identities
@staticmethod
def _default_responder(method, target, headers, body):
@@ -285,7 +332,8 @@ class PskTlsServer:
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)
version=tls.version(), identity=self.identity_seen,
psk_match=self.identity_matched)
while not self.stopped:
req = self._read_request(tls)
if req is None:
@@ -304,15 +352,15 @@ class PskTlsServer:
status, resp_headers, resp_body = self.responder(
method, target, headers, body)
reason = {200: 'OK', 204: 'No Content'}.get(status, 'Status')
conn = self.conn_header
if conn == 'none':
conn = None
elif conn is None:
conn = 'keep-alive' if self.keep_alive else 'close'
conn_hdr = self.conn_header
if conn_hdr == 'none':
conn_hdr = None
elif conn_hdr is None:
conn_hdr = 'keep-alive' if self.keep_alive else 'close'
response = build_http_response(
status, reason, resp_headers, resp_body,
chunked=self.chunked, compact=self.compact_headers,
connection=conn)
connection=conn_hdr)
# The card's HTTP client reads its response record-by-record:
# the whole response must arrive in ONE TLS record (chunk_size
# 0), otherwise a split head stalls it and a head-only record