Files
otaman/pysim_otaman_server/server.py
T
catarrh fe10603aea server+ui: auto-equip on card insertion; reset card views on session change
- _apply_equipped_card() centralizes the post-equip refresh + TERMINAL
  PROFILE (shared by the /api/command equip branch and auto-equip)
- server tracks card_session (bumped on equip and disconnect) and
  equipping; /api/status exposes connected, card_present, card_session,
  equipping, auto_equip and is exempt from _CARD_LOCK (pure cached state)
- auto-equip is on by default (--no-auto-equip; off with --no-card-init):
  the presence observer spawns a one-shot worker after insertion, which
  runs equip under _CARD_LOCK and applies the same refresh; the monitor
  starts after the startup init so pyscard's initial 'already present'
  event does not re-equip a fresh session
- UI: /api/status polls every 2s (other views stay at 5s); when
  card_session changes it runs pysimResetCardData() (STK overlay, file
  tree, events, proactive log, PLI, status) — the same reset as a manual
  Equip; messages: initializing / press Equip / no card

Tests for the observer and auto-equip rules, session bumps,
_apply_equipped_card, and the UI state machine. SW cache v91 -> v92.
2026-09-12 13:34:40 +03:00

2510 lines
105 KiB
Python

import json
import sys
import os
import time
import threading
import traceback
import re
import codecs
from datetime import datetime, timedelta
from http.server import HTTPServer, BaseHTTPRequestHandler
from io import StringIO
from pySim.transport import ApduTracer, ProactiveHandler
from pySim.cards import UiccCardBase
from smartcard.CardMonitoring import CardMonitor, CardObserver
import gsm0338 # registers 'gsm03.38' codec
from construct import GreedyBytes
from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
VERSION = '1.9.28'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
# Static file serving (the PWA lives in <repo>/frontend, served by this server
# so the UI and the API share an origin and no CORS/PNA is involved).
_STATIC_MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json',
'.map': 'application/json',
'.wasm': 'application/wasm',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
}
_T0 = time.time()
_TIMING = False
_APDU_N = 0
_RESET_N = 0
def _timing_on():
global _TIMING
_TIMING = True
def _tlog(msg):
if not _TIMING:
return
sys.stderr.write('TIMING [+%7.3fs] %s\n' % (time.time() - _T0, msg))
class StderrApduTracer(ApduTracer):
def __init__(self):
super().__init__()
self._cmd_start = 0
def trace_command(self, cmd):
self._cmd_start = time.time()
def trace_reset(self):
global _RESET_N
_RESET_N += 1
if _TIMING:
sys.stderr.write('TIMING [+%7.3fs] RESET #%d\n' % (time.time() - _T0, _RESET_N))
def trace_response(self, cmd, sw, resp):
global _APDU_N
_APDU_N += 1
elapsed = int((time.time() - self._cmd_start) * 1000)
if _TIMING:
msg = 'APDU-TRACE(+%7.3fs #%d, %dms): %s → SW: %s' % (time.time() - _T0, _APDU_N, elapsed, cmd, sw)
else:
msg = 'APDU-TRACE(%dms): %s → SW: %s' % (elapsed, cmd, sw)
if resp:
msg += ' RESP: %s' % resp
os.write(2, (msg + '\n').encode())
class _LoggingApduTracer(StderrApduTracer):
"""StderrApduTracer that additionally records the SW of TERMINAL RESPONSE
APDUs sent by pySim's auto-handler (which happen outside our own chain code)
onto the most recent log entry that is still missing its tr_sw."""
def trace_response(self, cmd, sw, resp):
super().trace_response(cmd, sw, resp)
if len(cmd) >= 4 and cmd[2:4] == '14':
for entry in reversed(_PROACTIVE_LOG):
if 'tr_hex' in entry and 'tr_sw' not in entry:
entry['tr_sw'] = sw
break
ERROR_MSGS = {
'en': {
'app_not_init': 'Server not initialized',
'no_card_state': 'No card state available',
'reader_not_init': 'Reader not initialized',
'not_found': 'Not found',
},
'ru': {
'app_not_init': 'Сервер не инициализирован',
'no_card_state': 'Состояние карты недоступно',
'reader_not_init': 'Считыватель не инициализирован',
'not_found': 'Не найдено',
},
}
def _get_lang(headers):
lang = headers.get('Accept-Language', 'en')
if lang not in ('en', 'ru'):
lang = 'en'
return lang
def _err(key, lang):
return ERROR_MSGS.get(lang, ERROR_MSGS['en']).get(key, key)
def _strip_ansi(text):
return re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
def _parse_help_text(text):
result = {'usage': '', 'description': '', 'args': []}
lines = text.split('\n')
in_usage = False
in_pos = False
in_opt = False
for line in lines:
stripped = line.strip()
if stripped.startswith('usage:'):
result['usage'] = stripped[6:].strip()
in_usage = True
in_pos = in_opt = False
elif stripped.startswith('positional arguments:'):
in_pos = True
in_opt = in_usage = False
elif stripped.startswith('options:') or stripped.startswith('optional arguments:'):
in_opt = True
in_pos = in_usage = False
elif in_pos and stripped and not stripped.startswith('usage:'):
m = re.match(r'^(\S+)\s+(.+)$', stripped)
if m:
result['args'].append({'name': m.group(1), 'type': 'positional', 'help': m.group(2)})
elif in_opt and stripped and not stripped.startswith('usage:'):
m = re.match(r'^(\S+(?:,\s*\S+)?)\s+(\S+\s+)?(.+)?$', stripped)
if m:
names = m.group(1)
first_name = names.split(',')[0].strip()
result['args'].append({'name': first_name, 'type': 'optional', 'help': (m.group(3) or '').strip()})
elif not in_pos and not in_opt and not in_usage:
if stripped:
result['description'] = (result['description'] + ' ' + stripped).strip()
return result
def _get_file_type(lchan, cur_file):
if cur_file and cur_file.name:
if cur_file.name.startswith('EF.'):
if lchan and lchan.selected_file_fcp:
ft = lchan.selected_file_type()
if ft != 'df':
return lchan.selected_file_structure()
return 'transparent'
if cur_file.name.startswith('DF.') or cur_file.name.startswith('ADF.') or cur_file.name == 'MF':
return 'df'
if lchan and lchan.selected_file_fcp:
return lchan.selected_file_structure()
return None
def _select_with_parent(lchan, name, parent_sel, app):
if parent_sel:
lchan.select(parent_sel, app)
fcp = lchan.select(name, app)
return fcp
def _select_path(lchan, path, app):
"""Select a file described by a full path.
Path is '/' separated; the first element is either 'MF' (or the MF fid
'3F00') or an ADF AID (hex). Remaining elements are FIDs or file names.
pySim's lchan.select() cannot select an ADF by its raw AID (selectables are
keyed by name/fid only), so ADF roots are resolved through rs.mf.applications.
"""
parts = [p for p in (path or '').split('/') if p]
if not parts:
raise RuntimeError('Empty path')
rs = app.rs
first = parts[0]
if first.upper() in ('MF', '3F00'):
lchan.select('MF', app)
else:
aid = first.lower()
adf = rs.mf.applications.get(aid)
if not adf:
adf = next((v for k, v in rs.mf.applications.items() if k.lower() == aid), None)
if not adf:
raise RuntimeError('ADF not found: %s' % first)
lchan.select_file(adf, app)
for seg in parts[1:]:
lchan.select(seg, app)
return lchan.selected_file
def _parse_tree_output(output):
lines = (output or '').split('\n')
children = []
for line in lines:
if line.startswith(' '):
continue
m = re.match(r'^(\S+)\s+([0-9a-fA-F]{4})?(?:\s|$)', line)
if m:
cname = m.group(1)
cfid = m.group(2).lower() if m.group(2) else None
children.append({
'name': cname,
'fid': cfid,
'isDir': cname.startswith('ADF.') or cname.startswith('DF.') or cname == 'MF',
})
return children
def _encode_sms_oa(number):
digits = [int(c) for c in number if c.isdigit()]
oa = bytes([len(digits), 0x81])
for i in range(0, len(digits), 2):
first = digits[i]
second = digits[i + 1] if i + 1 < len(digits) else 0xF
oa += bytes([(second << 4) | first])
return oa
def _bcd_pair(value):
return ((value % 10) << 4) | ((value // 10) % 10)
def _encode_scts(dt=None):
dt = dt or datetime.now().astimezone()
offset = dt.utcoffset() or timedelta()
quarters = int(offset.total_seconds() // 900)
tz = _bcd_pair(abs(quarters)) | (0x08 if quarters < 0 else 0x00)
return bytes([
_bcd_pair(dt.year % 100),
_bcd_pair(dt.month),
_bcd_pair(dt.day),
_bcd_pair(dt.hour),
_bcd_pair(dt.minute),
_bcd_pair(dt.second),
tz,
])
def _lv(hex_str):
"""Length-prefix a hex string (1-byte length)."""
n = len(hex_str) // 2
return '%02x%s' % (n, hex_str)
def _ber_len(n):
"""BER-TLV length encoding."""
if n < 0x80:
return '%02x' % n
elif n < 0x100:
return '81%02x' % n
else:
return '82%04x' % n
def _cap_parse(cap_hex):
"""Parse a .cap file (as hex string) and return (loadfile_aid, module_aid, loadfile_data) as hex strings.
The .cap file is a ZIP archive containing nested .cap component files.
We extract the Header (for package AID / Load File AID) and Applet component
(for applet AID / Module AID), then concatenate all components for the loadfile data.
"""
import zipfile, io, struct
cap_bytes = bytes.fromhex(cap_hex)
zf = zipfile.ZipFile(io.BytesIO(cap_bytes))
components = {}
for name in zf.namelist():
if name.lower().endswith('.cap') and not name.lower().endswith('.capx'):
key = name.split('/')[-1].removesuffix('.cap')
components[key] = zf.read(name)
zf.close()
if 'Header' not in components:
raise ValueError('.cap file missing Header component')
if 'Applet' not in components:
raise ValueError('.cap file missing Applet component')
# Header component: tag(1) size(2) magic(4) minor(1) major(1) flags(1) package(minor(1) major(1) aid(LV))
hdr = components['Header']
magic = struct.unpack('>I', hdr[3:7])[0]
if magic != 0xDECAFFED:
raise ValueError('Invalid .cap Header magic: 0x%08X (expected 0xDECAFFED)' % magic)
aid_len = hdr[12]
loadfile_aid = hdr[13:13 + aid_len].hex().upper()
# Applet component: tag(1) size(2) count(1) [aid_len(1) aid(N) install_offset(2)]*
app = components['Applet']
num_applets = app[3]
if num_applets < 1:
raise ValueError('.cap file has no applets')
off = 4
aid_len2 = app[off]
module_aid = app[off + 1:off + 1 + aid_len2].hex().upper()
# Concatenate all components for the loadfile (GP spec order)
order = ['Header', 'Directory', 'Import', 'Applet', 'Class', 'Method',
'StaticField', 'Export', 'ConstantPool', 'RefLocation', 'Descriptor']
loadfile_parts = []
for comp in order:
if comp in components:
loadfile_parts.append(components[comp])
loadfile_data = b''.join(loadfile_parts).hex().upper()
return loadfile_aid, module_aid, loadfile_data
def _build_sms_tpdu(chunk_hex, chunk_total=1, chunk_num=1, oa_number='12345', include_cpi=True):
chunk = bytes.fromhex(chunk_hex)
# TS 23.040 UDH: first octet is UDHL, then the information elements.
# TS 31.115 4.2/4.3: the OTA CPI is UDH IEIa='70' with IEIDLa='00'.
udh = b''
if chunk_total > 1:
udh = bytes([0x00, 0x03, 0x01, chunk_total, chunk_num])
if chunk_num == 1 and include_cpi:
udh += bytes([0x70, 0x00])
elif include_cpi:
udh = bytes([0x70, 0x00])
tp_ud = (bytes([len(udh)]) + udh + chunk) if udh else chunk
first_byte = 0x44 if udh and chunk_total > 1 else (0x40 if udh else 0x04)
tpdu = bytes([first_byte]) + _encode_sms_oa(oa_number) + bytes([0x7F, 0xF6]) + _encode_scts() + bytes([len(tp_ud)]) + tp_ud
return tpdu.hex()
def _send_envelope(tpdu_hex, scc, sm_sc='12345678912', submit_handler=None):
from pySim.ts_31_102 import SMSPPDownload
from pySim.cat import DeviceIdentities, Address
from osmocom.tlv import COMPR_TLV_IE
from pySim.utils import b2h
class RawTpdu(COMPR_TLV_IE, tag=0x8B):
comprehension = False
def __init__(self, data_hex):
super().__init__()
self._raw = bytes.fromhex(data_hex)
def to_bytes(self, context={}):
return self._raw
address = Address()
oa_raw = _encode_sms_oa(sm_sc)
address.from_bytes(oa_raw[1:])
dev_ids = DeviceIdentities(decoded={'source_dev_id': 'network', 'dest_dev_id': 'uicc'})
raw_tpdu = RawTpdu(tpdu_hex)
sms_dl = SMSPPDownload(children=[dev_ids, address, raw_tpdu])
env_hex = '%sc20000%02x%s' % (scc.cat_cla, len(sms_dl.to_tlv()), b2h(sms_dl.to_tlv()))
data, sw = scc._tp.send_apdu(env_hex)
if sw.startswith('61'):
get_len = int(sw[2:], 16) if len(sw) == 4 else 0x100
data, sw = scc._tp.send_apdu('00c00000%02x' % get_len)
elif sw.startswith('91'):
def _capture_sms_tpdu(raw, cmd_num, cmd_type, dev_src, dev_dst):
if submit_handler:
tpdu_hex = _find_sms_tpdu(raw)
if tpdu_hex:
ref, total, num, payload = _parse_sms_concat(bytes.fromhex(tpdu_hex))
if total is not None and num is not None:
submit_handler.sms_segments.append((ref, total, num, payload.hex()))
matching = [s for s in submit_handler.sms_segments if s[0] == ref]
if len(matching) >= total:
sorted_segs = sorted(matching, key=lambda s: s[2])
assembled = b''.join(bytes.fromhex(s[3]) for s in sorted_segs)
submit_handler.submit_tpdu_hex = assembled.hex()
sys.stderr.write('SMS concat: assembled %d segments (ref=%s)\n' % (total, ref))
else:
submit_handler.submit_tpdu_hex = tpdu_hex
_handle_proactive_chain(scc, sw, _capture_sms_tpdu)
data, sw = '', '9000'
if sw == '9000' and submit_handler and not submit_handler.submit_tpdu_hex:
sys.stderr.write('STATUS poll (PoR not captured)\n')
st_data, st_sw = _send_status(scc)
sys.stderr.write('STATUS -> %s\n' % st_sw)
if st_sw.startswith('91'):
_handle_proactive_chain(scc, st_sw, _capture_sms_tpdu)
return data, sw
# TS 03.48 / TS 102 225 SPI coding
_RC_CC_DS = {0: 'no_rc_cc_ds', 1: 'rc', 2: 'cc', 3: 'ds'}
_CNTR_REQ = {0: 'no_counter', 1: 'counter_no_replay_or_seq', 2: 'counter_must_be_higher', 3: 'counter_must_be_lower'}
_POR_REQ = {0: 'no_por', 1: 'por_required', 2: 'por_only_when_error'}
_CRYPT_ALGO = {1: 'single_des', 5: 'triple_des_cbc2', 9: 'triple_des_cbc3', 2: 'aes_cbc'}
_AUTH_ALGO = {1: 'single_des', 5: 'triple_des_cbc2', 9: 'triple_des_cbc3', 2: 'aes_cmac'}
def _spi_from_bytes(spi1, spi2):
return {
'counter': _CNTR_REQ[(spi1 >> 3) & 0x03],
'ciphering': bool(spi1 & 0x04),
'rc_cc_ds': _RC_CC_DS[spi1 & 0x03],
'por_in_submit': bool(spi2 & 0x20),
'por_shall_be_ciphered': bool(spi2 & 0x10),
'por_rc_cc_ds': _RC_CC_DS[(spi2 >> 2) & 0x03],
'por': _POR_REQ[spi2 & 0x03],
}
def _ota_keyset(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex):
from pySim.ota import OtaKeyset
from osmocom.utils import h2b
kic_b = int(kic, 16)
kid_b = int(kid, 16)
algo_crypt = _CRYPT_ALGO.get(kic_b & 0x0F)
algo_auth = _AUTH_ALGO.get(kid_b & 0x0F)
if algo_crypt is None:
raise ValueError('Unsupported KIc algorithm nibble %02X' % (kic_b & 0x0F))
if algo_auth is None:
raise ValueError('Unsupported KID algorithm nibble %02X' % (kid_b & 0x0F))
return OtaKeyset(algo_crypt=algo_crypt, kic_idx=kic_b >> 4, kic=h2b(kic_key_hex),
algo_auth=algo_auth, kid_idx=kid_b >> 4, kid=h2b(kid_key_hex),
cntr=int(cntr_hex, 16) if cntr_hex else 0)
def _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex, kic_key_hex, kid_key_hex):
from pySim.ota import OtaDialectSms
from osmocom.utils import h2b, b2h
otak = _ota_keyset(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex)
spi = _spi_from_bytes(int(spi1, 16), int(spi2, 16))
out = OtaDialectSms().encode_cmd(otak, h2b(tar_hex), spi, h2b(apdu_hex))
if not spi['ciphering'] and spi['rc_cc_ds'] != 'no_rc_cc_ds':
# pySim drops the CPL octets from its unciphered output; re-add them
# (they are included in the RC/CC/DS calculation) per TS 31.115 4.2.
# CPL counts octets from the CHL octet to the last octet of the
# Secured Data (incl. padding); pySim's unciphered output is exactly
# that range, so the CPL value equals its length.
cpl = len(out)
out = cpl.to_bytes(2, 'big') + out
return b2h(out), spi
def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, response_hex):
from pySim.ota import OtaDialectSms, CompactRemoteResp
from osmocom.utils import h2b, b2h
if not response_hex:
return None
otak = _ota_keyset(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex)
spi = _spi_from_bytes(int(spi1, 16), int(spi2, 16))
try:
data = h2b(response_hex)
if not data or data[0] != 0x02:
return None
res, dec = OtaDialectSms().decode_resp(otak, spi, data)
except Exception:
# any malformed/garbage PoR (non-hex, bad UDL, truncated fields,
# bad CC) is not a POR; never let decoding crash the request handler.
return None
out = {
'response_status': str(res['response_status']),
'tar': res['tar'].hex().upper(),
'cntr': res['cntr'].hex().upper(),
'pcntr': res['pcntr'],
'rpl': res['rpl'],
'rhl': res['rhl'],
'cc_rc': res['cc_rc'].hex().upper(),
'raw': response_hex,
}
# Try ExpandedRemoteResponse first (TS 102 226 §5.2.2)
if res.response_status == 'por_ok' and len(res['secured_data']):
expanded_response_data = ''
try:
from construct import Struct, Int8ub, Bytes, GreedyBytes, Optional, Array, this
ExpandedRemoteResponse = Struct(
'response_count'/Int8ub,
'responses'/Array(this.response_count, Struct(
'command_number'/Int8ub,
'status_word'/Bytes(2),
'response_data'/GreedyBytes,
'error_details'/Optional(Struct(
'error_code'/Int8ub,
'error_info'/GreedyBytes
)),
'chaining_context'/Optional(Struct(
'script_id'/Bytes(4),
'is_first'/Int8ub,
'is_last'/Int8ub,
))
))
)
expanded = ExpandedRemoteResponse.parse(res['secured_data'])
out['response_type'] = 'expanded'
out['response_count'] = expanded.response_count
out['responses'] = []
for resp in expanded.responses:
response_data = {
'command_number': resp.command_number,
'status_word': resp.status_word.hex().upper(),
'response_data': b2h(resp.response_data).upper() if resp.response_data else '',
}
if resp.error_details:
response_data['error_code'] = resp.error_details.error_code
response_data['error_info'] = b2h(resp.error_details.error_info).upper()
if resp.chaining_context:
response_data['script_id'] = resp.chaining_context.script_id.hex().upper()
response_data['is_first'] = resp.chaining_context.is_first == 0x01
response_data['is_last'] = resp.chaining_context.is_last == 0x01
out['responses'].append(response_data)
if expanded.response_count > 0 and expanded.responses[0].response_data:
expanded_response_data = b2h(expanded.responses[0].response_data).upper()
except Exception:
pass
if dec is not None:
out['response_type'] = 'compact'
# Use compact parser's last_response_data; expanded parser gives wrong results for compact format
out['decoded'] = {
'number_of_commands': dec.number_of_commands,
'last_status_word': str(dec.last_status_word),
'last_response_data': str(dec.last_response_data),
}
else:
out['response_type'] = 'none'
elif dec is not None:
out['response_type'] = 'compact'
out['decoded'] = {
'number_of_commands': dec.number_of_commands,
'last_status_word': str(dec.last_status_word),
'last_response_data': str(dec.last_response_data),
}
else:
out['response_type'] = 'none'
return out
class PoRSubmitHandler(ProactiveHandler):
"""Captures the SMS-SUBMIT TPDU from a SendShortMessage proactive command
issued by the SIM in response to PoR-in-submit (SPI2 bit 0x20).
The 91XX path in _send_envelope scans the FETCH response directly for
the SMS_TPDU child (tag 0x8B) and populates submit_tpdu_hex.
Supports concatenated SMS: when UDH contains IEI 0x00 or 0x08, segments
are accumulated and reassembled once all parts arrive."""
def __init__(self):
super().__init__()
self.submit_tpdu_hex = None
self.sms_segments = [] # [(ref, total, num, payload_hex), ...]
class _DefaultProactiveHandler(ProactiveHandler):
"""Catch-all for any proactive command not explicitly handled. Logs the
fetch and its TERMINAL RESPONSE into _PROACTIVE_LOG and answers
PROVIDE LOCAL INFORMATION (0x26) with data from the PLI dictionary,
so the card does not re-request."""
def receive_fetch_raw(self, pcmd, parsed):
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = 1, 0, 0x83, 0x81, None
entry = None
try:
raw = bytes.fromhex(parsed) if parsed else None
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')
if cmd_type == 0x26 and cmd_qual is not None:
pli_hex = _PLI_DATA.get(cmd_qual, '')
if pli_hex:
try:
ti_list.insert(2, _RawBerTlv(pli_hex))
except Exception:
pass
if entry:
_record_tr(entry, b''.join(x.to_tlv() for x in ti_list))
return ti_list
class _RawBerTlv(BER_TLV_IE):
"""Emits pre-encoded TLV bytes verbatim (used to inject PLI data TLVs
into the TERMINAL RESPONSE built by pySim's auto-handler)."""
def __init__(self, data_hex):
super().__init__()
self._raw = bytes.fromhex(data_hex)
def to_bytes(self, context={}):
return self._raw
_STK_DECODE = GsmOrUcs2Adapter(GreedyBytes)
_PROACTIVE_LOG = []
_PROACTIVE_SESSION_START = None
_PROACTIVE_ENTRY_ID = 0
PROACTIVE_TYPE_NAMES = {
0x03: 'POLL INTERVAL', 0x05: 'SET UP EVENT LIST',
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',
0x15: 'LAUNCH BROWSER', 0x70: 'ACTIVATE',
}
PLI_QUALIFIER_NAMES = {
0x00: 'Location Information (MCC, MNC, LAC/TAC, Cell ID)',
0x01: 'IMEI',
0x02: 'Network Measurement results',
0x03: 'Date, time and time zone',
0x04: 'Language setting',
0x05: 'Timing Advance',
0x06: 'Access Technology (single)',
0x08: 'IMEISV',
0x09: 'Search Mode',
0x0A: 'Battery charge state',
0x0C: 'Current WSID',
0x0D: 'Broadcast Network information',
0x0E: 'Multiple Access Technologies',
0x0F: 'Location Info (multi-RAT)',
0x10: 'NMR (multi-RAT)',
0x11: 'CSG ID list + HNB name',
0x12: 'H(e)NB IP address',
0x13: 'H(e)NB surrounding macrocells',
0x14: 'Current WLAN identifier',
0x15: 'Slices information',
0x16: 'CAG information list',
0x17: 'Rejected slices information',
}
_PLI_DATA = {q: '' for q in PLI_QUALIFIER_NAMES}
_POLL_ENABLED = False
_POLL_INTERVAL = 30
_POLL_TIMER = None
_CARD_LOCK = threading.RLock()
_CARD_CONNECTED = False
def _set_poll_interval(seconds):
global _POLL_INTERVAL
_POLL_INTERVAL = max(0, min(255, int(seconds)))
def _reset_poll_timer():
global _POLL_TIMER
if _POLL_TIMER is not None:
_POLL_TIMER.cancel()
_POLL_TIMER = None
if _POLL_ENABLED and _POLL_INTERVAL > 0:
_POLL_TIMER = threading.Timer(_POLL_INTERVAL, _do_status_poll)
_POLL_TIMER.daemon = True
_POLL_TIMER.start()
def _do_status_poll():
global _POLL_TIMER
_POLL_TIMER = None
if not _POLL_ENABLED:
return
with _CARD_LOCK:
try:
scc = getattr(_server_ref, 'scc', None) if _server_ref else None
if not scc:
return
st_data, st_sw = _send_status(scc)
sys.stderr.write('AUTO-STATUS -> %s\n' % st_sw)
if st_sw.startswith('91'):
_handle_proactive_chain(scc, st_sw)
except Exception as e:
sys.stderr.write('AUTO-STATUS error: %s\n' % e)
_handle_card_disconnect()
_reset_poll_timer()
def _poll_enable():
global _POLL_ENABLED
if _POLL_INTERVAL <= 0:
_POLL_ENABLED = False
return
_POLL_ENABLED = True
_reset_poll_timer()
_MENU_TIMEOUT = 60
_MENU_TIMER = None
def _set_menu_timeout(seconds):
global _MENU_TIMEOUT
_MENU_TIMEOUT = max(0, min(3600, int(seconds)))
def _cancel_menu_timeout():
global _MENU_TIMER
if _MENU_TIMER is not None:
_MENU_TIMER.cancel()
_MENU_TIMER = None
def _arm_menu_timeout():
"""Watchdog: a paused proactive command must always get a TERMINAL RESPONSE,
even if the user never answers. Fires 0x12 ('timeout') via the same path as
an explicit user response."""
global _MENU_TIMER
_cancel_menu_timeout()
if _MENU_TIMEOUT <= 0:
return
_MENU_TIMER = threading.Timer(_MENU_TIMEOUT, _menu_timeout_fire)
_MENU_TIMER.daemon = True
_MENU_TIMER.start()
def _menu_timeout_fire():
global _MENU_TIMER
_MENU_TIMER = None
with _CARD_LOCK:
server = _server_ref
if not server or not getattr(server, 'stk_pending', None) or not getattr(server, 'scc', None):
return
pd = server.stk_pending
sys.stderr.write('MENU-TIMEOUT: auto TR timeout (cmd=%02x type=%02x)\n' % (pd['cmd_num'], pd['cmd_type']))
try:
_menu_send_response(server, 'timeout', None)
except Exception as e:
sys.stderr.write('MENU-TIMEOUT error: %s\n' % e)
def _poll_disable():
global _POLL_ENABLED, _POLL_TIMER
_POLL_ENABLED = False
if _POLL_TIMER is not None:
_POLL_TIMER.cancel()
_POLL_TIMER = None
_server_ref = None
def _tr_data_only(tr_tlv):
"""Strip boilerplate CTLVs (Command Details, Device IDs, Result) from TR,
returning only command-specific data TLVs or empty bytes."""
skip_tags = {0x81, 0x82, 0x03, 0x83}
data = bytearray()
off = 0
while off < len(tr_tlv) - 1:
tag, tlen = tr_tlv[off], tr_tlv[off + 1]
val = tr_tlv[off + 2: off + 2 + tlen]
off += 2 + tlen
if tag not in skip_tags:
data.extend(tr_tlv[off - 2 - tlen: off])
return bytes(data)
EVENT_NAMES = {
0x00: 'MT call', 0x01: 'Call connected', 0x02: 'Call disconnected',
0x03: 'Location status', 0x04: 'User activity', 0x05: 'Idle screen available',
0x06: 'Card reader status', 0x07: 'Language selection',
0x08: 'Browser termination', 0x09: 'Data available',
0x0A: 'Channel status', 0x0B: 'Access Technology Change',
0x0C: 'Display parameters changed', 0x0D: 'Local connection',
0x0E: 'Network Search Mode Change', 0x0F: 'Browsing status',
0x10: 'Frames Information Change', 0x11: 'I-WLAN Access Status',
0x12: 'Network Rejection', 0x13: 'HCI Connectivity',
0x14: 'Change of UICC Access', 0x15: 'CSG Cell Change',
0x16: 'Contactless state request', 0x17: 'Profile Container',
0x18: 'LTE D2D Discovery Monitoring', 0x19: 'LTE D2D Communication Monitoring',
0x1A: 'LTE D2D Announcement Response', 0x1B: 'LTE D2D Revocation',
0x1C: 'LTE D2D Application Port', 0x1D: 'LTE D2D Security Recovery',
0x1E: 'Off-net Emergency Call', 0x1F: 'ECall Over IMS',
0x20: 'EARFCN Update', 0x21: 'SCEF Channel Status',
}
ACCESSTECH_NAMES = {
0: 'GSM', 1: 'GSM Compact', 2: 'TIA/EIA-533', 3: 'UTRAN',
4: 'TETRA', 5: 'TIA/EIA-95-B', 6: 'CDMA2000 1x', 7: 'CDMA2000 HRPD',
8: 'E-UTRAN', 9: 'eHRPD', 10: 'NG-RAN', 11: 'Satellite NG-RAN',
12: 'Satellite E-UTRAN',
}
PLI_QUALIFIER_SHORT = {
0x00: 'Loc', 0x01: 'IMEI', 0x02: 'NMR', 0x03: 'Time', 0x04: 'Lang',
0x05: 'TA', 0x06: 'AccTech', 0x08: 'IMEISV', 0x09: 'Search', 0x0A: 'Batt',
0x0C: 'WSID', 0x0D: 'BCInfo', 0x0E: 'MultiAT', 0x0F: 'MultiLoc',
0x10: 'MultiNMR', 0x11: 'CSG', 0x12: 'HNB-IP', 0x13: 'HNB-Macro',
0x14: 'WLAN', 0x15: 'Slices', 0x16: 'CAG', 0x17: 'RejSlice',
}
def _dec_plmn(hex6):
"""Decode 3-byte MCC/MNC nibble-swapped PLMN, e.g. '25001' from 'F50010'."""
h = re.sub(r'\s', '', hex6)[:6]
if len(h) < 6:
return '250', '01'
b1, b2, b3 = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
mcc = '%d%d%d' % (b1 & 0xF, (b1 >> 4) & 0xF, b2 & 0xF)
mnc = '%d' % (b3 & 0xF)
if (b2 >> 4) != 0xF:
mnc += '%d' % ((b2 >> 4) & 0xF)
return mcc, mnc
def _dec_imei(hex8):
"""Decode 8-byte IMEI (nibble-swapped, 15 digits)."""
h = re.sub(r'\s', '', hex8)[:16]
if len(h) < 16:
return ''
s = ''
for i in range(0, 15, 2):
b = int(h[i:i + 2], 16)
s += '%d%d' % (b & 0xF, (b >> 4) & 0xF)
return s[:15]
def _decode_cmd(cmd_type, raw, qualifier):
"""Decode a fetched proactive command into [{label, value}] pairs."""
if not raw:
return []
if cmd_type == 0x03:
idx = raw.find(b'\x84\x02\x01')
if idx >= 0 and idx + 3 < len(raw):
return [{'label': 'Interval', 'value': '%d s' % raw[idx + 3]}]
return []
if cmd_type == 0x05:
for tag in (0x99, 0x19):
idx = raw.find(bytes([tag]))
if idx >= 0 and idx + 1 < len(raw):
tlen = raw[idx + 1]
names = [EVENT_NAMES.get(b, 'Event 0x%02X' % b) for b in raw[idx + 2: idx + 2 + tlen]]
return [{'label': 'Events', 'value': ', '.join(names)}]
return []
if cmd_type == 0x13:
idx = raw.find(b'\x8b')
if idx >= 0 and idx + 1 < len(raw):
tlen = raw[idx + 1]
return [{'label': 'SMS TPDU', 'value': raw[idx + 2: idx + 2 + tlen].hex()}]
return []
if cmd_type == 0x21:
text = _parse_display_text(raw)
return [{'label': 'Text', 'value': text}] if text else []
if cmd_type == 0x24:
items = _parse_select_item(raw)
if items:
return [{'label': 'Items', 'value': ', '.join('%s. %s' % (it['id'], it['text']) for it in items)}]
return []
if cmd_type == 0x25:
items = _parse_setup_menu_items(raw)
if items:
return [{'label': 'Items', 'value': ', '.join('%s. %s' % (it['id'], it['text']) for it in items)}]
return []
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)}]
return [{'label': 'Data', 'value': raw.hex()}]
def _decode_tr(type_hex, qual_hex, tr_hex):
"""Decode the command-specific payload of a TERMINAL RESPONSE into
[{label, value}] pairs (empty list when the TR carried no data)."""
if not tr_hex:
return []
h = re.sub(r'\s', '', tr_hex)
try:
cmd_type = int(type_hex, 16) if type_hex else None
except ValueError:
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 == 0x26 and qual_hex:
try:
qual = int(qual_hex, 16)
except ValueError:
qual = None
if qual in (0x00, 0x01, 0x03, 0x04, 0x05, 0x06, 0x08, 0x09, 0x0A, 0x0E):
v = h[4:] if len(h) >= 4 else ''
if qual == 0x00 and len(v) >= 10:
mcc, mnc = _dec_plmn(v[:6])
return [{'label': 'MCC', 'value': mcc}, {'label': 'MNC', 'value': mnc},
{'label': 'LAC/TAC', 'value': v[6:10].upper()}]
if qual == 0x01 and len(v) >= 16:
return [{'label': 'IMEI', 'value': _dec_imei(v[:16])}]
if qual == 0x03 and len(v) >= 14:
yr = 2000 + int(v[0:2]) if int(v[0:2]) < 70 else 1900 + int(v[0:2])
mo, dy = int(v[2:4]), int(v[4:6])
hh, mm, ss = int(v[6:8]), int(v[8:10]), int(v[10:12])
tz = int(v[12:14], 16)
tz_sign = '-' if tz & 0x80 else '+'
tz_q = (tz & 0x3F) or 0
return [{'label': 'Date', 'value': '%04d-%02d-%02d' % (yr, mo, dy)},
{'label': 'Time', 'value': '%02d:%02d:%02d' % (hh, mm, ss)},
{'label': 'TZ offset', 'value': '%s%02d:%02d' % (tz_sign, tz_q // 4, (tz_q % 4) * 15)}]
if qual == 0x04 and len(v) >= 4:
try:
lang = bytes.fromhex(v[:4]).decode('ascii')
except Exception:
lang = v[:4]
return [{'label': 'Language', 'value': lang}]
if qual == 0x05 and len(v) >= 4:
return [{'label': 'ME Status', 'value': str(int(v[0:2], 16))},
{'label': 'Timing Advance', 'value': str(int(v[2:4], 16))}]
if qual == 0x06 and len(v) >= 2:
tech = int(v[0:2], 16)
return [{'label': 'Access Technology',
'value': '%s (%d)' % (ACCESSTECH_NAMES.get(tech, 'Unknown'), tech)}]
if qual == 0x08 and len(v) >= 16:
return [{'label': 'IMEISV', 'value': _dec_imei(v[:16]) + v[14:16].upper()}]
if qual == 0x09 and len(v) >= 2:
mode = int(v[0:2], 16)
return [{'label': 'Search Mode', 'value': 'Manual' if mode == 1 else ('Automatic' if mode == 0 else str(mode))}]
if qual == 0x0A and len(v) >= 2:
return [{'label': 'Charge state (%)', 'value': str(int(v[0:2], 16))}]
if qual == 0x0E:
techs = [int(v[i:i + 2], 16) for i in range(0, len(v), 2)]
return [{'label': 'Access Technologies',
'value': ', '.join(ACCESSTECH_NAMES.get(t, 'Unknown') for t in techs)}]
return [{'label': 'Data', 'value': h}]
return [{'label': 'Data', 'value': h}]
def _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual):
"""Build the TERMINAL RESPONSE TLV payload for a fetched command
(includes PLI dictionary data for PROVIDE LOCAL INFORMATION)."""
if cmd_type == 0x03:
return bytes([0x81, 0x03, cmd_num, cmd_type, 0x00,
0x82, 0x02, dev_dst, dev_src,
0x84, 0x02, 0x01, _POLL_INTERVAL,
0x03, 0x01, 0x00])
base = bytes([0x81, 0x03, cmd_num, cmd_type, 0x00,
0x82, 0x02, dev_dst, dev_src])
if cmd_type == 0x26 and cmd_qual is not None:
pli_hex = _PLI_DATA.get(cmd_qual, '')
if pli_hex:
base += bytes.fromhex(pli_hex)
return base + bytes([0x03, 0x01, 0x00])
_RESULT_NAMES_BASIC = {
0x00: 'Command performed successfully',
0x01: 'Command performed with partial comprehension',
0x02: 'Command performed, with missing information',
0x03: 'REFUSED BY THE ME',
0x04: 'Command not understood by the ME',
0x05: 'Command not permitted by the user',
0x06: 'Command performed with modification',
0x20: 'Proactive SIM session terminated by the user',
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',
}
_RESULT_NAMES_GENERAL = {
0x10: 'Command performed with additional information',
0x20: 'ME currently unable to process command',
0x21: 'Network currently unable to process command',
0x22: 'User did not accept the proactive command',
0x23: 'User cleared down call before connection or network release',
0x24: 'Action in contradiction with the current enforcement state',
0x25: 'Action in contradiction with the current timer state',
0x26: 'ME currently unable to process command',
0x27: 'User did not accept the proactive command',
0x28: 'User cleared down call before connection or network release',
0x29: 'Action in contradiction with the current enforcement state',
0x2A: 'Action in contradiction with the current timer state',
0x30: 'Command performed but partial understanding',
0x31: 'Command performed, with missing information',
0x32: 'REFUSED BY THE ME',
0x33: 'Command not understood by the ME',
0x34: 'Command not permitted by the user',
0x35: 'Command performed with modification',
}
def _tr_result_name(b, is_general):
table = _RESULT_NAMES_GENERAL if is_general else _RESULT_NAMES_BASIC
if b in table:
return table[b]
if 0x40 <= b <= 0x4F or 0x70 <= b <= 0x7F:
return 'Command performed with modification'
if 0x60 <= b <= 0x6F:
return 'Command performed with limited understanding'
return None
def _extract_tr_result(tr_tlv):
"""Return (tag, value) of the Result CTLV (tag 0x03 basic or 0x83 general)
from a TERMINAL RESPONSE payload, or None if absent."""
if not tr_tlv:
return None
off = 0
while off < len(tr_tlv) - 1:
tag, tlen = tr_tlv[off], tr_tlv[off + 1]
if tag in (0x03, 0x83) and off + 2 + tlen <= len(tr_tlv):
return (tag, tr_tlv[off + 2: off + 2 + tlen])
off += 2 + tlen
return None
def _record_tr(entry, tr_tlv, tr_sw=None):
"""Attach a sent TERMINAL RESPONSE to a log entry: payload hex, SW and
server-side decode. tr_sw may be omitted (pySim auto-handler) and filled
later by _LoggingApduTracer."""
if entry is None:
return
entry['tr_hex'] = _tr_data_only(tr_tlv).hex()
if tr_sw is not None:
entry['tr_sw'] = tr_sw
try:
result = _extract_tr_result(tr_tlv)
if result is not None:
tag, val = result
entry['tr_result'] = val.hex()
name = _tr_result_name(val[0], tag == 0x83)
if name:
entry['tr_result_name'] = name
entry['tr_decoded'] = _decode_tr(entry.get('type_hex'), entry.get('qualifier'), entry['tr_hex'])
except Exception:
entry['tr_decoded'] = []
def _handle_card_disconnect():
global _CARD_CONNECTED
_poll_disable()
_cancel_menu_timeout()
_CARD_CONNECTED = False
if _server_ref:
_server_ref.card = None
_server_ref.scc = None
_server_ref.stk_pending = None
_server_ref.menu_active = False
_server_ref.event_list = None
_server_ref.sim_menu = None
_server_ref.equipping = False
_server_ref.card_session = getattr(_server_ref, 'card_session', 0) + 1
_reset_proactive_log()
def _apply_equipped_card(server):
"""Common post-equip state refresh + TERMINAL PROFILE, shared by the
/api/command equip branch and the auto-equip worker."""
global _CARD_CONNECTED
server.stk_pending = None
server.menu_active = False
_cancel_menu_timeout()
server.event_list = None
_reset_proactive_log()
server.card = server.app.card
server.scc = server.app.card._scc
server.scc.cat_cla = '80' if isinstance(server.card, UiccCardBase) else 'a0'
_CARD_CONNECTED = True
server.card_present = True
server.card_session = getattr(server, 'card_session', 0) + 1
_poll_enable()
sm, el = _send_terminal_profile(server.scc, server.terminal_profile)
server.sim_menu = sm
server.event_list = el
_tlog('equip: terminal profile done')
_AUTO_EQUIP = True
_AUTO_EQUIP_BUSY = False
def set_auto_equip(enabled):
global _AUTO_EQUIP
_AUTO_EQUIP = bool(enabled)
def _auto_equip_trigger():
"""Spawn a one-shot worker; never run equip in the pyscard monitor thread."""
global _AUTO_EQUIP_BUSY
if not _AUTO_EQUIP or _AUTO_EQUIP_BUSY:
return
_AUTO_EQUIP_BUSY = True
threading.Thread(target=_auto_equip_worker, name='auto-equip', daemon=True).start()
def _auto_equip_worker():
global _AUTO_EQUIP_BUSY
try:
with _CARD_LOCK:
server = _server_ref
if not server or _CARD_CONNECTED or not getattr(server, 'card_present', False):
return
app = server.app
if app is None or not getattr(server, 'terminal_profile', None):
return
server.equipping = True
try:
sys.stderr.write('AUTO-EQUIP: card inserted, initializing\n')
old_stdout, old_stderr = app.stdout, sys.stderr
app.stdout = StringIO()
sys.stderr = app.stdout
try:
app.onecmd_plus_hooks('equip')
finally:
app.stdout = old_stdout
sys.stderr = old_stderr
if not getattr(server, 'card_present', False) or server.app.card is None:
sys.stderr.write('AUTO-EQUIP: card gone during initialization\n')
return
_apply_equipped_card(server)
sys.stderr.write('AUTO-EQUIP: done\n')
except Exception as e:
sys.stderr.write('AUTO-EQUIP failed: %s\n' % e)
finally:
server.equipping = False
finally:
_AUTO_EQUIP_BUSY = False
class _CardPresenceObserver(CardObserver):
"""Passive PC/SC presence watcher: pyscard's CardMonitor only polls
SCardGetStatusChange (no connection, no APDUs), so it can never interleave
with our APDU traffic. We only update flags and tear down card state."""
def __init__(self, reader_name):
self.reader_name = reader_name
def update(self, observable, handlers):
addedcards, removedcards = handlers
try:
trigger_auto = False
for card in removedcards:
if str(getattr(card, 'reader', '')) == self.reader_name:
sys.stderr.write('CARD-WATCH: card removed from %s\n' % self.reader_name)
with _CARD_LOCK:
if _server_ref:
_server_ref.card_present = False
_handle_card_disconnect()
for card in addedcards:
if str(getattr(card, 'reader', '')) == self.reader_name:
sys.stderr.write('CARD-WATCH: card inserted into %s\n' % self.reader_name)
with _CARD_LOCK:
if _server_ref:
_server_ref.card_present = True
trigger_auto = True
if trigger_auto and _AUTO_EQUIP:
_auto_equip_trigger()
except Exception as e:
sys.stderr.write('CARD-WATCH error: %s\n' % e)
_card_presence_observer = None
def start_card_monitor(reader_name):
"""Start the process-wide pyscard monitor (one daemon thread, no process)
and register our reader's presence observer."""
global _card_presence_observer
if not reader_name:
return None
if _card_presence_observer is None:
_card_presence_observer = _CardPresenceObserver(reader_name)
CardMonitor().addObserver(_card_presence_observer)
return _card_presence_observer
def _init_proactive_session():
global _PROACTIVE_SESSION_START
_PROACTIVE_SESSION_START = time.time()
def _log_proactive(cmd_type, raw, qualifier=None, cmd_num=None):
global _PROACTIVE_ENTRY_ID
if _PROACTIVE_SESSION_START is None:
return
_PROACTIVE_ENTRY_ID += 1
entry = {
'id': _PROACTIVE_ENTRY_ID,
'type_hex': '%02x' % cmd_type,
'type_name': PROACTIVE_TYPE_NAMES.get(cmd_type, 'UNKNOWN'),
'elapsed': round(time.time() - _PROACTIVE_SESSION_START, 1),
'bytes': len(raw) if raw else 0,
'raw': raw.hex() if raw else None,
}
if cmd_num is not None:
entry['cmd_num'] = cmd_num
if qualifier is not None:
entry['qualifier'] = '%02x' % qualifier
try:
entry['cmd_decoded'] = _decode_cmd(cmd_type, raw, qualifier)
except Exception:
entry['cmd_decoded'] = []
_PROACTIVE_LOG.append(entry)
return entry
def _reset_proactive_log():
global _PROACTIVE_LOG, _PROACTIVE_SESSION_START
_PROACTIVE_LOG.clear()
_PROACTIVE_SESSION_START = time.time()
def _send_status(scc):
"""STATUS (F2) with correct P3 per card type: SIM=0x23, UICC=0x00.
P2=0C mirrors phone behavior - no FCP of the selected DF returned
(plain F2 00 00 would echo the FCP, which is unnecessary overhead)."""
p3 = '23' if scc.cat_cla == 'a0' else '00'
return scc._tp.send_apdu('%sf2000c%s' % (scc.cat_cla, p3))
def _send_event_download(scc, event_type, event_data=None):
"""Send ENVELOPE(Event Download) for the given event type.
Builds: CLA C2 0000 Lc D6 [len] (99 01 [type] 82 02 82 81 [extra])"""
inner = bytearray()
inner.extend([0x99, 0x01, event_type])
inner.extend([0x82, 0x02, 0x82, 0x81])
if event_data:
inner.extend(event_data)
d6_tlv = bytes([0xD6, len(inner)]) + bytes(inner)
env_hex = '%sc20000%02x%s' % (scc.cat_cla, len(d6_tlv), d6_tlv.hex())
sys.stderr.write('ENVELOPE(Event Download): type=0x%02x data=%s\n' % (event_type, event_data.hex() if event_data else '(none)'))
data, sw = scc._tp.send_apdu(env_hex)
sys.stderr.write('ENVELOPE SW: %s\n' % sw)
if sw.startswith('91'):
_handle_proactive_chain(scc, sw)
sw = '9000'
return data, sw
def _skip_ber_len(raw, off):
if off >= len(raw):
return off
if raw[off] < 0x80:
return off + 1
if raw[off] == 0x81:
return off + 2
return off + 3
def _decode_stk_text(raw):
try:
return _STK_DECODE._decode(raw, {}, 'stk')
except Exception:
return raw.hex()
def _decode_dcs_text(raw):
if not raw or len(raw) < 2:
return raw.hex() if raw else ''
try:
dcs = raw[0]
data = raw[1:]
if (dcs & 0x0C) == 0x08:
return codecs.decode(data, 'utf_16_be')
if (dcs & 0x0C) == 0x04:
return data.decode('latin-1', errors='replace')
return codecs.decode(data, 'gsm03.38')
except Exception:
return raw.hex()
def _parse_proactive_header(raw):
cmd_num, cmd_type = 1, 0
cmd_qual = None
dev_src, dev_dst = 0x83, 0x81
if raw[0] == 0xD0:
off = _skip_ber_len(raw, 1)
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:
cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2]
elif tag == 0x82 and tlen >= 2:
dev_src, dev_dst = val[0], val[1]
return cmd_num, cmd_type, dev_src, dev_dst, cmd_qual
def _find_sms_tpdu(raw):
if raw[0] == 0xD0:
off = _skip_ber_len(raw, 1)
while off < len(raw) - 1:
tag, tlen = raw[off], raw[off + 1]
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
if tag == 0x8B and tlen >= 1:
return val.hex()
return None
def _calc_ud_offset(tpdu):
"""Calculate the offset of TP-UD (User Data) within an SMS TPDU.
Handles SMS-SUBMIT (MTI=01) and SMS-DELIVER (MTI=00)."""
first_octet = tpdu[0]
mti = first_octet & 0x03
vpf = (first_octet >> 3) & 0x03
if mti == 0x01:
# SMS-SUBMIT: 1 + 1(MR) + 1(DA_len) + 1(DA_type) + ceil(DA_len/2) + 1(PID) + 1(DCS) [+7 if VPF]
if len(tpdu) < 3:
return None
da_len_digits = tpdu[2]
da_data_bytes = (da_len_digits + 1) // 2
off = 1 + 1 + 1 + 1 + da_data_bytes + 1 + 1
if vpf in (0x01, 0x02): # relative or absolute
off += 7
if off >= len(tpdu):
return None
return off + 1 # skip UDL byte
elif mti == 0x00:
# SMS-DELIVER: 1 + 1(OA_len) + ceil(OA_len/2) + 1(PID) + 1(DCS) + 7(SCTS)
if len(tpdu) < 2:
return None
oa_len_digits = tpdu[1]
oa_data_bytes = (oa_len_digits + 1) // 2
off = 1 + 1 + oa_data_bytes + 1 + 1 + 7
if off >= len(tpdu):
return None
return off + 1 # skip UDL byte
return None
def _parse_sms_concat(tpdu_bytes):
"""Parse an SMS TPDU for UDH concatenation info and payload.
Returns (ref, total, num, payload_bytes) or (None, None, None, payload_bytes) if no concat."""
if not tpdu_bytes or len(tpdu_bytes) < 2:
return None, None, None, tpdu_bytes or b''
first_octet = tpdu_bytes[0]
udhi = bool(first_octet & 0x40)
ud_offset = _calc_ud_offset(tpdu_bytes)
if ud_offset is None or ud_offset >= len(tpdu_bytes):
return None, None, None, tpdu_bytes
if not udhi:
# No UDH — entire UD is the payload
return None, None, None, tpdu_bytes[ud_offset:]
# TP-UDHI is set: UD starts with UDHL
udhl = tpdu_bytes[ud_offset]
udh_start = ud_offset + 1
udh_end = udh_start + udhl
if udh_end > len(tpdu_bytes):
return None, None, None, tpdu_bytes[ud_offset:]
# Walk UDH IEs looking for concatenation
ref = None
total = None
num = None
ie_off = udh_start
while ie_off + 2 <= udh_end:
iei = tpdu_bytes[ie_off]
iedl = tpdu_bytes[ie_off + 1]
if ie_off + 2 + iedl > udh_end:
break
if iei == 0x00 and iedl == 3:
ref = tpdu_bytes[ie_off + 2]
total = tpdu_bytes[ie_off + 3]
num = tpdu_bytes[ie_off + 4]
elif iei == 0x08 and iedl == 4:
ref = (tpdu_bytes[ie_off + 2] << 8) | tpdu_bytes[ie_off + 3]
total = tpdu_bytes[ie_off + 4]
num = tpdu_bytes[ie_off + 5]
ie_off += 2 + iedl
payload = tpdu_bytes[ud_offset + 1 + udhl:] # payload after UDH
return ref, total, num, payload
def _parse_display_text(raw):
if raw[0] == 0xD0:
off = _skip_ber_len(raw, 1)
while off < len(raw) - 1:
tag, tlen = raw[off], raw[off + 1]
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
if tag == 0x8D and tlen >= 1:
return _decode_dcs_text(val)
return None
def _parse_select_item(raw):
items = []
if raw[0] == 0xD0:
off = _skip_ber_len(raw, 1)
while off < len(raw) - 1:
tag, tlen = raw[off], raw[off + 1]
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
if tag == 0x05 and tlen >= 1:
try:
_title = _decode_stk_text(val)
except Exception:
pass
elif tag in (0x8F, 0x0F) and tlen >= 2:
items.append({'id': val[0], 'text': _decode_stk_text(val[1:])})
return items
def _parse_setup_menu_items(raw):
items = []
if not raw or raw[0] != 0xD0:
return items
off = _skip_ber_len(raw, 1)
while off < len(raw) - 1:
tag, tlen = raw[off], raw[off + 1]
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
if tag == 0x8F and tlen >= 2:
items.append({'id': val[0], 'text': _decode_stk_text(val[1:])})
return items
def _handle_proactive_chain(scc, sw91, on_fetch=None):
sys.stderr.write('91XX chain: sw=%s\n' % sw91)
sw = sw91
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))
sys.stderr.write('FETCH(%s): %s -> %s\n' % (fetch_len, rv[0] if rv[0] else '(none)', rv[1]))
fdata, sw = rv[0], rv[1]
raw = bytes.fromhex(fdata) if fdata else None
action = None
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)
else:
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = 1, 0, 0x83, 0x81, None
entry = None
if on_fetch:
action = on_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst)
if action != 'pause':
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()))
sys.stderr.write('TR: cmd=%02x type=%02x -> %s %s\n' % (cmd_num, cmd_type, tr_rv[1], ('(%d bytes)' % len(tr_tlv))))
_record_tr(entry, tr_tlv, tr_rv[1])
sw = tr_rv[1]
if sw == '9000':
sys.stderr.write('STATUS poll (chain ended)\n')
st_data, st_sw = _send_status(scc)
sys.stderr.write('STATUS -> %s\n' % st_sw)
if st_sw.startswith('91'):
sw = st_sw
if action == 'exit':
return sw
def _send_terminal_profile(scc, tp_hex):
tp_data, tp_sw = scc._tp.send_apdu('%s100000%02x%s' % (scc.cat_cla, len(tp_hex) // 2, tp_hex))
sim_menu = None
sim_menu = None
event_list = None
if tp_sw.startswith('91'):
sw = tp_sw
while sw.startswith('91'):
fetch_len = int(sw[2:], 16) if len(sw) == 4 else 0xff
fdata, sw = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla, fetch_len))
cmd_num, cmd_type = 1, 0
cmd_qual = None
dev_src, dev_dst = 0x83, 0x81
if fdata:
raw = bytes.fromhex(fdata)
if raw[0] == 0xD0:
off = _skip_ber_len(raw, 1)
menu = None
items = []
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:
cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2]
if cmd_type == 0x25:
menu = {'command_number': cmd_num, 'items': items}
elif tag == 0x82 and tlen >= 2:
dev_src, dev_dst = val[0], val[1]
elif tag == 0x05 and tlen >= 1 and menu is not None:
try:
menu['title'] = _STK_DECODE._decode(val, {}, 'stk_title')
except Exception:
menu['title'] = val.hex()
elif tag == 0x8F and tlen >= 2:
try:
txt = _STK_DECODE._decode(val[1:], {}, 'stk_item')
except Exception:
txt = val[1:].hex()
items.append({'id': val[0], 'text': txt})
elif tag in (0x99, 0x19) and tlen >= 1:
event_list = [b for b in val]
if menu:
sim_menu = menu
if fdata and cmd_type:
entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num)
else:
entry = 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()))
sys.stderr.write('TR(tp): cmd=%02x type=%02x -> %s\n' % (cmd_num, cmd_type, tr_rv[1]))
_record_tr(entry, tr_tlv, tr_rv[1])
sw = tr_rv[1]
if sw == '9000':
sys.stderr.write('STATUS poll (tp chain ended)\n')
st_data, st_sw = _send_status(scc)
sys.stderr.write('STATUS -> %s\n' % st_sw)
if st_sw.startswith('91'):
sw = st_sw
return sim_menu, event_list
def _make_menu_fetch_handler(server, resp):
"""on_fetch callback for the menu chain: pauses on user-interactive commands
and stores the pending command so a TERMINAL RESPONSE can be sent later."""
def _on_menu_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst):
if cmd_type == 0x21:
text = _parse_display_text(raw) if raw else None
if text:
server.stk_pending = {'type': 'display_text',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'text': text}
resp.update(type='display_text', text=text)
return 'pause'
elif cmd_type == 0x24:
items = _parse_select_item(raw) if raw else []
server.stk_pending = {'type': 'select_item',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
resp.update(type='select_item', items=items)
return 'pause'
elif cmd_type == 0x25:
items = _parse_setup_menu_items(raw) if raw else []
server.stk_pending = {'type': 'select_item',
'cmd_num': cmd_num, 'cmd_type': cmd_type,
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
resp.update(type='select_item', items=items)
return 'pause'
return _on_menu_fetch
def _menu_send_response(server, result, item_id=None):
"""Send the pending command's TERMINAL RESPONSE and continue the chain.
Shared by /api/menu-respond and the user-input timeout watchdog. Returns
(payload, http_status)."""
if not server.stk_pending:
return {'error': 'no pending command'}, 400
scc = server.scc
RESULT_MAP = {'ok': 0x00, 'cancel': 0x10, 'back': 0x11, 'timeout': 0x12}
gr = RESULT_MAP.get(result, 0x00)
pd = server.stk_pending
cd = bytes([0x81, 0x03, pd['cmd_num'], pd['cmd_type'], 0x00])
di = bytes([0x82, 0x02, pd['dev_dst'], pd['dev_src']])
tr_data = cd + di
if isinstance(item_id, int) and result == 'ok' and pd['type'] == 'select_item':
tr_data += bytes([0x90, 0x01, item_id])
tr_data += bytes([0x83, 0x02, gr, 0x00])
tr_hex = '%s140000%02x%s' % (scc.cat_cla, len(tr_data), tr_data.hex())
tr_rv = scc._tp.send_apdu(tr_hex)
sys.stderr.write('TR(menu): cmd=%02x type=%02x result=%02x -> %s\n' % (pd['cmd_num'], pd['cmd_type'], gr, tr_rv[1]))
for entry in reversed(_PROACTIVE_LOG):
if (entry.get('cmd_num') == pd['cmd_num']
and entry.get('type_hex') == '%02x' % pd['cmd_type']
and 'tr_hex' not in entry):
_record_tr(entry, tr_data, tr_rv[1])
break
sw = tr_rv[1]
resp = {'sw': sw}
if result == 'cancel':
server.stk_pending = None
server.menu_active = False
else:
server.stk_pending = None
if sw.startswith('91'):
_handle_proactive_chain(scc, sw, _make_menu_fetch_handler(server, resp))
else:
server.menu_active = False
resp['type'] = 'done'
if server.stk_pending:
_arm_menu_timeout()
else:
_cancel_menu_timeout()
return resp, 200
class PysimHandler(BaseHTTPRequestHandler):
def _send_json(self, data, status=200):
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode())
def _read_body(self):
length = int(self.headers.get('Content-Length', 0))
return json.loads(self.rfile.read(length))
def _log_req(self, body=None):
if self.server.log_requests:
if body is not None:
sys.stderr.write("REQUEST %s %s: %s\n" % (self.command, self.path, json.dumps(body)))
else:
sys.stderr.write("REQUEST %s %s\n" % (self.command, self.path))
def _log_resp(self, data):
if self.server.log_requests:
sys.stderr.write("RESPONSE %s: %s\n" % (self.path, json.dumps(data, ensure_ascii=False)))
def do_OPTIONS(self):
self._log_req()
self.send_response(204)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
# Chrome/Edge Private Network Access: a page served from a public (or
# otherwise less-local) origin needs explicit permission to reach this
# loopback server. Without this, the preflight fails and the browser
# blocks the follow-up GET/POST ("Permission was denied ... loopback").
self.send_header('Access-Control-Allow-Private-Network', 'true')
self.end_headers()
def _serve_static(self):
web_dir = getattr(self.server, 'web_dir', None)
if not web_dir:
self.send_error(404)
return
rel = self.path.split('?', 1)[0].lstrip('/')
if rel in ('', '/'):
rel = 'index.html'
if '..' in rel.split('/') or rel.startswith('/'):
self.send_error(404)
return
fs_path = os.path.join(web_dir, rel)
if not os.path.isfile(fs_path):
self.send_error(404)
return
with open(fs_path, 'rb') as f:
data = f.read()
content_type = _STATIC_MIME.get(os.path.splitext(rel)[1].lower(), 'application/octet-stream')
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(data)))
if rel in ('index.html', 'sw.js'):
self.send_header('Cache-Control', 'no-cache')
self.end_headers()
self.wfile.write(data)
def do_GET(self):
# /api/status is pure cached state (no card I/O); keeping it out of the
# lock lets the UI report 'initializing' while a long equip holds the
# card lock. Result-shaping masks everything card-derived when the
# session is not connected.
if self.path == '/api/status':
self._do_GET()
return
# Serialize all card access: the background STATUS poll runs in its own
# thread and must never interleave with a FETCH/TERMINAL RESPONSE pair.
with _CARD_LOCK:
self._do_GET()
def _do_GET(self):
lang = _get_lang(self.headers)
if self.path == '/api/version':
self._log_req()
self._send_json({'version': VERSION})
self._log_resp({'version': VERSION})
elif self.path == '/api/status':
self._log_req()
app = self.server.app
rs = app.rs if app else None
lchan = rs.lchan[0] if rs else None
cur_file = lchan.selected_file if lchan else None
scc = app.card._scc if app and app.card else None
card = app.card if app else None
connected = bool(_CARD_CONNECTED and card is not None)
if not connected:
rs = None
lchan = None
cur_file = None
scc = None
card = None
data = {
'reader': str(self.server.sl) if self.server.sl else None,
'connected': connected,
'card_present': bool(getattr(self.server, 'card_present', False)),
'card_session': int(getattr(self.server, 'card_session', 0)),
'equipping': bool(getattr(self.server, 'equipping', False)),
'auto_equip': bool(_AUTO_EQUIP),
'card': card.name if card else None,
'profile': str(rs.profile) if rs and rs.profile else None,
'app_ready': app is not None,
'adm_verified': rs.adm_verified if rs else False,
'atr': rs.identity.get('ATR') if rs and rs.identity else None,
'cla_byte': scc.cla_byte if scc else None,
'sel_ctrl': scc.sel_ctrl if scc else None,
'current_selection': {
'fid': cur_file.fid.upper() if cur_file and cur_file.fid else None,
'name': cur_file.name if cur_file else None,
'desc': cur_file.desc if cur_file else None,
'type': cur_file.__class__.__name__ if cur_file else None,
'path': str(lchan.get_cwd()) if lchan else None,
'file_type': _get_file_type(lchan, cur_file),
'file_size': lchan.selected_file_size() if lchan else None,
'record_len': lchan.selected_file_record_len() if lchan else None,
'num_of_rec': lchan.selected_file_num_of_rec() if lchan else None,
} if cur_file else None,
'channels': [str(i) for i, ch in rs.lchan.items() if ch] if rs else [],
}
self._send_json(data)
self._log_resp(data)
elif self.path == '/api/commands':
self._log_req()
app = self.server.app
if not app:
self._send_json({'error': _err('app_not_init', lang)}, 503)
self._log_resp({'error': _err('app_not_init', lang)})
return
cmds = sorted(
attr[3:] for attr in dir(app)
if attr.startswith('do_') and not attr.startswith('do__')
)
self._send_json(cmds)
self._log_resp(cmds)
elif self.path == '/api/cardinfo':
self._log_req()
app = self.server.app
if not app:
self._send_json({'error': _err('app_not_init', lang)}, 503)
self._log_resp({'error': _err('app_not_init', lang)})
return
out = StringIO()
old_stdout = app.stdout
old_stderr = sys.stderr
app.stdout = out
sys.stderr = out
try:
app.onecmd_plus_hooks('cardinfo')
output = _strip_ansi(out.getvalue())
except Exception as e:
output = str(e) + '\n' + traceback.format_exc()
finally:
app.stdout = old_stdout
sys.stderr = old_stderr
resp = {'output': output}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/menu':
menu = self.server.sim_menu or {'items': []}
resp = {**menu, 'active': self.server.menu_active}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/events':
resp = self.server.event_list or []
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/pli-qualifiers':
qualifiers = [{'code': '%02X' % q, 'name': PLI_QUALIFIER_NAMES[q]} for q in PLI_QUALIFIER_NAMES]
self._send_json(qualifiers)
self._log_resp(qualifiers)
elif self.path == '/api/pli-dict':
resp = {('%02X' % q): v for q, v in _PLI_DATA.items()}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/poll-status':
resp = {'enabled': _POLL_ENABLED, 'interval': _POLL_INTERVAL}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/proactive-log':
log = list(reversed(_PROACTIVE_LOG[-50:]))
self._send_json(log)
self._log_resp(log)
elif self.path == '/api/stk-status':
resp = {'active': self.server.menu_active,
'pending': self.server.stk_pending is not None,
'pending_type': self.server.stk_pending['type'] if self.server.stk_pending else None}
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)})
else:
self._log_req()
self._serve_static()
def do_POST(self):
# Serialize all card access: the background STATUS poll runs in its own
# thread and must never interleave with a FETCH/TERMINAL RESPONSE pair.
with _CARD_LOCK:
_reset_poll_timer()
self._do_POST()
def _do_POST(self):
lang = _get_lang(self.headers)
if self.path == '/api/command':
app = self.server.app
if not app:
self._send_json({'error': _err('app_not_init', lang)}, 503)
self._log_resp({'error': _err('app_not_init', lang)})
return
body = self._read_body()
self._log_req(body)
cmd = body.get('cmd', '')
t0 = time.time()
out = StringIO()
old_stdout = app.stdout
old_stderr = sys.stderr
app.stdout = out
sys.stderr = out
try:
stop = app.onecmd_plus_hooks(cmd)
output = _strip_ansi(out.getvalue())
except Exception as e:
output = str(e) + '\n' + traceback.format_exc()
finally:
app.stdout = old_stdout
sys.stderr = old_stderr
elapsed = int((time.time() - t0) * 1000)
status = 'OK' if not output or 'not a recognized command' not in output else 'ERROR'
is_equip = str(cmd).strip().startswith('equip')
if is_equip:
_tlog('equip: onecmd_plus_hooks %dms' % elapsed)
if is_equip and self.server.app and self.server.app.card and self.server.terminal_profile:
_apply_equipped_card(self.server)
sys.stderr.write("CMD: %s%s (%dms)\n" % (cmd, status, elapsed))
resp = {'output': output, 'stop': bool(stop)}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/apdu':
scc = self.server.scc
if not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
body = self._read_body()
self._log_req(body)
apdu_hex = body.get('apdu', '')
t0 = time.time()
try:
data, sw = scc.send_apdu_checksw(apdu_hex)
elapsed = int((time.time() - t0) * 1000)
resp = {'response': data, 'sw': sw}
sys.stderr.write("APDU: %s → SW: %s (%dms)\n" % (apdu_hex, sw, elapsed))
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
elapsed = int((time.time() - t0) * 1000)
err = {'error': str(e)}
sys.stderr.write("APDU: %s → ERROR: %s (%dms)\n" % (apdu_hex, str(e), elapsed))
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/status-poll':
scc = self.server.scc
if not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
sys.stderr.write('STATUS poll (manual)\n')
try:
st_data, st_sw = _send_status(scc)
sys.stderr.write('STATUS -> %s\n' % st_sw)
resp = {'sw': st_sw}
if st_sw.startswith('91'):
_handle_proactive_chain(scc, st_sw)
resp['proactive'] = True
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
sys.stderr.write('STATUS poll error: %s\n' % e)
_handle_card_disconnect()
self._send_json({'sw': None, 'error': 'card disconnected'})
self._log_resp({'sw': None, 'error': 'card disconnected'})
elif self.path == '/api/rescue':
scc = self.server.scc
if not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
if not self.server.terminal_profile:
self._send_json({'error': 'no terminal profile configured'}, 400)
self._log_resp({'error': 'no terminal profile configured'})
return
sys.stderr.write('RESCUE: re-sending TERMINAL PROFILE\n')
self.server.stk_pending = None
self.server.menu_active = False
_cancel_menu_timeout()
self.server.event_list = None
_reset_proactive_log()
sm, el = _send_terminal_profile(scc, self.server.terminal_profile)
self.server.sim_menu = sm
self.server.event_list = el
resp = {'menu': sm is not None, 'events': el}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/help':
app = self.server.app
if not app:
self._send_json({'error': _err('app_not_init', lang)}, 503)
self._log_resp({'error': _err('app_not_init', lang)})
return
body = self._read_body()
self._log_req(body)
cmd = body.get('cmd', '')
out = StringIO()
old_stdout = app.stdout
old_stderr = sys.stderr
app.stdout = out
sys.stderr = out
try:
app.onecmd_plus_hooks('help ' + cmd)
raw = out.getvalue()
finally:
app.stdout = old_stdout
sys.stderr = old_stderr
clean = _strip_ansi(raw)
parsed = _parse_help_text(clean)
self._send_json(parsed)
self._log_resp(parsed)
elif self.path == '/api/select':
app = self.server.app
if not app:
self._send_json({'error': _err('app_not_init', lang)}, 503)
self._log_resp({'error': _err('app_not_init', lang)})
return
body = self._read_body()
self._log_req(body)
path = body.get('path')
fid = body.get('fid')
name = fid if fid else body.get('name', '')
parent_sel = body.get('parent_sel')
rs = app.rs
if not rs:
self._send_json({'error': _err('no_card_state', lang)}, 503)
self._log_resp({'error': _err('no_card_state', lang)})
return
lchan = rs.lchan[0]
try:
if path:
_select_path(lchan, path, app)
else:
_select_with_parent(lchan, name, parent_sel, app)
cur = lchan.selected_file
data = {
'name': cur.name if cur else None,
'fid': cur.fid.upper() if cur and cur.fid else None,
'file_type': _get_file_type(lchan, cur),
'file_size': lchan.selected_file_size() if lchan else None,
'record_len': lchan.selected_file_record_len() if lchan else None,
'num_of_rec': lchan.selected_file_num_of_rec() if lchan else None,
'fci_hex': (lchan.selected_file_fcp_hex or '').upper() if lchan and lchan.selected_file_fcp_hex else None,
'exists': True,
}
self._send_json(data)
self._log_resp(data)
except Exception as e:
err = {'error': str(e), 'exists': False}
self._send_json(err, 404)
self._log_resp(err)
elif self.path == '/api/read':
app = self.server.app
if not app:
self._send_json({'error': _err('app_not_init', lang)}, 503)
self._log_resp({'error': _err('app_not_init', lang)})
return
body = self._read_body()
self._log_req(body)
path = body.get('path')
fid = body.get('fid')
name = fid if fid else body.get('name', '')
parent_sel = body.get('parent_sel')
mode = body.get('mode', 'raw')
rs = app.rs
if not rs:
self._send_json({'error': _err('no_card_state', lang)}, 503)
self._log_resp({'error': _err('no_card_state', lang)})
return
lchan = rs.lchan[0]
try:
sel = fid if fid else name
if path:
_select_path(lchan, path, app)
else:
_select_with_parent(lchan, sel, parent_sel, app)
ft = _get_file_type(lchan, lchan.selected_file)
is_record = ft in ('linear_fixed', 'cyclic')
if mode == 'decoded':
cmd = 'read_records_decoded' if is_record else 'read_binary_decoded'
else:
cmd = 'read_records' if is_record else 'read_binary'
out = StringIO()
old_stdout = app.stdout
old_stderr = sys.stderr
app.stdout = out
sys.stderr = out
try:
app.onecmd_plus_hooks(cmd)
output = _strip_ansi(out.getvalue())
finally:
app.stdout = old_stdout
sys.stderr = old_stderr
sw_match = re.search(r'SW:\s*(\w+)', output)
err_match = re.search(r'got (\w+)', output)
if err_match:
sw = err_match.group(1)
descs = {'6982': 'Security status not satisfied', '6983': 'PIN blocked',
'6985': 'Conditions of use not satisfied', '6A88': 'Referenced data not found',
'6A82': 'File not found'}
resp = {'success': False, 'sw': sw, 'error': descs.get(sw, 'Error')}
self._send_json(resp)
self._log_resp(resp)
return
sw = sw_match.group(1) if sw_match else '9000'
clean = re.sub(r'^SW:\s*\w+\s*', '', output, flags=re.MULTILINE).strip()
if mode == 'decoded':
try:
parsed = json.loads(clean)
resp = {'success': True, 'sw': sw, 'file_type': ft, 'decoded': parsed}
except json.JSONDecodeError:
resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean}
elif is_record:
records = []
for line in clean.split('\n'):
m = re.match(r'^(\d+)\s(.+)', line)
if m:
records.append({'num': int(m.group(1)), 'data': m.group(2)})
resp = {'success': True, 'sw': sw, 'file_type': ft, 'records': records}
else:
resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean}
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/write':
app = self.server.app
if not app:
self._send_json({'error': _err('app_not_init', lang)}, 503)
self._log_resp({'error': _err('app_not_init', lang)})
return
body = self._read_body()
self._log_req(body)
name = body.get('name', '')
data = body.get('data', '')
fid = body.get('fid')
record_nr = body.get('record_nr')
parent_sel = body.get('parent_sel')
rs = app.rs
if not rs:
self._send_json({'error': _err('no_card_state', lang)}, 503)
self._log_resp({'error': _err('no_card_state', lang)})
return
lchan = rs.lchan[0]
try:
sel = fid if fid else name
_select_with_parent(lchan, sel, parent_sel, app)
ft = _get_file_type(lchan, lchan.selected_file)
is_record = ft in ('linear_fixed', 'cyclic')
if record_nr:
cmd = 'update_record %d %s' % (record_nr, data)
elif is_record:
cmd = 'update_record 1 %s' % data
else:
cmd = 'update_binary %s' % data
out = StringIO()
old_stdout = app.stdout
old_stderr = sys.stderr
app.stdout = out
sys.stderr = out
try:
app.onecmd_plus_hooks(cmd)
output = out.getvalue()
finally:
app.stdout = old_stdout
sys.stderr = old_stderr
sw_match = re.search(r'SW:\s*(\w+)', output)
err_match = re.search(r'got (\w+)', output)
if err_match:
sw = err_match.group(1)
descs = {'6982': 'Security status not satisfied', '6983': 'PIN blocked',
'6985': 'Conditions of use not satisfied', '6A88': 'Referenced data not found',
'6A82': 'File not found'}
resp = {'success': False, 'sw': sw, 'error': descs.get(sw, 'Error')}
else:
sw = sw_match.group(1) if sw_match else '9000'
resp = {'success': True, 'sw': sw}
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/tree':
app = self.server.app
if not app:
self._send_json({'error': _err('app_not_init', lang)}, 503)
self._log_resp({'error': _err('app_not_init', lang)})
return
body = self._read_body()
self._log_req(body)
fid = body.get('fid')
name = fid if fid else body.get('name', '')
fid = body.get('fid')
parent_sel = body.get('parent_sel')
rs = app.rs
if not rs:
self._send_json({'error': _err('no_card_state', lang)}, 503)
self._log_resp({'error': _err('no_card_state', lang)})
return
lchan = rs.lchan[0]
try:
sel = fid if fid else name
_select_with_parent(lchan, sel, parent_sel, app)
cur = lchan.selected_file
out = StringIO()
old_stdout = app.stdout
old_stderr = sys.stderr
app.stdout = out
sys.stderr = out
try:
app.onecmd_plus_hooks('tree')
output = _strip_ansi(out.getvalue())
finally:
app.stdout = old_stdout
sys.stderr = old_stderr
children = _parse_tree_output(output)
sels = lchan.selected_file.get_selectables() if lchan and lchan.selected_file else {}
for child in children:
if child['isDir'] and child['name'] in sels:
f = sels[child['name']]
if hasattr(f, 'aid') and f.aid:
child['aid'] = f.aid.upper()
resp = {
'exists': True,
'name': cur.name if cur else None,
'fid': cur.fid.upper() if cur and cur.fid else None,
'file_type': _get_file_type(lchan, cur),
'children': children,
}
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
sys.stderr.write('Handler error: %s\n' % e)
if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e):
_handle_card_disconnect()
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/menu-select':
scc = self.server.scc
if not scc:
self._send_json({'error': 'card reader not available'}, 503)
return
body = self._read_body()
self._log_req(body)
item_id = body.get('item_id', 0)
if not isinstance(item_id, int):
item_id = int(item_id)
self.server.menu_active = True
# Build ENVELOPE(Menu Selection): D3 [len] DeviceIdentities + ItemIdentifier
menu_tlv = bytes([0xD3, 0x07, 0x02, 0x02, 0x01, 0x81, 0x90, 0x01, item_id])
env_hex = '%sc20000%02x%s' % (scc.cat_cla, len(menu_tlv), menu_tlv.hex())
data, sw = scc._tp.send_apdu(env_hex)
resp = {'type': 'done', 'sw': sw}
on_fetch = _make_menu_fetch_handler(self.server, resp)
if sw.startswith('91'):
_handle_proactive_chain(scc, sw, on_fetch)
else:
self.server.menu_active = False
self.server.stk_pending = None
if sw == '9000':
sys.stderr.write('STATUS poll (menu-select 9000)\n')
st_data, st_sw = _send_status(scc)
sys.stderr.write('STATUS -> %s\n' % st_sw)
if st_sw.startswith('91'):
_handle_proactive_chain(scc, st_sw, on_fetch)
if self.server.stk_pending:
_arm_menu_timeout()
else:
_cancel_menu_timeout()
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/menu-respond':
body = self._read_body()
self._log_req(body)
resp, code = _menu_send_response(self.server, body.get('result', 'ok'), body.get('item_id'))
self._send_json(resp, code)
self._log_resp(resp)
elif self.path == '/api/event-send':
scc = self.server.scc
if not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
body = self._read_body()
self._log_req(body)
event_type = body.get('event_type')
event_data_hex = body.get('event_data')
if event_type is None:
self._send_json({'error': 'event_type is required'}, 400)
return
event_data = bytes.fromhex(event_data_hex) if event_data_hex else None
try:
data, sw = _send_event_download(scc, event_type, event_data)
resp = {'sw': sw}
if data:
resp['data'] = data
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
sys.stderr.write('Event send error: %s\n' % e)
_handle_card_disconnect()
self._send_json({'sw': None, 'error': 'card disconnected'})
self._log_resp({'sw': None, 'error': 'card disconnected'})
elif self.path == '/api/pli-dict':
body = self._read_body()
self._log_req(body)
if isinstance(body, dict):
for k, v in body.items():
if isinstance(v, str):
try:
code = int(k, 16)
if code in _PLI_DATA:
bytes.fromhex('') if not v else bytes.fromhex(v)
_PLI_DATA[code] = v
except (ValueError, KeyError):
pass
resp = {('%02X' % q): v for q, v in _PLI_DATA.items()}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/poll-toggle':
body = self._read_body()
self._log_req(body)
if isinstance(body, dict) and body.get('enabled'):
_poll_enable()
else:
_poll_disable()
resp = {'enabled': _POLL_ENABLED, 'interval': _POLL_INTERVAL}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/send-ota':
app = self.server.app
if not app:
self._send_json({'error': _err('app_not_init', lang)}, 503)
self._log_resp({'error': _err('app_not_init', lang)})
return
body = self._read_body()
self._log_req(body)
sp = body.get('sp', '')
apdu = body.get('apdu', '')
scc = self.server.scc
if not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
include_cpi = body.get('includeCpi', True)
try:
if apdu:
# RAM operation: SCP80-wrap the raw GP command
spi1 = body.get('spi1', '16')
spi2 = body.get('spi2', '01')
kic = body.get('kic', '25')
kid = body.get('kid', '25')
tar = body.get('tar', '000000')
cntr = body.get('cntr', '')
kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '')
sp_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar, cntr, apdu, kic_key, kid_key)
sp_bytes = bytes.fromhex(sp_hex)
else:
# Regular SCP80: use pre-built secured packet
sp_hex = sp
sp_bytes = bytes.fromhex(sp_hex)
spi2_val = int(body.get('spi2', '00'), 16)
por_in_submit = bool(spi2_val & 0x20)
submit_handler = None
old_proactive = None
if por_in_submit and hasattr(scc, '_tp'):
submit_handler = PoRSubmitHandler()
old_proactive = scc._tp.proactive_handler
scc._tp.proactive_handler = submit_handler
try:
max_chunk = 130
chunks = [sp_bytes[i:i+max_chunk] for i in range(0, len(sp_bytes), max_chunk)]
total = len(chunks)
sys.stderr.write('OTA SEND: SPI %s %s KIc %s KID %s TAR %s CNTR %s LEN %dB CHUNKS %d\n' % (
body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''),
len(sp_bytes), total))
if total > MAX_ENVELOPE_SEGMENTS:
resp = {'success': False, 'error': 'Secured packet too large: %d segments (max %d)' % (total, MAX_ENVELOPE_SEGMENTS)}
sys.stderr.write('OTA SEND FAILED: %d segments exceeds max %d\n' % (total, MAX_ENVELOPE_SEGMENTS))
else:
sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp)
sys.stderr.write('RAM SECURED-PACKET: %s\n' % sp_hex)
last_data = None
last_sw = None
for i, chunk in enumerate(chunks):
tpdu = _build_sms_tpdu(chunk.hex(), total, i + 1, oa_number=self.server.sms_oa,
include_cpi=include_cpi)
data, sw = _send_envelope(tpdu, scc, sm_sc=self.server.sms_sc, submit_handler=submit_handler)
last_data = data
last_sw = sw
if sw != '9000' and not sw.startswith('91'):
resp = {'success': False, 'sw': sw, 'error': 'ENVELOPE failed at chunk %d' % (i + 1)}
sys.stderr.write('OTA SEND FAILED: chunk %d SW %s\n' % (i + 1, sw))
break
else:
resp = {'success': True, 'sw': last_sw, 'response_data': last_data if last_data else None}
por_src = 'envelope'
por_hex = resp['response_data']
if submit_handler and submit_handler.submit_tpdu_hex:
tpdu_b = bytes.fromhex(submit_handler.submit_tpdu_hex)
idx = tpdu_b.find(b'\x02\x71\x00')
if idx >= 0:
por_hex = tpdu_b[idx:].hex()
por_src = 'sms-submit'
por = _decode_por(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''),
body.get('kidKey', ''), por_hex)
# Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR
is_ram = bool(apdu)
por_required = bool(spi2_val & 0x01)
no_por_received = not por_hex and not (submit_handler and submit_handler.submit_tpdu_hex)
if is_ram and por_required and last_sw == '9000' and no_por_received:
sys.stderr.write('WARNING: Card refused to return PoR - ENVELOPE returned 9000 with no response data\n')
sys.stderr.write('RAM RESPONSE-PACKET: %s\n' % (por_hex if por_hex else 'empty'))
if por:
resp['por'] = por
extra = ''
if por.get('decoded'):
extra = ' (compact: %s cmd, last SW %s)' % (por['decoded'].get('number_of_commands', '?'),
por['decoded'].get('last_status_word', '?'))
sys.stderr.write('RAM R-APDU: %s\n' % por['decoded'].get('last_response_data', ''))
sys.stderr.write('OTA PoR[%s]: status=%s TAR=%s CNTR=%s PCNTR=%s RPL=%s RHL=%s%s\n' % (
por_src, por.get('response_status'), por.get('tar'), por.get('cntr'),
por.get('pcntr'), por.get('rpl'), por.get('rhl'), extra))
elif por_hex:
sys.stderr.write('OTA PoR[%s]: undecodable raw=%s\n' % (por_src, str(por_hex)))
else:
sys.stderr.write('OTA PoR[%s]: none\n' % por_src)
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
sys.stderr.write('OTA send error: %s\n' % e)
if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e):
_handle_card_disconnect()
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/sp-verify':
body = self._read_body()
self._log_req(body)
try:
ref, spi = _ota_reference(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''),
body.get('apdu', ''), body.get('kicKey', ''), body.get('kidKey', ''))
js_sp = (body.get('sp', '') or '').replace(' ', '').lower()
ref_l = ref.lower()
diffs = []
if js_sp != ref_l:
n = min(len(js_sp), len(ref_l))
for i in range(0, n, 2):
if js_sp[i:i+2] != ref_l[i:i+2]:
diffs.append({'offset': i // 2, 'js': js_sp[i:i+2], 'ref': ref_l[i:i+2]})
if len(js_sp) != len(ref_l):
diffs.append({'offset': n // 2, 'js': js_sp[n:], 'ref': ref_l[n:]})
resp = {'js_sp': js_sp, 'py_sp': ref_l, 'match': js_sp == ref_l, 'diffs': diffs[:50], 'spi': spi}
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/ram-install':
body = self._read_body()
self._log_req(body)
scc = self.server.scc
if not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
try:
cap_hex = body.get('cap_hex', '').replace(' ', '')
if not cap_hex:
self._send_json({'error': 'No cap_hex provided'}, 400)
return
# Parse .cap file
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
# SCP80 params
spi1 = body.get('spi1', '16')
spi2 = body.get('spi2', '01')
kic = body.get('kic', '25')
kid = body.get('kid', '25')
tar = body.get('tar', '000000')
cntr = body.get('cntr', '00000000')
kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '')
sd_aid = body.get('sd_aid', '').replace(' ', '')
install_params_hex = body.get('install_params', '').replace(' ', '')
stk_params_hex = body.get('stk_params', '').replace(' ', '')
make_selectable = body.get('make_selectable', True)
privileges_hex = body.get('privileges', '').replace(' ', '') or '00'
steps = []
include_cpi = body.get('includeCpi', True)
spi2_val = int(spi2, 16)
por_in_submit = bool(spi2_val & 0x20)
def _send_gp_apdu(apdu_hex, step_name):
nonlocal cntr
sp_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar, cntr, apdu_hex, kic_key, kid_key)
sp_bytes = bytes.fromhex(sp_hex)
max_chunk = 130
chunks = [sp_bytes[i:i + max_chunk] for i in range(0, len(sp_bytes), max_chunk)]
submit_handler = None
old_proactive = None
if por_in_submit and hasattr(scc, '_tp'):
submit_handler = PoRSubmitHandler()
old_proactive = scc._tp.proactive_handler
scc._tp.proactive_handler = submit_handler
try:
last_data = None
last_sw = None
for i, chunk in enumerate(chunks):
tpdu = _build_sms_tpdu(chunk.hex(), len(chunks), i + 1,
oa_number=self.server.sms_oa, include_cpi=include_cpi)
data, sw = _send_envelope(tpdu, scc, sm_sc=self.server.sms_sc,
submit_handler=submit_handler)
last_data = data
last_sw = sw
if sw != '9000' and not sw.startswith('91'):
steps.append({'name': step_name, 'por_status': 'envelope_error', 'sw': sw})
sys.stderr.write('RAM-INSTALL: %s ENVELOPE failed SW %s\n' % (step_name, sw))
return False
# Decode PoR
por_src = 'envelope'
por_hex = last_data
if submit_handler and submit_handler.submit_tpdu_hex:
tpdu_b = bytes.fromhex(submit_handler.submit_tpdu_hex)
idx = tpdu_b.find(b'\x02\x71\x00')
if idx >= 0:
por_hex = tpdu_b[idx:].hex()
por_src = 'sms-submit'
por = _decode_por(spi1, spi2, kic, kid, cntr, kic_key, kid_key, por_hex)
por_status = 'unknown'
if por and por.get('decoded'):
ps = por['decoded'].get('response_status', '')
por_status = 'por_ok' if ps == '9100' else 'por_error_%s' % ps
sys.stderr.write('RAM-INSTALL: %s PoR[%s] status=%s\n' % (step_name, por_src, ps))
elif last_sw == '9000' and not por_hex:
por_status = 'no_por'
steps.append({'name': step_name, 'por_status': por_status, 'sw': last_sw})
# Increment counter
cntr = '%010X' % ((int(cntr, 16) + 1) % (2 ** 32))
return True
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
# Step 1: INSTALL [for load]
sys.stderr.write('RAM-INSTALL: Step 1 — INSTALL [for load] loadfile_aid=%s\n' % loadfile_aid)
ifl_data = _lv(loadfile_aid) + _lv(sd_aid) + '00' + '00' + '00'
ifl_apdu = '80E60200%02X%s00' % (len(ifl_data) // 2, ifl_data)
if not _send_gp_apdu(ifl_apdu, 'INSTALL [for load]'):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': 'INSTALL [for load] failed', 'load_file_aid': loadfile_aid, 'module_aid': module_aid}
self._send_json(resp)
self._log_resp(resp)
return
# Step 2: LOAD blocks
loadfile_tlv = 'C4' + _ber_len(len(loadfile_data) // 2) + loadfile_data
block_size = 240
total_bytes = len(loadfile_tlv) // 2
blocks = [loadfile_tlv[i * 2:(i + block_size) * 2] for i in range(0, (total_bytes + block_size - 1) // block_size)]
sys.stderr.write('RAM-INSTALL: Step 2 — LOAD %d bytes in %d blocks\n' % (total_bytes, len(blocks)))
for block_idx, block in enumerate(blocks):
is_last = (block_idx == len(blocks) - 1)
p1 = 0x80 if is_last else 0x00
p2 = block_idx % 256
load_apdu = '80E8%02X%02X%02X%s00' % (p1, p2, len(block) // 2, block)
if not _send_gp_apdu(load_apdu, 'LOAD (%d/%d)' % (block_idx + 1, len(blocks))):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': 'LOAD block %d failed' % (block_idx + 1),
'load_file_aid': loadfile_aid, 'module_aid': module_aid}
self._send_json(resp)
self._log_resp(resp)
return
# Step 3: INSTALL [for install]
sys.stderr.write('RAM-INSTALL: Step 3 — INSTALL [for install]\n')
instance_aid = module_aid
privileges = privileges_hex
inst_params = install_params_hex if install_params_hex else 'C900'
if stk_params_hex:
inst_params += stk_params_hex
p1_install = 0x0C if make_selectable else 0x04
ifi_data = _lv(loadfile_aid) + _lv(module_aid) + _lv(instance_aid) + _lv(privileges) + _lv(inst_params) + '00'
ifi_apdu = '80E6%02X00%02X%s00' % (p1_install, len(ifi_data) // 2, ifi_data)
if not _send_gp_apdu(ifi_apdu, 'INSTALL [for install]'):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': 'INSTALL [for install] failed', 'load_file_aid': loadfile_aid, 'module_aid': module_aid}
self._send_json(resp)
self._log_resp(resp)
return
resp = {'success': True, 'steps': steps, 'load_file_aid': loadfile_aid,
'module_aid': module_aid, 'final_cntr': cntr}
sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
loadfile_aid, module_aid, cntr))
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
sys.stderr.write('RAM-INSTALL error: %s\n' % e)
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
else:
self._send_json({'error': _err('not_found', lang)}, 404)
self._log_resp({'error': _err('not_found', lang)})
def log_message(self, format, *args):
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))