scp81: PSK TLS server and command scripting (phases B/C) + BIP fix (v2.1.5)
- scp81.py: PSK TLS listener (stdlib ssl PSK callbacks) speaking the GP HTTP administration dialog; configurable framing (chunked/Content-Length, TLS record split, Apache-style/compact headers, Connection header, keep-alive, Next-URI template with %d, TLS version/cipher, answer delay, keylog for capture decryption) - server.py: script responder + Response Scripting parsing (AF/AB, 80/23 TLVs), memory decoder, SCP81 start options, terminal-side timer management, background-mode BIP events, permissive OPEN CHANNEL - BIP fix: the RECEIVE DATA channel-data TLV length is BER long form (36 81 <len>) above 127 bytes; a raw length byte is mis-parsed on the card, so the large TLS records never reached its stack (a live card fetched the script response and silently never processed it - endless resume). The card now executes scripts and returns R-APDUs: memory (13 applets, 50646 B NV free, 2402 B volatile), ISD, stored HTTP OTA parameters, ELF and application registries - frontend: SCP81 tab (listener, script selection, HTTP OTA log), phone event forms, i18n; service worker v141 - docs: api.md, scp81-findings.md (attempt matrix + root cause analysis); tools/scp81_decrypt.py decrypts listener captures via the keylog - tests: 187 python + 337 frontend
This commit is contained in:
@@ -42,8 +42,10 @@ def main():
|
||||
help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)')
|
||||
parser.add_argument('--sms-sm-sc', default='12345678912', metavar='DIGITS',
|
||||
help='SM-SC address for SMS-SUBMIT routing in PoR-in-submit mode (default: 12345678912)')
|
||||
parser.add_argument('--terminal-profile', default='7FFFFFFFFF0000CF02', metavar='HEX',
|
||||
help='TERMINAL PROFILE payload (default: 10-byte profile with SMS-PP download and event list)')
|
||||
parser.add_argument('--terminal-profile',
|
||||
default='FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003',
|
||||
metavar='HEX',
|
||||
help='TERMINAL PROFILE payload (default: the 33-byte profile of a real BIP-capable handset - the live card only starts HTTP OTA when BIP events/commands are advertised)')
|
||||
parser.add_argument('--poll-interval', type=int, default=30, metavar='SECS',
|
||||
help='Idle interval before automatic STATUS polling (1-255 seconds, default: 30). Disable with --poll-interval 0')
|
||||
parser.add_argument('--no-card-init', action='store_true', default=False,
|
||||
|
||||
@@ -92,6 +92,9 @@ class BipChannel:
|
||||
self.bytes_out = 0
|
||||
self.opened_at = time.time()
|
||||
self.peer_closed = False
|
||||
self.closed_reported = False
|
||||
self.notified_len = 0
|
||||
self.last_notify = 0.0
|
||||
|
||||
def pump(self, timeout=0.05):
|
||||
"""Move whatever the network has into the local buffer. Returns bytes moved."""
|
||||
@@ -157,6 +160,9 @@ class BipTerminal:
|
||||
self.entries = []
|
||||
self.seq = 0
|
||||
self.lock = threading.Lock()
|
||||
self.pending_events = []
|
||||
self.on_data = None
|
||||
self._monitor = None
|
||||
|
||||
def log(self, kind, **fields):
|
||||
with self.lock:
|
||||
@@ -168,25 +174,94 @@ class BipTerminal:
|
||||
del self.entries[:len(self.entries) - MAX_LOG]
|
||||
return entry
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""Watch channels for incoming bytes and ask the card to fetch them.
|
||||
|
||||
The card only learns about server data through the Data available
|
||||
event (TS 102 223 7.5.10), so the socket must be pumped even while
|
||||
the card is idle."""
|
||||
while True:
|
||||
time.sleep(0.25)
|
||||
with self.lock:
|
||||
channels = list(self.channels.values())
|
||||
for ch in channels:
|
||||
try:
|
||||
ch.pump()
|
||||
except OSError:
|
||||
ch.peer_closed = True
|
||||
if ch.peer_closed and not ch.closed_reported and not ch.rx:
|
||||
# Report a dropped link (TS 102 223 7.5.11) only once the
|
||||
# buffered server data has been fetched: signalling the
|
||||
# drop while bytes are still waiting makes the card abort
|
||||
# the fetch and end the session prematurely.
|
||||
ch.closed_reported = True
|
||||
self.log('peer-close', channel=ch.id)
|
||||
self._queue_link_status(ch.id)
|
||||
if (self.on_data and ch.rx and not ch.peer_closed
|
||||
and (len(ch.rx) > ch.notified_len
|
||||
or time.time() - ch.last_notify > 2.0)):
|
||||
# Re-notify while data stays unfetched: the live card
|
||||
# sometimes needs the Data available event again to drain
|
||||
# a partially received TLS record.
|
||||
if self.on_data(ch):
|
||||
ch.notified_len = len(ch.rx)
|
||||
ch.last_notify = time.time()
|
||||
|
||||
def _start_monitor(self):
|
||||
if self._monitor is None or not self._monitor.is_alive():
|
||||
self._monitor = threading.Thread(target=self._monitor_loop,
|
||||
name='bip-monitor', daemon=True)
|
||||
self._monitor.start()
|
||||
|
||||
def enable(self, host, port):
|
||||
self.target = (host, int(port))
|
||||
self.enabled = True
|
||||
self.log('enabled', target='%s:%d' % self.target)
|
||||
self._start_monitor()
|
||||
|
||||
def disable(self):
|
||||
self.enabled = False
|
||||
self.log('disabled')
|
||||
self.close_all()
|
||||
self.close_all(link_lost=True)
|
||||
self.target = None
|
||||
|
||||
def close_all(self):
|
||||
def close_all(self, link_lost=False):
|
||||
for ch in list(self.channels.values()):
|
||||
self._close_channel(ch)
|
||||
self._close_channel(ch, link_lost=link_lost)
|
||||
|
||||
def _close_channel(self, ch):
|
||||
def _close_channel(self, ch, link_lost=False):
|
||||
ch.close()
|
||||
if self.channels.get(ch.id) is ch:
|
||||
del self.channels[ch.id]
|
||||
if link_lost:
|
||||
self._queue_link_status(ch.id)
|
||||
|
||||
def _queue_link_status(self, channel_id, status=None, info=0x05):
|
||||
"""Record a BIP link change that did not result from a proactive
|
||||
command (TS 102 223 7.5.11). The default is link not established +
|
||||
info 05 = link dropped; a successful background-mode OPEN CHANNEL
|
||||
reports link established instead. The server turns these into
|
||||
ENVELOPE (Channel status)."""
|
||||
with self.lock:
|
||||
if any(e['channel'] == channel_id for e in self.pending_events):
|
||||
return
|
||||
self.pending_events.append({
|
||||
'channel': channel_id,
|
||||
'status': channel_id & 0x07 if status is None else status,
|
||||
'info': info})
|
||||
|
||||
def take_pending_events(self):
|
||||
with self.lock:
|
||||
events, self.pending_events = self.pending_events, []
|
||||
return events
|
||||
|
||||
def _check_peer(self, ch):
|
||||
"""Notify once per channel when the peer closed the connection, after
|
||||
any buffered data has been fetched (see _monitor_loop)."""
|
||||
if ch.peer_closed and not ch.closed_reported and not ch.rx:
|
||||
ch.closed_reported = True
|
||||
self.log('peer-close', channel=ch.id)
|
||||
self._queue_link_status(ch.id)
|
||||
|
||||
def _alloc_id(self):
|
||||
for _ in range(7):
|
||||
@@ -225,7 +300,7 @@ class BipTerminal:
|
||||
ch.send(data)
|
||||
except OSError as e:
|
||||
self.log('send-fail', channel=channel_id, error=str(e))
|
||||
self._close_channel(ch)
|
||||
self._close_channel(ch, link_lost=True)
|
||||
return False
|
||||
self.log('send', channel=channel_id, bytes=len(data), hex=data.hex().upper()[:2000])
|
||||
return True
|
||||
@@ -238,15 +313,28 @@ class BipTerminal:
|
||||
if data:
|
||||
self.log('receive', channel=channel_id, bytes=len(data), remaining=len(ch.rx),
|
||||
hex=data.hex().upper()[:2000])
|
||||
# The TR announced the remainder via the channel-data-length TLV,
|
||||
# but the live card still waits for a fresh Data available event
|
||||
# before fetching it - re-arm the notification for what is left.
|
||||
ch.notified_len = 0
|
||||
self._check_peer(ch)
|
||||
return data
|
||||
|
||||
def available(self, channel_id):
|
||||
ch = self.channels.get(channel_id)
|
||||
return ch.available() if ch else 0
|
||||
if not ch:
|
||||
return 0
|
||||
n = ch.available()
|
||||
self._check_peer(ch)
|
||||
return n
|
||||
|
||||
def send_capacity(self, channel_id):
|
||||
ch = self.channels.get(channel_id)
|
||||
return ch.send_capacity() if ch else 0
|
||||
if not ch:
|
||||
return 0
|
||||
n = ch.send_capacity()
|
||||
self._check_peer(ch)
|
||||
return n
|
||||
|
||||
def clear_log(self):
|
||||
with self.lock:
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Phase B: PSK TLS server and HTTP administration session for SCP81.
|
||||
|
||||
Implements the Remote Administration Server side of GP RAM over HTTP
|
||||
(GPC v2.2 Amendment B):
|
||||
|
||||
- TLS 1.2 with the PSK cipher suites of clause 4.3.2. The handshake and
|
||||
record layer are handled by the stdlib ``ssl`` module through OpenSSL's
|
||||
PSK callbacks (identity -> PSK), so no TLS code lives here.
|
||||
- The HTTP dialog of clause 4.4: parse the Security Domain's POST
|
||||
(``X-Admin-*`` headers, optional body with the previous response string)
|
||||
and answer with 200 + a command string, or 204 No Content to close the
|
||||
administration session.
|
||||
|
||||
The card talks TLS *through* the BIP channel: this server listens on the
|
||||
local redirect target and the BIP terminal proxies the card's SEND/RECEIVE
|
||||
DATA records to it.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
|
||||
MAX_HEAD = 32 * 1024
|
||||
MAX_BODY = 1 * 1024 * 1024
|
||||
|
||||
# TLS_PSK_* suites from GPC v2.2 Amendment B Table 4-2 / RFC 4279/4785/5487.
|
||||
PSK_CIPHERS = ':'.join([
|
||||
'PSK-AES128-CBC-SHA256', # TLS_PSK_WITH_AES_128_CBC_SHA256 (0x00AE)
|
||||
'PSK-AES128-CBC-SHA', # TLS_PSK_WITH_AES_128_CBC_SHA (0x008C)
|
||||
'PSK-AES256-CBC-SHA', # TLS_PSK_WITH_AES_256_CBC_SHA (0x008D)
|
||||
'PSK-3DES-EDE-CBC-SHA', # TLS_PSK_WITH_3DES_EDE_CBC_SHA (0x008B)
|
||||
'PSK-NULL-SHA256', # TLS_PSK_WITH_NULL_SHA256 (0x00B0)
|
||||
'PSK-NULL-SHA', # TLS_PSK_WITH_NULL_SHA (0x002C)
|
||||
])
|
||||
|
||||
GP_PROTOCOL = 'globalplatform-remote-admin/1.0'
|
||||
GP_CT_COMMAND = 'application/vnd.globalplatform.card-content-mgt;version=1.0'
|
||||
GP_CT_RESPONSE = 'application/vnd.globalplatform.card-content-mgt-response;version=1.0'
|
||||
|
||||
# OpenSSL SSL_OP_NO_ENCRYPT_THEN_MAC (not exposed by the ssl module). The live
|
||||
# card offers the encrypt_then_mac extension but aborts the session with
|
||||
# SSLV3_ALERT_UNEXPECTED_MESSAGE as soon as the server echoes it, so keep the
|
||||
# extension out of the ServerHello (verified live 2026-09-15).
|
||||
OP_NO_ENCRYPT_THEN_MAC = 0x00080000
|
||||
|
||||
TLS_VERSIONS = {
|
||||
'1.0': ssl.TLSVersion.TLSv1,
|
||||
'1.1': ssl.TLSVersion.TLSv1_1,
|
||||
'1.2': ssl.TLSVersion.TLSv1_2,
|
||||
}
|
||||
|
||||
|
||||
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)."""
|
||||
head = data.split(b'\r\n\r\n', 1)[0]
|
||||
lines = head.split(b'\r\n')
|
||||
parts = lines[0].split(b' ')
|
||||
if len(parts) < 3:
|
||||
raise ValueError('malformed request line')
|
||||
method, target = parts[0].decode('latin-1'), parts[1].decode('latin-1')
|
||||
headers = {}
|
||||
for line in lines[1:]:
|
||||
name, _, value = line.partition(b':')
|
||||
headers[name.strip().decode('latin-1').lower()] = value.strip().decode('latin-1')
|
||||
return method, target, headers
|
||||
|
||||
|
||||
def decode_chunked(body):
|
||||
"""Decode a chunked transfer body (RFC 2616 3.6.1)."""
|
||||
out = bytearray()
|
||||
while body:
|
||||
line, _, rest = body.partition(b'\r\n')
|
||||
try:
|
||||
size = int(line.split(b';')[0], 16)
|
||||
except ValueError:
|
||||
raise ValueError('bad chunk size %r' % line[:16])
|
||||
if size == 0:
|
||||
break
|
||||
out.extend(rest[:size])
|
||||
body = rest[size + 2:]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_http_response(status, reason, headers, body=b'', chunked=False,
|
||||
compact=False, connection=None):
|
||||
"""Build an HTTP response. With chunked=True the body is framed as 100-byte
|
||||
chunks (like the reference admin server); with compact=True header names
|
||||
and values are separated by ':' without whitespace, which keeps the whole
|
||||
response inside one card-sized TLS record (<= 256 bytes ciphertext).
|
||||
connection ('close'/'keep-alive') declares the connection fate: without
|
||||
it an HTTP/1.1 client assumes the connection persists and tries to reuse
|
||||
it for the next POST instead of dialing a new one (live card 2026-09-15)."""
|
||||
lines = ['HTTP/1.1 %d %s' % (status, reason)]
|
||||
sep = ':' if compact else ': '
|
||||
for name, value in headers.items():
|
||||
lines.append('%s%s%s' % (name, sep, value))
|
||||
if connection:
|
||||
lines.append('Connection%s%s' % (sep, connection))
|
||||
has_te = 'transfer-encoding' in [k.lower() for k in headers]
|
||||
if body and (chunked or has_te):
|
||||
if not has_te:
|
||||
lines.append('Transfer-Encoding: chunked')
|
||||
elif body and 'content-length' not in [k.lower() for k in headers]:
|
||||
lines.append('Content-Length%s%d' % (sep, len(body)))
|
||||
head = ('\r\n'.join(lines) + '\r\n\r\n').encode('latin-1')
|
||||
if not body:
|
||||
return head
|
||||
if not chunked:
|
||||
return head + body
|
||||
out = bytearray(head)
|
||||
for i in range(0, len(body), 100):
|
||||
piece = body[i:i + 100]
|
||||
out += ('%X\r\n' % len(piece)).encode('latin-1') + piece + b'\r\n'
|
||||
out += b'0\r\n\r\n'
|
||||
return bytes(out)
|
||||
|
||||
|
||||
class PskTlsServer:
|
||||
"""PSK TLS listener speaking the GP remote administration HTTP dialog."""
|
||||
|
||||
def __init__(self, host, port, psk, 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):
|
||||
self.psk = psk
|
||||
self.identity = identity
|
||||
self.on_log = on_log
|
||||
self.responder = responder or self._default_responder
|
||||
self.timeout = timeout
|
||||
self.chunked = chunked
|
||||
# chunk_size 0 = one record for the whole response
|
||||
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'
|
||||
# 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
|
||||
# Called with the peer address just before closing a non-keep-alive
|
||||
# connection: the server waits until the card has drained the BIP
|
||||
# buffer, otherwise the EOF truncates the response fetch.
|
||||
self.on_before_close = on_before_close
|
||||
# Debug aid: write the TLS traffic secrets to this file
|
||||
# (SSLKEYLOGFILE format), so captures of the PSK dialog can be
|
||||
# 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).
|
||||
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
|
||||
# not answer with its own close_notify in time, so this cannot be
|
||||
# implemented with the stdlib ssl module; the flag is kept for the
|
||||
# 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).
|
||||
self.answer_delay = float(answer_delay or 0)
|
||||
self.identity_seen = None
|
||||
self.stopped = False
|
||||
self.conns = []
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
# A quick Stop -> Start can race the previous listener's close (the
|
||||
# port stays busy for a moment); retry before giving up.
|
||||
last_error = None
|
||||
for _ in range(10):
|
||||
try:
|
||||
self.sock.bind((host, int(port)))
|
||||
last_error = None
|
||||
break
|
||||
except OSError as e:
|
||||
last_error = e
|
||||
time.sleep(0.3)
|
||||
if last_error is not None:
|
||||
self.sock.close()
|
||||
raise last_error
|
||||
self.sock.listen(4)
|
||||
self.host, self.port = self.sock.getsockname()[:2]
|
||||
self.ctx = self._make_context()
|
||||
if self.keylog:
|
||||
try:
|
||||
self.ctx.keylog_filename = self.keylog
|
||||
except (AttributeError, OSError):
|
||||
self.keylog = None
|
||||
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
self.thread.start()
|
||||
self.log('tls-listener-start', host=self.host, port=self.port)
|
||||
|
||||
def log(self, kind, **fields):
|
||||
if self.on_log:
|
||||
try:
|
||||
self.on_log(kind, **fields)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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
|
||||
ciphers = self.cipher or PSK_CIPHERS
|
||||
if self.tls_version in ('1.0', '1.1'):
|
||||
# OpenSSL 3.x disables the legacy protocol versions by default.
|
||||
ciphers += ':@SECLEVEL=0'
|
||||
ctx.set_ciphers(ciphers)
|
||||
# Prefer our (AES-first) order over the card's NULL-suite-first list.
|
||||
ctx.options |= ssl.OP_CIPHER_SERVER_PREFERENCE
|
||||
ctx.options |= OP_NO_ENCRYPT_THEN_MAC
|
||||
# No TLS session resumption: the live card aborts with
|
||||
# SSLV3_ALERT_UNEXPECTED_MESSAGE on the post-handshake
|
||||
# NewSessionTicket record (verified live 2026-09-15).
|
||||
ctx.options |= ssl.OP_NO_TICKET
|
||||
ctx.set_psk_server_callback(self._psk_cb)
|
||||
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.
|
||||
return b'\x00' * 16
|
||||
return self.psk
|
||||
|
||||
@staticmethod
|
||||
def _default_responder(method, target, headers, body):
|
||||
"""No script configured: close the administration session (4.4.2)."""
|
||||
return 204, {'X-Admin-Protocol': GP_PROTOCOL}, b''
|
||||
|
||||
def _accept_loop(self):
|
||||
while not self.stopped:
|
||||
try:
|
||||
self.sock.settimeout(0.2)
|
||||
conn, addr = self.sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
self.conns.append(conn)
|
||||
peer = '%s:%d' % addr[:2]
|
||||
threading.Thread(target=self._conn_loop, args=(conn, peer),
|
||||
daemon=True).start()
|
||||
|
||||
def _read_request(self, tls):
|
||||
buf = b''
|
||||
while b'\r\n\r\n' not in buf:
|
||||
chunk = tls.recv(4096)
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
if len(buf) > MAX_HEAD:
|
||||
raise ValueError('request head too large')
|
||||
head, _, rest = buf.partition(b'\r\n\r\n')
|
||||
method, target, headers = parse_http_request(head + b'\r\n\r\n')
|
||||
body = rest
|
||||
if 'content-length' in headers:
|
||||
want = int(headers['content-length'])
|
||||
while len(body) < want:
|
||||
chunk = tls.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
body += chunk
|
||||
body = body[:want]
|
||||
elif headers.get('transfer-encoding', '').lower() == 'chunked':
|
||||
while not body.endswith(b'0\r\n\r\n'):
|
||||
chunk = tls.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
body += chunk
|
||||
body = decode_chunked(body)
|
||||
return method, target, headers, body
|
||||
|
||||
def _conn_loop(self, conn, peer):
|
||||
tls = None
|
||||
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)
|
||||
while not self.stopped:
|
||||
req = self._read_request(tls)
|
||||
if req is None:
|
||||
break
|
||||
method, target, headers, body = req
|
||||
if self.answer_delay > 0:
|
||||
time.sleep(self.answer_delay)
|
||||
self.log('tls-request', peer=peer, method=method, uri=target,
|
||||
headers=headers,
|
||||
agent=headers.get('x-admin-from'),
|
||||
protocol=headers.get('x-admin-protocol'),
|
||||
script_status=headers.get('x-admin-script-status'),
|
||||
resume=headers.get('x-admin-resume'),
|
||||
content_type=headers.get('content-type'),
|
||||
bytes=len(body), body_hex=body.hex().upper()[:2000] or None)
|
||||
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'
|
||||
response = build_http_response(
|
||||
status, reason, resp_headers, resp_body,
|
||||
chunked=self.chunked, compact=self.compact_headers,
|
||||
connection=conn)
|
||||
# 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
|
||||
# followed by the body draws an unexpected_message alert. When
|
||||
# a chunk_size is given, the head goes in one record and the
|
||||
# body in pieces of that size.
|
||||
if self.chunk_size <= 0:
|
||||
tls.sendall(response)
|
||||
else:
|
||||
head, sep, rest = response.partition(b'\r\n\r\n')
|
||||
tls.sendall(head + sep if sep else head)
|
||||
for off in range(0, len(rest), self.chunk_size):
|
||||
tls.sendall(rest[off:off + self.chunk_size])
|
||||
self.log('tls-response', peer=peer, status=status,
|
||||
bytes=len(resp_body), chunked=self.chunked,
|
||||
response_hex=response.hex().upper()[:600],
|
||||
body_hex=resp_body.hex().upper()[:2000] or None)
|
||||
# 204 always ends the dialog. Without keep-alive every response
|
||||
# ends it: the card's HTTP client appears to delimit the
|
||||
# response at connection close (live 2026-09-15) and then
|
||||
# starts a fresh session for its next POST.
|
||||
if status == 204 or not resp_body or not self.keep_alive:
|
||||
peer_name = None
|
||||
if resp_body and self.on_before_close:
|
||||
try:
|
||||
peer_name = tls.getpeername()
|
||||
except Exception:
|
||||
peer_name = None
|
||||
plain = None
|
||||
if not self.keep_alive:
|
||||
# Clean TLS shutdown BEFORE the card drains the
|
||||
# buffer: a bare TCP close leaves the card's TLS stack
|
||||
# with a truncated session (it then neither processes
|
||||
# the script nor posts the response), and a
|
||||
# close_notify sent only after the drain is never
|
||||
# fetched. Send it while the response still waits, so
|
||||
# the card reads both, then wait for the buffer to
|
||||
# drain and only then send the FIN.
|
||||
try:
|
||||
tls.settimeout(2.0)
|
||||
plain = tls.unwrap()
|
||||
tls = None
|
||||
except Exception:
|
||||
plain = None
|
||||
if peer_name and self.on_before_close:
|
||||
try:
|
||||
self.on_before_close(peer_name)
|
||||
except Exception:
|
||||
pass
|
||||
if plain is not None:
|
||||
try:
|
||||
plain.close()
|
||||
except OSError:
|
||||
pass
|
||||
break
|
||||
except ssl.SSLError as e:
|
||||
self.log('tls-error', peer=peer, error=str(e))
|
||||
except (OSError, ValueError) as e:
|
||||
self.log('tls-error', peer=peer, error=str(e))
|
||||
finally:
|
||||
if tls is not None:
|
||||
try:
|
||||
tls.close()
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.log('tls-close', peer=peer)
|
||||
if conn in self.conns:
|
||||
self.conns.remove(conn)
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
self.log('tls-listener-stop', host=self.host, port=self.port)
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
for conn in list(self.conns):
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.conns = []
|
||||
+618
-30
@@ -12,6 +12,7 @@ from io import StringIO
|
||||
from pySim.transport import ApduTracer, ProactiveHandler
|
||||
from pySim.cards import UiccCardBase
|
||||
from pysim_otaman_server import httpota
|
||||
from pysim_otaman_server import scp81
|
||||
from smartcard.CardMonitoring import CardMonitor, CardObserver
|
||||
|
||||
import gsm0338 # registers 'gsm03.38' codec
|
||||
@@ -20,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
|
||||
from osmocom.tlv import BER_TLV_IE
|
||||
|
||||
|
||||
VERSION = '2.1.2'
|
||||
VERSION = '2.1.5'
|
||||
|
||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||
|
||||
@@ -709,14 +710,18 @@ class _DefaultProactiveHandler(ProactiveHandler):
|
||||
def receive_fetch_raw(self, pcmd, parsed):
|
||||
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = 1, 0, 0x83, 0x81, None
|
||||
entry = None
|
||||
# pySim parses the FETCH response into a command-specific object
|
||||
# ('parsed'); the 'pcmd' collection stays empty and is only useful as
|
||||
# a fallback. Use the parsed object for both the log and the response.
|
||||
cmd_obj = parsed if getattr(parsed, 'children', None) else pcmd
|
||||
try:
|
||||
raw = bytes.fromhex(parsed) if parsed else None
|
||||
raw = cmd_obj.to_tlv()
|
||||
if raw:
|
||||
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw)
|
||||
entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num)
|
||||
except Exception:
|
||||
pass
|
||||
ti_list = self.prepare_response(pcmd, 'performed_successfully')
|
||||
ti_list = self.prepare_response(cmd_obj, 'performed_successfully')
|
||||
if cmd_type == 0x26 and cmd_qual is not None:
|
||||
pli_hex = _PLI_DATA.get(cmd_qual, '')
|
||||
if pli_hex:
|
||||
@@ -740,6 +745,9 @@ class _RawBerTlv(BER_TLV_IE):
|
||||
def to_bytes(self, context={}):
|
||||
return self._raw
|
||||
|
||||
def to_tlv(self):
|
||||
return self._raw
|
||||
|
||||
|
||||
_STK_DECODE = GsmOrUcs2Adapter(GreedyBytes)
|
||||
|
||||
@@ -752,7 +760,7 @@ PROACTIVE_TYPE_NAMES = {
|
||||
0x13: 'SEND SHORT MESSAGE', 0x20: 'PLAY TONE',
|
||||
0x21: 'DISPLAY TEXT', 0x22: 'GET INKEY', 0x23: 'GET INPUT',
|
||||
0x24: 'SELECT ITEM', 0x25: 'SET UP MENU',
|
||||
0x26: 'PROVIDE LOCAL INFORMATION',
|
||||
0x26: 'PROVIDE LOCAL INFORMATION', 0x27: 'TIMER MANAGEMENT',
|
||||
0x15: 'LAUNCH BROWSER', 0x70: 'ACTIVATE',
|
||||
0x40: 'OPEN CHANNEL', 0x41: 'CLOSE CHANNEL',
|
||||
0x42: 'RECEIVE DATA', 0x43: 'SEND DATA', 0x44: 'GET CHANNEL STATUS',
|
||||
@@ -787,6 +795,7 @@ _PLI_DATA = {q: '' for q in PLI_QUALIFIER_NAMES}
|
||||
|
||||
_BIP = httpota.BipTerminal()
|
||||
_SCP81_LISTENER = None
|
||||
_SCP81_PSK = {}
|
||||
|
||||
_POLL_ENABLED = False
|
||||
_POLL_INTERVAL = 30
|
||||
@@ -959,6 +968,41 @@ def _dec_imei(hex8):
|
||||
return s[:15]
|
||||
|
||||
|
||||
def _cmd_tlv(tlvs, tag):
|
||||
"""Fetch a command TLV, tolerating both the plain tag and its
|
||||
comprehension-required variant (e.g. 0x24 and 0xA4, TS 101 220 7.1.1)."""
|
||||
return tlvs.get(tag) or tlvs.get(tag | 0x80) or b''
|
||||
|
||||
|
||||
def _tlv_map(data):
|
||||
"""Top-level COMPREHENSION-TLV map {tag: value} of a payload without a
|
||||
D0 wrapper (e.g. the command-specific TLVs of a TERMINAL RESPONSE)."""
|
||||
out = {}
|
||||
off = 0
|
||||
while off + 1 < len(data):
|
||||
tag, tlen = data[off], data[off + 1]
|
||||
out.setdefault(tag, data[off + 2: off + 2 + tlen])
|
||||
off += 2 + tlen
|
||||
return out
|
||||
|
||||
|
||||
def _bcd_swap(b):
|
||||
"""Semi-octet BCD digit pair (TS 123 040 TP-SCT): low nibble first."""
|
||||
return (b & 0x0F) * 10 + ((b >> 4) & 0x0F)
|
||||
|
||||
|
||||
def _hms_bcd(seconds):
|
||||
"""Encode seconds as hour/minute/second semi-octet BCD (TS 123 040)."""
|
||||
h, rem = divmod(int(seconds), 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return bytes([((h % 10) << 4) | (h // 10),
|
||||
((m % 10) << 4) | (m // 10),
|
||||
((s % 10) << 4) | (s // 10)])
|
||||
|
||||
|
||||
_TIMER_ACTIONS = {0x00: 'Start', 0x01: 'Deactivate', 0x02: 'Get current value'}
|
||||
|
||||
|
||||
def _decode_cmd(cmd_type, raw, qualifier):
|
||||
"""Decode a fetched proactive command into [{label, value}] pairs."""
|
||||
if not raw:
|
||||
@@ -1000,6 +1044,23 @@ def _decode_cmd(cmd_type, raw, qualifier):
|
||||
if cmd_type == 0x26 and qualifier is not None:
|
||||
name = PLI_QUALIFIER_NAMES.get(qualifier, 'Unknown')
|
||||
return [{'label': 'Qualifier', 'value': '%s (0x%02X)' % (name, qualifier)}]
|
||||
if cmd_type == 0x27:
|
||||
out = []
|
||||
if qualifier is not None:
|
||||
action = _TIMER_ACTIONS.get(qualifier & 0x03)
|
||||
out.append({'label': 'Action',
|
||||
'value': action or 'Reserved (0x%02X)' % qualifier})
|
||||
tlvs = httpota.proactive_tlvs(raw)
|
||||
timer = _cmd_tlv(tlvs, 0x24)
|
||||
if timer:
|
||||
tv = timer[0]
|
||||
out.append({'label': 'Timer',
|
||||
'value': str(tv) if 1 <= tv <= 8 else 'Invalid (0x%02X)' % tv})
|
||||
value = _cmd_tlv(tlvs, 0x25)
|
||||
if len(value) >= 3:
|
||||
out.append({'label': 'Value', 'value': '%02d:%02d:%02d' % (
|
||||
_bcd_swap(value[0]), _bcd_swap(value[1]), _bcd_swap(value[2]))})
|
||||
return out
|
||||
return [{'label': 'Data', 'value': raw.hex()}]
|
||||
|
||||
|
||||
@@ -1015,6 +1076,17 @@ def _decode_tr(type_hex, qual_hex, tr_hex):
|
||||
return [{'label': 'Data', 'value': h}]
|
||||
if cmd_type == 0x03 and len(h) >= 8 and h[0:2] == '84':
|
||||
return [{'label': 'Interval', 'value': '%d s' % int(h[6:8], 16)}]
|
||||
if cmd_type == 0x27:
|
||||
tlvs = _tlv_map(bytes.fromhex(h))
|
||||
out = []
|
||||
tid = _cmd_tlv(tlvs, 0x24)
|
||||
if tid:
|
||||
out.append({'label': 'Timer', 'value': str(tid[0])})
|
||||
val = _cmd_tlv(tlvs, 0x25)
|
||||
if len(val) >= 3:
|
||||
out.append({'label': 'Remaining', 'value': '%02d:%02d:%02d' % (
|
||||
_bcd_swap(val[0]), _bcd_swap(val[1]), _bcd_swap(val[2]))})
|
||||
return out or [{'label': 'Data', 'value': h}]
|
||||
if cmd_type == 0x26 and qual_hex:
|
||||
try:
|
||||
qual = int(qual_hex, 16)
|
||||
@@ -1088,28 +1160,31 @@ def _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, result=0x00, info=Non
|
||||
def _decode_bip_cmd(cmd_type, raw):
|
||||
tlvs = httpota.proactive_tlvs(raw)
|
||||
out = []
|
||||
dev = _cmd_tlv(tlvs, 0x02)
|
||||
if len(dev) >= 2 and 0x21 <= dev[1] <= 0x27:
|
||||
out.append({'label': 'Channel', 'value': str(dev[1] & 0x07)})
|
||||
if cmd_type == 0x40:
|
||||
bearer = tlvs.get(httpota.TAG_BEARER, b'')
|
||||
bearer = _cmd_tlv(tlvs, httpota.TAG_BEARER)
|
||||
if bearer:
|
||||
out.append({'label': 'Bearer', 'value': '0x%02X' % bearer[0]})
|
||||
bs = tlvs.get(httpota.TAG_BUFFER_SIZE, b'')
|
||||
bs = _cmd_tlv(tlvs, httpota.TAG_BUFFER_SIZE)
|
||||
if len(bs) >= 2:
|
||||
out.append({'label': 'Buffer size', 'value': str(int.from_bytes(bs[:2], 'big'))})
|
||||
naa = tlvs.get(httpota.TAG_NAA, b'')
|
||||
naa = _cmd_tlv(tlvs, httpota.TAG_NAA)
|
||||
if naa:
|
||||
out.append({'label': 'APN', 'value': naa[1:].decode('ascii', 'replace')})
|
||||
addr = httpota.parse_other_address(tlvs.get(httpota.TAG_OTHER_ADDRESS, b''))
|
||||
addr = httpota.parse_other_address(_cmd_tlv(tlvs, httpota.TAG_OTHER_ADDRESS))
|
||||
if addr:
|
||||
out.append({'label': 'Destination', 'value': addr})
|
||||
proto, port = httpota.parse_transport_level(tlvs.get(httpota.TAG_TRANSPORT_LEVEL, b''))
|
||||
proto, port = httpota.parse_transport_level(_cmd_tlv(tlvs, httpota.TAG_TRANSPORT_LEVEL))
|
||||
if port is not None:
|
||||
out.append({'label': 'Transport', 'value': '%s port %d' % ({0x02: 'TCP client'}.get(proto, 'proto 0x%02X' % (proto or 0)), port)})
|
||||
elif cmd_type == 0x42:
|
||||
req = tlvs.get(httpota.TAG_CHANNEL_DATA_LENGTH, b'')
|
||||
req = _cmd_tlv(tlvs, httpota.TAG_CHANNEL_DATA_LENGTH)
|
||||
if req:
|
||||
out.append({'label': 'Requested bytes', 'value': str(req[0])})
|
||||
elif cmd_type == 0x43:
|
||||
data = tlvs.get(httpota.TAG_CHANNEL_DATA, b'')
|
||||
data = _cmd_tlv(tlvs, httpota.TAG_CHANNEL_DATA)
|
||||
out.append({'label': 'Data bytes', 'value': str(len(data))})
|
||||
if data:
|
||||
out.append({'label': 'Data', 'value': data.hex()[:120]})
|
||||
@@ -1121,33 +1196,41 @@ def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
|
||||
tlvs = httpota.proactive_tlvs(raw)
|
||||
channel = _bip_channel_id(dev_dst)
|
||||
if cmd_type == 0x40:
|
||||
bs = tlvs.get(httpota.TAG_BUFFER_SIZE, b'\x02\x00')
|
||||
bs = _cmd_tlv(tlvs, httpota.TAG_BUFFER_SIZE) or b'\x02\x00'
|
||||
buffer_size = int.from_bytes(bs[:2], 'big') if len(bs) >= 2 else 0x0200
|
||||
bearer = tlvs.get(httpota.TAG_BEARER, b'\x03')
|
||||
bearer = _cmd_tlv(tlvs, httpota.TAG_BEARER) or b'\x03'
|
||||
extra = bytes([httpota.TAG_BEARER, len(bearer)]) + bearer
|
||||
extra += bytes([httpota.TAG_BUFFER_SIZE, 0x02]) + buffer_size.to_bytes(2, 'big')
|
||||
if not _BIP.enabled:
|
||||
_BIP.log('open-unavailable', reason='BIP not enabled')
|
||||
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00, extra)
|
||||
addr = httpota.parse_other_address(tlvs.get(httpota.TAG_OTHER_ADDRESS, b''))
|
||||
proto, port = httpota.parse_transport_level(tlvs.get(httpota.TAG_TRANSPORT_LEVEL, b''))
|
||||
addr = httpota.parse_other_address(_cmd_tlv(tlvs, httpota.TAG_OTHER_ADDRESS))
|
||||
proto, port = httpota.parse_transport_level(_cmd_tlv(tlvs, httpota.TAG_TRANSPORT_LEVEL))
|
||||
if not addr or port is None:
|
||||
_BIP.log('open-unavailable', reason='missing destination/transport', address=addr, port=port)
|
||||
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00, extra)
|
||||
cid, err = _BIP.open(addr, port, buffer_size)
|
||||
# Emulation is deliberately permissive: the APN, destination and
|
||||
# transport are informational, the channel always goes to the
|
||||
# configured local target (the live card emits truncated/empty
|
||||
# destination TLVs - see the AGENTS.md HTTP OTA notes).
|
||||
_BIP.log('open-relaxed', address=addr, port=port,
|
||||
note='destination/transport not fully specified')
|
||||
cid, err = _BIP.open(addr or '-', port or 0, buffer_size)
|
||||
if cid is None:
|
||||
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00, extra)
|
||||
if cmd_qual and (cmd_qual & 0x04):
|
||||
# Background mode: the terminal shall inform the UICC that the
|
||||
# link was established (TS 102 223 7.5.11).
|
||||
_BIP._queue_link_status(cid, status=0x80 | (cid & 0x07), info=0x00)
|
||||
status = bytes([httpota.TAG_CHANNEL_STATUS, 0x02, 0x80 | (cid & 0x07), 0x00])
|
||||
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x00, None, status + extra)
|
||||
if cmd_type == 0x43:
|
||||
data = tlvs.get(httpota.TAG_CHANNEL_DATA, b'')
|
||||
data = _cmd_tlv(tlvs, httpota.TAG_CHANNEL_DATA)
|
||||
if channel is None or not _BIP.send(channel, data):
|
||||
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00)
|
||||
length = _BIP.send_capacity(channel)
|
||||
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x00, None,
|
||||
bytes([httpota.TAG_CHANNEL_DATA_LENGTH, 0x01, length & 0xFF]))
|
||||
if cmd_type == 0x42:
|
||||
req = tlvs.get(httpota.TAG_CHANNEL_DATA_LENGTH, b'\x00')
|
||||
req = _cmd_tlv(tlvs, httpota.TAG_CHANNEL_DATA_LENGTH) or b'\x00'
|
||||
n = req[0] if req else 0
|
||||
if channel is None:
|
||||
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00)
|
||||
@@ -1155,7 +1238,17 @@ def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
|
||||
if data is None:
|
||||
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00)
|
||||
remaining = _BIP.available(channel)
|
||||
extra = bytes([httpota.TAG_CHANNEL_DATA, len(data)]) + data if data else b''
|
||||
# The channel data TLV length is BER-encoded: a single byte only up
|
||||
# to 127, then the 0x81 long form (the reference terminal traces use
|
||||
# `36 81 ed` for a 237-byte chunk). With a raw length byte >0x7F the
|
||||
# card reads a malformed TLV and the record bytes never reach its
|
||||
# TLS layer (it fetches, accepts, and never processes the response).
|
||||
extra = b''
|
||||
if data:
|
||||
if len(data) <= 0x7F:
|
||||
extra = bytes([httpota.TAG_CHANNEL_DATA, len(data)]) + data
|
||||
else:
|
||||
extra = bytes([httpota.TAG_CHANNEL_DATA, 0x81, len(data)]) + data
|
||||
extra += bytes([httpota.TAG_CHANNEL_DATA_LENGTH, 0x01, 0xFF if remaining > 0xFF else remaining])
|
||||
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x00, None, extra)
|
||||
if cmd_type == 0x41:
|
||||
@@ -1173,11 +1266,258 @@ def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
|
||||
def _scp81_listener_status():
|
||||
if not _SCP81_LISTENER:
|
||||
return None
|
||||
if isinstance(_SCP81_LISTENER, scp81.PskTlsServer):
|
||||
return {'mode': 'tls', 'host': _SCP81_LISTENER.host, 'port': _SCP81_LISTENER.port,
|
||||
'psk_identity': _SCP81_LISTENER.identity,
|
||||
'identity_seen': _SCP81_LISTENER.identity_seen,
|
||||
'chunked': _SCP81_LISTENER.chunked,
|
||||
'chunk_size': _SCP81_LISTENER.chunk_size,
|
||||
'keep_alive': _SCP81_LISTENER.keep_alive,
|
||||
'compact_headers': _SCP81_LISTENER.compact_headers,
|
||||
'tls_version': _SCP81_LISTENER.tls_version,
|
||||
'cipher': _SCP81_LISTENER.cipher}
|
||||
return {'mode': 'dump', 'host': _SCP81_LISTENER.host, 'port': _SCP81_LISTENER.port}
|
||||
|
||||
|
||||
def _scp81_wait_drained(peer):
|
||||
"""Wait until the BIP channel for this TLS connection has delivered its
|
||||
buffered bytes to the card (matched by the terminal's ephemeral port), so
|
||||
a connection close does not truncate the response fetch."""
|
||||
if not peer or len(peer) < 2:
|
||||
return
|
||||
port = peer[1]
|
||||
deadline = time.time() + 5.0
|
||||
seen_data = False
|
||||
while time.time() < deadline:
|
||||
ch = None
|
||||
for c in list(_BIP.channels.values()):
|
||||
try:
|
||||
if c.sock.getsockname()[1] == port:
|
||||
ch = c
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
if ch is None:
|
||||
return
|
||||
if ch.rx:
|
||||
# Channel pump has picked up the response; wait for the card.
|
||||
seen_data = True
|
||||
elif seen_data:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def _bip_data_available(ch):
|
||||
"""Monitor-thread callback: tell the card there is server data to fetch.
|
||||
|
||||
TS 102 223 7.5.10 - ENVELOPE (Event Download - Data available) carries the
|
||||
Channel status and the number of bytes waiting; the card then issues
|
||||
RECEIVE DATA. Returns True when the event was sent (the caller then marks
|
||||
the bytes as notified)."""
|
||||
server = _server_ref
|
||||
if not server or not _CARD_CONNECTED or ch.peer_closed:
|
||||
return False
|
||||
ev_list = getattr(server, 'event_list', None) or []
|
||||
if 0x09 not in ev_list or not ch.rx:
|
||||
return False
|
||||
with _CARD_LOCK:
|
||||
server = _server_ref
|
||||
if not server or not _CARD_CONNECTED or not getattr(server, 'scc', None):
|
||||
return False
|
||||
if _PROACTIVE_BUSY or getattr(server, 'stk_pending', None):
|
||||
return False
|
||||
length = min(len(ch.rx), 0xFF)
|
||||
tlv = bytes([0xB8, 0x02, 0x80 | (ch.id & 0x07), 0x00,
|
||||
0xB7, 0x01, length])
|
||||
try:
|
||||
_send_event_download(server.scc, 0x09, tlv)
|
||||
except Exception as e:
|
||||
_BIP.log('data-available-skip', channel=ch.id, reason=str(e))
|
||||
return False
|
||||
_BIP.log('data-available', channel=ch.id, length=length)
|
||||
return True
|
||||
|
||||
|
||||
# ---- SCP81 command scripting (GP RAM over HTTP, TS 102 226 5.2) -----------
|
||||
# The administration server answers the card's POST with one C-APDU per
|
||||
# request (Command Scripting template 'AE 80 22 <len> <apdu> 00 00',
|
||||
# indefinite length as recommended for RAM over HTTPS) and reads the R-APDU
|
||||
# from the next POST's Response Scripting template ('AB'/'AF', with '80'
|
||||
# executed-count and '23' R-APDU TLVs whose last two bytes are SW1 SW2).
|
||||
|
||||
_SCP81_SCRIPTS = {
|
||||
# The command sequence of the reference administration server
|
||||
# (samples/HTTP_OTA/httpota_adminserver_php_v2, get_next_apdu), extended
|
||||
# with the registries: GET DATA FF21 (extended card resources / free
|
||||
# memory), GET STATUS P1=80 (Issuer Security Domain), GET DATA 0085,
|
||||
# GET STATUS P1=40 (executable load files / ELF), GET STATUS P1=10
|
||||
# (applications/applets); P2=02 with data '4F00' selects the TLV format,
|
||||
# Le=00 so no GET RESPONSE is needed.
|
||||
'explore': ['80CAFF2100', '80F28002024F0000', '80CA008500',
|
||||
'80F24002024F0000', '80F21002024F0000'],
|
||||
'none': [],
|
||||
}
|
||||
_SCP81_SCRIPT = list(_SCP81_SCRIPTS['explore'])
|
||||
_SCP81_SCRIPT_SENT = 0
|
||||
_SCP81_SCRIPT_RESULTS = []
|
||||
_SCP81_SCRIPT_TEMPLATE = 'indefinite'
|
||||
_SCP81_SCRIPT_CR_TAG = False
|
||||
# None = short per-command Next-URI ('/N'); '' = omit the header (spec: the
|
||||
# card executes the script, sends no response string and closes the session).
|
||||
_SCP81_NEXT_URI = None
|
||||
# Optional X-Admin-Targeted-Application header (spec syntax //aid/<RID>/<PIX>).
|
||||
# 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).
|
||||
_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:
|
||||
# the card must drain the buffered response and resume on a new connection.
|
||||
_SCP81_LINK_EVENTS = True
|
||||
|
||||
|
||||
def _scp81_command_body(apdu_hex, definite=False, cr_tag=False):
|
||||
"""Command Scripting template with one C-APDU TLV: the indefinite-length
|
||||
variant ('AE 80 22 <len> <apdu> 00 00', recommended for RAM over HTTPS) or
|
||||
the definite-length one ('AA <len> 22 <len> <apdu>'). The C-APDU TLV tag
|
||||
is '22' per TS 101 220 (CR flag 0); some cards expect the CR-set 'A2'
|
||||
instead, so it is configurable."""
|
||||
apdu = bytes.fromhex(re.sub(r'\s', '', apdu_hex))
|
||||
cmd_tlv = bytes([0xA2 if cr_tag else 0x22, len(apdu)]) + apdu
|
||||
if definite:
|
||||
return bytes([0xAA, len(cmd_tlv)]) + cmd_tlv
|
||||
return bytes([0xAE, 0x80]) + cmd_tlv + b'\x00\x00'
|
||||
|
||||
|
||||
def _scp81_parse_response(body):
|
||||
"""Parse a Response Scripting template (TS 102 226 5.2.2, definite 'AB'
|
||||
or indefinite 'AF'); returns (executed_count, [(rapdu, sw_hex), ...])."""
|
||||
if not body:
|
||||
return 0, []
|
||||
if body[0] == 0xAF and len(body) >= 2 and body[1] == 0x80:
|
||||
content = body[2:-2] if body.endswith(b'\x00\x00') else body[2:]
|
||||
elif body[0] == 0xAB:
|
||||
ln, off = httpota.ber_len_read(body, 1)
|
||||
content = body[off:off + ln]
|
||||
else:
|
||||
content = body
|
||||
count, out = 0, []
|
||||
off = 0
|
||||
while off + 1 < len(content):
|
||||
tag, tlen = content[off], content[off + 1]
|
||||
val = content[off + 2:off + 2 + tlen]
|
||||
off += 2 + tlen
|
||||
if tag == 0x80:
|
||||
count = int.from_bytes(val, 'big') if val else 0
|
||||
elif tag == 0x23 and len(val) >= 2:
|
||||
out.append((val[:-2], val[-2:].hex().upper()))
|
||||
return count, out
|
||||
|
||||
|
||||
def _scp81_decode_memory(rapdu):
|
||||
"""GET DATA FF21 value: 81 applet count, 82 free NV (3 B), 83 free volatile."""
|
||||
if len(rapdu) < 5 or rapdu[0] != 0xFF or rapdu[1] != 0x21:
|
||||
return None
|
||||
content = rapdu[3:3 + rapdu[2]]
|
||||
out = {}
|
||||
off = 0
|
||||
while off + 1 < len(content):
|
||||
tag, tlen = content[off], content[off + 1]
|
||||
val = content[off + 2:off + 2 + tlen]
|
||||
off += 2 + tlen
|
||||
if tag == 0x81:
|
||||
out['applets'] = int.from_bytes(val, 'big')
|
||||
elif tag == 0x82:
|
||||
out['free_nv'] = int.from_bytes(val, 'big')
|
||||
elif tag == 0x83:
|
||||
out['free_volatile'] = int.from_bytes(val, 'big')
|
||||
return out or None
|
||||
|
||||
|
||||
def _scp81_script_responder(method, target, headers, body):
|
||||
"""Remote Administration Server side of the administration session: send
|
||||
the next scripted C-APDU or close the session (TS 102 226 / GP 4.4.2)."""
|
||||
global _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS
|
||||
status = headers.get('x-admin-script-status')
|
||||
if status is not None:
|
||||
index = _SCP81_SCRIPT_SENT
|
||||
if status != 'ok':
|
||||
_BIP.log('script-status', index=index, status=status)
|
||||
else:
|
||||
count, rapdus = _scp81_parse_response(body)
|
||||
for rapdu, sw in rapdus:
|
||||
_BIP.log('script-rapdu', index=index, sw=sw, bytes=len(rapdu),
|
||||
hex=rapdu.hex().upper()[:2000])
|
||||
_SCP81_SCRIPT_RESULTS.append({'index': index, 'sw': sw,
|
||||
'rapdu': rapdu.hex().upper()})
|
||||
if rapdus and _SCP81_SCRIPT and index >= 1:
|
||||
apdu = _SCP81_SCRIPT[index - 1].upper()
|
||||
if apdu.startswith('80CAFF21'):
|
||||
decoded = _scp81_decode_memory(rapdus[-1][0])
|
||||
if decoded:
|
||||
_BIP.log('script-memory', **decoded)
|
||||
else:
|
||||
# First (or resumed) POST of a session: run the script from the start.
|
||||
_SCP81_SCRIPT_SENT = 0
|
||||
_SCP81_SCRIPT_RESULTS = []
|
||||
if _SCP81_SCRIPT_SENT < len(_SCP81_SCRIPT):
|
||||
apdu = _SCP81_SCRIPT[_SCP81_SCRIPT_SENT]
|
||||
_SCP81_SCRIPT_SENT += 1
|
||||
_BIP.log('script-send', index=_SCP81_SCRIPT_SENT, apdu=apdu)
|
||||
headers = _scp81_response_headers()
|
||||
if _SCP81_TARGETED_APP:
|
||||
headers['X-Admin-Targeted-Application'] = _SCP81_TARGETED_APP
|
||||
# The working reference session (samples/HTTPOTA_session_3311_success1)
|
||||
# answers with a relative URI plus a QUERY (/Download?req=N): a
|
||||
# query-less Next-URI makes the card abort the TLS session. A '%d'
|
||||
# in the configured/default URI is replaced with the command number.
|
||||
template = _SCP81_NEXT_URI if _SCP81_NEXT_URI is not None else '/api/scp81?req=%d'
|
||||
next_uri = template % _SCP81_SCRIPT_SENT if '%d' in template else template
|
||||
if next_uri:
|
||||
headers['X-Admin-Next-URI'] = next_uri
|
||||
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))
|
||||
headers['Content-Type'] = scp81.GP_CT_COMMAND
|
||||
return 200, headers, body_out
|
||||
_BIP.log('script-done', sent=_SCP81_SCRIPT_SENT,
|
||||
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''
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _scp81_bip_control(body):
|
||||
global _SCP81_LISTENER
|
||||
global _SCP81_LISTENER, _SCP81_PSK
|
||||
global _SCP81_SCRIPT, _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS
|
||||
global _SCP81_SCRIPT_TEMPLATE, _SCP81_SCRIPT_CR_TAG, _SCP81_NEXT_URI
|
||||
global _SCP81_LINK_EVENTS, _SCP81_TARGETED_APP, _SCP81_APACHE_HEADERS
|
||||
global _SCP81_CHUNKED
|
||||
body = body or {}
|
||||
action = body.get('action', 'start')
|
||||
if action == 'stop':
|
||||
@@ -1187,14 +1527,81 @@ def _scp81_bip_control(body):
|
||||
_BIP.disable()
|
||||
return {'ok': True, 'bip': _BIP.status(), 'listener': None}
|
||||
host = body.get('host') or '127.0.0.1'
|
||||
port = int(body.get('port') or 8443)
|
||||
port = body.get('port')
|
||||
port = int(port) if port not in (None, '') else 8443
|
||||
mode = body.get('mode', 'dump')
|
||||
if _SCP81_LISTENER:
|
||||
_SCP81_LISTENER.stop()
|
||||
_SCP81_LISTENER = None
|
||||
_BIP.disable()
|
||||
if mode == 'tls':
|
||||
psk_hex = body.get('psk_hex') or _SCP81_PSK.get('psk_hex')
|
||||
if not psk_hex:
|
||||
return {'ok': False, 'error': 'psk_hex is required for tls mode'}
|
||||
try:
|
||||
psk = bytes.fromhex(re.sub(r'\s', '', psk_hex))
|
||||
except ValueError:
|
||||
return {'ok': False, 'error': 'psk_hex is not valid hex'}
|
||||
if not psk:
|
||||
return {'ok': False, 'error': 'psk_hex is empty'}
|
||||
identity = body.get('psk_identity')
|
||||
if identity is not None:
|
||||
identity = identity.strip() or None # empty clears the pin
|
||||
else:
|
||||
identity = _SCP81_PSK.get('psk_identity')
|
||||
_SCP81_PSK = {'psk_hex': psk_hex, 'psk_identity': identity}
|
||||
script = body.get('script', 'explore')
|
||||
if isinstance(script, list):
|
||||
_SCP81_SCRIPT = [re.sub(r'\s', '', s) for s in script if s]
|
||||
elif script in _SCP81_SCRIPTS:
|
||||
_SCP81_SCRIPT = list(_SCP81_SCRIPTS[script])
|
||||
else:
|
||||
return {'ok': False, 'error': 'unknown script preset: %s' % script}
|
||||
_SCP81_SCRIPT_SENT = 0
|
||||
_SCP81_SCRIPT_RESULTS = []
|
||||
template = body.get('script_template', 'indefinite')
|
||||
if template not in ('indefinite', 'definite'):
|
||||
return {'ok': False, 'error': 'script_template must be indefinite or definite'}
|
||||
_SCP81_SCRIPT_TEMPLATE = template
|
||||
_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))
|
||||
_SCP81_CHUNKED = bool(body.get('chunked', True))
|
||||
cs = body.get('chunk_size')
|
||||
chunk_size = int(cs) if cs not in (None, '') else 0
|
||||
_SCP81_LISTENER = scp81.PskTlsServer(
|
||||
host, port, psk, identity=identity,
|
||||
responder=_scp81_script_responder,
|
||||
chunked=bool(body.get('chunked', True)),
|
||||
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'),
|
||||
cipher=(body.get('cipher') or None),
|
||||
on_before_close=_scp81_wait_drained,
|
||||
keylog=(body.get('keylog') or None),
|
||||
conn_header=(body.get('conn_header') or 'none'),
|
||||
answer_delay=(body.get('answer_delay') or 0),
|
||||
on_log=lambda kind, **fields: _BIP.log(kind, **fields))
|
||||
_BIP.on_data = _bip_data_available
|
||||
_BIP.enable(host, _SCP81_LISTENER.port)
|
||||
return {'ok': True, 'bip': _BIP.status(), 'listener': _scp81_listener_status(),
|
||||
'script': _SCP81_SCRIPT, '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}
|
||||
_BIP.on_data = _bip_data_available
|
||||
_SCP81_LISTENER = httpota.TcpDumpServer(
|
||||
host, port,
|
||||
on_rx=lambda peer, data: _BIP.log('dump-rx', peer=peer, bytes=len(data), hex=data.hex().upper()[:2000]),
|
||||
@@ -1232,9 +1639,9 @@ _RESULT_NAMES_BASIC = {
|
||||
0x21: 'Backward move in the proactive SIM session requested by the user',
|
||||
0x22: 'No response from user',
|
||||
0x23: 'Help information required by the user',
|
||||
0x24: 'USSD or SS transaction terminated by the user',
|
||||
0x25: 'Proactive SIM session terminated by the user',
|
||||
0x26: 'Backward move in the proactive SIM session requested by the user',
|
||||
0x24: 'Action in contradiction with the current timer state',
|
||||
0x25: 'Interaction with call control by NAA, temporary problem',
|
||||
0x26: 'Launch browser generic error',
|
||||
}
|
||||
|
||||
_RESULT_NAMES_GENERAL = {
|
||||
@@ -1309,6 +1716,7 @@ def _handle_card_disconnect():
|
||||
global _CARD_CONNECTED
|
||||
_poll_disable()
|
||||
_cancel_menu_timeout()
|
||||
_timer_cancel()
|
||||
_CARD_CONNECTED = False
|
||||
if _server_ref:
|
||||
_server_ref.card = None
|
||||
@@ -1329,6 +1737,7 @@ def _apply_equipped_card(server):
|
||||
server.stk_pending = None
|
||||
server.menu_active = False
|
||||
_cancel_menu_timeout()
|
||||
_timer_cancel()
|
||||
server.event_list = None
|
||||
_reset_proactive_log()
|
||||
server.card = server.app.card
|
||||
@@ -1502,6 +1911,39 @@ def _send_event_download(scc, event_type, event_data=None):
|
||||
return data, sw
|
||||
|
||||
|
||||
_FLUSHING_CHANNEL_EVENTS = False
|
||||
# True while a FETCH/TERMINAL RESPONSE chain is running: terminal-initiated
|
||||
# ENVELOPEs must never interleave with it.
|
||||
_PROACTIVE_BUSY = False
|
||||
|
||||
|
||||
def _bip_flush_channel_events(scc):
|
||||
"""Inform the UICC about BIP link changes detected outside its proactive
|
||||
commands (TS 102 223 7.5.11), if the card subscribed to Channel status.
|
||||
Called when the proactive session is idle, never between FETCH and TR."""
|
||||
global _FLUSHING_CHANNEL_EVENTS
|
||||
if _FLUSHING_CHANNEL_EVENTS:
|
||||
return
|
||||
if not _SCP81_LINK_EVENTS:
|
||||
_BIP.take_pending_events()
|
||||
return
|
||||
ev_list = getattr(_server_ref, 'event_list', None) or []
|
||||
if 0x0A not in ev_list:
|
||||
return
|
||||
events = _BIP.take_pending_events()
|
||||
if not events:
|
||||
return
|
||||
_FLUSHING_CHANNEL_EVENTS = True
|
||||
try:
|
||||
for ev in events:
|
||||
_send_event_download(scc, 0x0A, bytes([
|
||||
httpota.TAG_CHANNEL_STATUS | 0x80, 0x02, ev['status'], ev['info']]))
|
||||
except Exception as e:
|
||||
sys.stderr.write('Channel status event error: %s\n' % e)
|
||||
finally:
|
||||
_FLUSHING_CHANNEL_EVENTS = False
|
||||
|
||||
|
||||
def _skip_ber_len(raw, off):
|
||||
if off >= len(raw):
|
||||
return off
|
||||
@@ -1512,6 +1954,121 @@ def _skip_ber_len(raw, off):
|
||||
return off + 3
|
||||
|
||||
|
||||
# ---- TIMER MANAGEMENT (TS 102 223 6.6.21, 6.8.13/14, 7.4) -----------------
|
||||
# The terminal keeps up to 8 timers per card session. On expiry it must send
|
||||
# ENVELOPE (TIMER EXPIRATION, tag D7) so the card can act (a common OTA retry
|
||||
# mechanism); a reset or card removal deactivates all timers.
|
||||
|
||||
_TIMERS = {}
|
||||
_TIMER_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _timer_cancel(timer_id=None):
|
||||
"""Cancel one timer, or all of them (reset / card removal)."""
|
||||
with _TIMER_LOCK:
|
||||
ids = list(_TIMERS) if timer_id is None else [timer_id]
|
||||
for tid in ids:
|
||||
entry = _TIMERS.pop(tid, None)
|
||||
if entry:
|
||||
entry['timer'].cancel()
|
||||
|
||||
|
||||
def _timer_remaining(timer_id):
|
||||
with _TIMER_LOCK:
|
||||
entry = _TIMERS.get(timer_id)
|
||||
if not entry:
|
||||
return None
|
||||
return max(0, int(round(entry['deadline'] - time.time())))
|
||||
|
||||
|
||||
def _timer_start(timer_id, seconds):
|
||||
"""Start (or restart) a timer; returns False for an invalid identifier."""
|
||||
if not 1 <= timer_id <= 8:
|
||||
return False
|
||||
_timer_cancel(timer_id)
|
||||
timer = threading.Timer(seconds, _timer_fire, args=(timer_id, seconds))
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
with _TIMER_LOCK:
|
||||
_TIMERS[timer_id] = {'timer': timer, 'deadline': time.time() + seconds}
|
||||
return True
|
||||
|
||||
|
||||
def _timer_fire(timer_id, elapsed):
|
||||
"""Timer callback: the timer is consumed on expiry (7.4.1); a timer that
|
||||
was cancelled or restarted in the meantime must not report."""
|
||||
with _TIMER_LOCK:
|
||||
entry = _TIMERS.pop(timer_id, None)
|
||||
if entry is None:
|
||||
return
|
||||
_timer_expired(timer_id, elapsed)
|
||||
|
||||
|
||||
def _timer_expired(timer_id, elapsed):
|
||||
"""Pass an expired timer to the UICC with ENVELOPE (TIMER EXPIRATION)."""
|
||||
server = _server_ref
|
||||
if not _CARD_CONNECTED or not server:
|
||||
return
|
||||
# Never inject the ENVELOPE while a fetched command awaits its TERMINAL
|
||||
# RESPONSE (a paused STK menu); wait outside the card lock, then retry.
|
||||
for _ in range(10):
|
||||
if not getattr(server, 'stk_pending', None):
|
||||
break
|
||||
time.sleep(2)
|
||||
with _CARD_LOCK:
|
||||
if server is not _server_ref or not getattr(server, 'scc', None):
|
||||
return
|
||||
if getattr(server, 'stk_pending', None):
|
||||
_timer_start(timer_id, 5)
|
||||
return
|
||||
scc = server.scc
|
||||
inner = bytes([0x82, 0x02, 0x82, 0x81, 0xA4, 0x01, timer_id & 0xFF,
|
||||
0xA5, 0x03]) + _hms_bcd(elapsed)
|
||||
tlv = bytes([0xD7, len(inner)]) + inner
|
||||
apdu = '%sc20000%02x%s' % (scc.cat_cla, len(tlv), tlv.hex())
|
||||
for _ in range(3):
|
||||
try:
|
||||
data, sw = scc._tp.send_apdu(apdu)
|
||||
except Exception as e:
|
||||
sys.stderr.write('TIMER-EXPIRATION send error: %s\n' % e)
|
||||
_handle_card_disconnect()
|
||||
return
|
||||
sys.stderr.write('ENVELOPE(Timer Expiration): timer=%d elapsed=%ds -> %s\n'
|
||||
% (timer_id, elapsed, sw))
|
||||
if sw == '9300':
|
||||
# UICC busy: the terminal shall retry until accepted (7.4.1).
|
||||
time.sleep(1)
|
||||
continue
|
||||
if sw.startswith('91'):
|
||||
_handle_proactive_chain(scc, sw)
|
||||
break
|
||||
|
||||
|
||||
def _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst):
|
||||
"""Terminal side of TIMER MANAGEMENT. Returns the TERMINAL RESPONSE payload."""
|
||||
tlvs = httpota.proactive_tlvs(raw)
|
||||
tid = _cmd_tlv(tlvs, 0x24)
|
||||
timer_id = tid[0] if tid else 1
|
||||
action = (cmd_qual or 0) & 0x03
|
||||
base = bytes([0x81, 0x03, cmd_num, cmd_type, (cmd_qual or 0) & 0xFF,
|
||||
0x82, 0x02, dev_dst, dev_src])
|
||||
if action == 0x00:
|
||||
value = _cmd_tlv(tlvs, 0x25)
|
||||
if len(value) >= 3 and 1 <= timer_id <= 8:
|
||||
secs = (_bcd_swap(value[0]) * 3600 + _bcd_swap(value[1]) * 60
|
||||
+ _bcd_swap(value[2]))
|
||||
if secs > 0:
|
||||
_timer_start(timer_id, secs)
|
||||
return base + bytes([0x03, 0x01, 0x00])
|
||||
remaining = _timer_remaining(timer_id)
|
||||
if remaining is None:
|
||||
return base + bytes([0x03, 0x01, 0x24])
|
||||
if action == 0x01:
|
||||
_timer_cancel(timer_id)
|
||||
return (base + bytes([0xA4, 0x01, timer_id & 0xFF, 0xA5, 0x03])
|
||||
+ _hms_bcd(remaining) + bytes([0x03, 0x01, 0x00]))
|
||||
|
||||
|
||||
def _decode_stk_text(raw):
|
||||
try:
|
||||
return _STK_DECODE._decode(raw, {}, 'stk')
|
||||
@@ -1543,9 +2100,12 @@ def _parse_proactive_header(raw):
|
||||
while off < len(raw) - 1:
|
||||
tag, tlen = raw[off], raw[off + 1]
|
||||
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
|
||||
if tag == 0x81 and tlen >= 3:
|
||||
# Cards use both the plain (01/02) and comprehension-required
|
||||
# (81/82) tag variants - TS 101 220 7.1.1 leaves the CR flag to
|
||||
# the application, and the reference cards switch between them.
|
||||
if tag in (0x01, 0x81) and tlen >= 3:
|
||||
cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2]
|
||||
elif tag == 0x82 and tlen >= 2:
|
||||
elif tag in (0x02, 0x82) and tlen >= 2:
|
||||
dev_src, dev_dst = val[0], val[1]
|
||||
return cmd_num, cmd_type, dev_src, dev_dst, cmd_qual
|
||||
|
||||
@@ -1682,8 +2242,20 @@ def _parse_setup_menu_items(raw):
|
||||
|
||||
|
||||
def _handle_proactive_chain(scc, sw91, on_fetch=None):
|
||||
"""Run a FETCH/TERMINAL RESPONSE chain; marks the card as busy so that
|
||||
terminal-initiated ENVELOPEs (Data available, Channel status, timers) wait."""
|
||||
global _PROACTIVE_BUSY
|
||||
_PROACTIVE_BUSY = True
|
||||
try:
|
||||
return _run_proactive_chain(scc, sw91, on_fetch)
|
||||
finally:
|
||||
_PROACTIVE_BUSY = False
|
||||
|
||||
|
||||
def _run_proactive_chain(scc, sw91, on_fetch=None):
|
||||
sys.stderr.write('91XX chain: sw=%s\n' % sw91)
|
||||
sw = sw91
|
||||
paused = False
|
||||
while sw.startswith('91'):
|
||||
fetch_len = int(sw[2:], 16) if len(sw) == 4 else 0x100
|
||||
rv = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla, fetch_len))
|
||||
@@ -1699,10 +2271,13 @@ def _handle_proactive_chain(scc, sw91, on_fetch=None):
|
||||
entry = None
|
||||
if on_fetch:
|
||||
action = on_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst)
|
||||
if action != 'pause':
|
||||
paused = action == 'pause'
|
||||
if not paused:
|
||||
tr_tlv = None
|
||||
if raw and cmd_type in (0x40, 0x41, 0x42, 0x43, 0x44):
|
||||
tr_tlv = _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
|
||||
if cmd_type == 0x27:
|
||||
tr_tlv = _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
|
||||
if tr_tlv is None:
|
||||
tr_tlv = _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual)
|
||||
tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr_tlv), tr_tlv.hex()))
|
||||
@@ -1716,7 +2291,12 @@ def _handle_proactive_chain(scc, sw91, on_fetch=None):
|
||||
if st_sw.startswith('91'):
|
||||
sw = st_sw
|
||||
if action == 'exit':
|
||||
_bip_flush_channel_events(scc)
|
||||
return sw
|
||||
if not paused:
|
||||
# Never inject an ENVELOPE while a fetched command awaits its
|
||||
# TERMINAL RESPONSE (the menu browser answers it later).
|
||||
_bip_flush_channel_events(scc)
|
||||
|
||||
|
||||
def _send_terminal_profile(scc, tp_hex):
|
||||
@@ -2080,6 +2660,13 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
resp = {'seq': _BIP.seq, 'entries': _BIP.entries_after(after)[-200:]}
|
||||
self._send_json(resp)
|
||||
self._log_resp(resp)
|
||||
elif self.path == '/api/scp81/script':
|
||||
self._log_req()
|
||||
resp = {'script': _SCP81_SCRIPT, 'sent': _SCP81_SCRIPT_SENT,
|
||||
'template': _SCP81_SCRIPT_TEMPLATE, 'cr_tag': _SCP81_SCRIPT_CR_TAG,
|
||||
'results': _SCP81_SCRIPT_RESULTS}
|
||||
self._send_json(resp)
|
||||
self._log_resp(resp)
|
||||
elif self.path.startswith('/api/'):
|
||||
self._send_json({'error': _err('not_found', lang)}, 404)
|
||||
self._log_resp({'error': _err('not_found', lang)})
|
||||
@@ -2861,7 +3448,8 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
self._log_resp(err)
|
||||
elif self.path == '/api/scp81/bip':
|
||||
body = self._read_body()
|
||||
self._log_req(body)
|
||||
# Never log the pre-shared key.
|
||||
self._log_req(dict(body, psk_hex='<redacted>') if isinstance(body, dict) and body.get('psk_hex') else body)
|
||||
try:
|
||||
resp = _scp81_bip_control(body)
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user