feat: single-APDU INSTALL [for install] / [for make selectable] ops (v3.6.18)

Iterating the final install step no longer needs a delete + re-load of the
.cap:

- `POST /api/ram-install-app` runs INSTALL [for install] (`mode: "install"`,
  P1 0x0C/0x04 by make_selectable) or INSTALL [for make selectable]
  (`mode: "make_selectable"`, GP 11.5.2.3.3 Table 11-44) for an already
  loaded package.  The APDU builders are shared with `_cap_apdu_sequence`
  (`_cap_install_apdu`, `_cap_make_selectable_apdu` - one builder, no drift)
  and the per-step send/PoR/counter logic is the extracted shared
  `_ram_send_gp_apdu` (used by /api/ram-install too).
- PWA: the RAM op selector gains "INSTALL [for install] (loaded package)"
  and "INSTALL [for make selectable]" with Load File / Module / Instance AID
  inputs (instance defaults to the module AID); the result renders through
  the shared `ramShowInstallResult` and `final_cntr` keeps the preset in
  sync.
- Explore: an **Install** button on a package's module AIDs that have no
  instance yet, and a **Make selectable** button on INSTALLED (0x03)
  instances - both switch the op and pre-fill the AIDs.
- tests: the install APDU pinned to the live no-STK packet and to the
  sequence tail, the make-selectable APDU, ramHasInstance, the Explore
  buttons and the op wiring; docs/api.md updated.

650 frontend / 498 Python green; version 3.6.18; sw simple-v291.
This commit is contained in:
2026-09-28 08:10:40 +03:00
parent 6403fb9f29
commit face2268ca
7 changed files with 414 additions and 109 deletions
+176 -87
View File
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.6.17'
VERSION = '3.6.18'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -721,6 +721,30 @@ def _encode_scts(dt=None):
])
def _cap_install_apdu(loadfile_aid, module_aid, instance_aid='', privileges='00',
install_params='', stk_params='', make_selectable=True):
"""INSTALL [for install] APDU (GP Card Spec 11.5.2.3.2, Table 11-43) - the
final step of `_cap_apdu_sequence` and the /api/ram-install-app operation.
Case 3: no trailing Le (a trailing byte becomes a phantom command on the
card's SCP80 layer)."""
instance = instance_aid or module_aid
params = install_params if install_params else 'C900'
if stk_params:
params += stk_params
p1 = 0x0C if make_selectable else 0x04
data = (_lv(loadfile_aid) + _lv(module_aid) + _lv(instance) +
_lv(privileges or '00') + _lv(params) + '00')
return '80E6%02X00%02X%s' % (p1, len(data) // 2, data)
def _cap_make_selectable_apdu(instance_aid, privileges='00'):
"""INSTALL [for make selectable] APDU (GP Card Spec 11.5.2.3.3, Table
11-44): '00' '00' lv(AID) lv(privileges) lv(params) lv(token); the
parameters and token are empty here. Case 3 (no Le)."""
data = '00' + '00' + _lv(instance_aid) + _lv(privileges or '00') + '00' + '00'
return '80E60800%02X%s' % (len(data) // 2, data)
def _cap_apdu_sequence(loadfile_aid, module_aid, loadfile_data, sd_aid='',
privileges='00', install_params='', stk_params='',
make_selectable=True, block_size=240, instance_aid=None):
@@ -746,14 +770,10 @@ def _cap_apdu_sequence(loadfile_aid, module_aid, loadfile_data, sd_aid='',
for i, block in enumerate(blocks):
p1 = 0x80 if i == len(blocks) - 1 else 0x00
apdus.append('80E8%02X%02X%02X%s' % (p1, i % 256, len(block) // 2, block))
instance = instance_aid or module_aid
params = install_params if install_params else 'C900'
if stk_params:
params += stk_params
p1_install = 0x0C if make_selectable else 0x04
ifi_data = (_lv(loadfile_aid) + _lv(module_aid) + _lv(instance) +
_lv(privileges or '00') + _lv(params) + '00')
apdus.append('80E6%02X00%02X%s' % (p1_install, len(ifi_data) // 2, ifi_data))
apdus.append(_cap_install_apdu(
loadfile_aid, module_aid, instance_aid=instance_aid,
privileges=privileges, install_params=install_params,
stk_params=stk_params, make_selectable=make_selectable))
return apdus
@@ -1176,6 +1196,77 @@ def _ram_step_result(step_name, last_sw, por, por_hex, bytes_, segments):
return step, error
def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex):
"""Send one GP APDU as an SCP80 secured packet and append its step result
to `state['steps']`. `sp` carries the SCP80 parameters (spi1, spi2, kic,
kid, tar, kic_key, kid_key, include_cpi); `state` carries the mutable run
state ({'steps', 'encode_error', 'failure', 'cntr'}) and the counter is
advanced only for a packet the card accepted. Returns True on success
(the step record is the last element of state['steps'])."""
spi1, spi2 = sp['spi1'], sp['spi2']
try:
sp_hex, _ = _build_secured_packet(spi1, spi2, sp['kic'], sp['kid'], sp['tar'],
state['cntr'], apdu_hex,
sp['kic_key'], sp['kid_key'])
except ValueError as e:
state['encode_error'] = str(e)
state['steps'].append({'name': step_name, 'por_status': 'encode_error',
'sw': str(e)})
sys.stderr.write('RAM-INSTALL: %s encode failed: %s\n' % (step_name, e))
return False
submit_handler = None
old_proactive = None
if bool(int(spi2, 16) & 0x20) and hasattr(scc, '_tp'):
submit_handler = PoRSubmitHandler()
old_proactive = scc._tp.proactive_handler
scc._tp.proactive_handler = submit_handler
try:
result = _send_secured_packet(
scc, sp_hex, oa_number=server.sms_oa, sm_sc=server.sms_sc,
include_cpi=sp['include_cpi'], submit_handler=submit_handler)
if not result['success']:
state['steps'].append({'name': step_name, 'por_status': 'envelope_error',
'sw': result.get('sw') or result.get('error'),
'bytes': result.get('bytes'),
'segments': result.get('segments')})
sys.stderr.write('RAM-INSTALL: %s send failed: %s\n' % (
step_name, result.get('error')))
return False
por_hex = result['response_data']
last_sw = result['sw']
por_src = 'envelope'
submit_hex = _sms_submit_por(submit_handler)
if submit_hex:
por_hex = submit_hex
por_src = 'sms-submit'
elif submit_handler and submit_handler.submit_tpdu_hex:
# no assembled UD: fall back to a raw RPI packet
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, sp['kic'], sp['kid'], state['cntr'],
sp['kic_key'], sp['kid_key'], por_hex)
step, step_error = _ram_step_result(step_name, last_sw, por, por_hex,
result['bytes'], result['segments'])
state['steps'].append(step)
sys.stderr.write('RAM-INSTALL: %s PoR[%s] status=%s remote_sw=%s%s (%d B, %d SM)\n' % (
step_name, por_src, step.get('por_status', '?'), step.get('por_sw', '-'),
(' error=%s' % step_error) if step_error else '',
result['bytes'], result['segments']))
# Advance the counter only for an accepted packet
state['cntr'] = _ram_next_cntr(
state['cntr'], step.get('por_status') in ('por_ok', 'no_por'))
if step_error:
state['failure']['error'] = '%s: %s' % (step_name, step_error)
return False
return True
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
def _parse_response_scripting(data):
"""Parse a Response Scripting template (TS 102 226 5.2.2, tables
5.10/5.10a): `AB <len>` (definite) or `AF 80 ... 00 00` (indefinite),
@@ -4371,7 +4462,7 @@ _TEST_KIND_LABELS = {
_TEST_BLOCKED_PATHS = frozenset([
'/api/command', '/api/cardinfo', '/api/tree', '/api/select', '/api/read',
'/api/write', '/api/apdu', '/api/verify-adm', '/api/send-ota',
'/api/ram-install', '/api/sp-verify', '/api/menu-select',
'/api/ram-install', '/api/ram-install-app', '/api/sp-verify', '/api/menu-select',
'/api/menu-respond', '/api/event-send', '/api/net-sim',
'/api/net-state-refresh', '/api/status-poll', '/api/rescue',
'/api/terminal-profile', '/api/poll-toggle', '/api/esim/chip',
@@ -6125,78 +6216,11 @@ class PysimHandler(BaseHTTPRequestHandler):
sys.stderr.write('RAM-INSTALL: LOAD block size %d bytes\n' % block_size)
steps = []
encode_error = None
include_cpi = body.get('includeCpi', True)
spi2_val = int(spi2, 16)
por_in_submit = bool(spi2_val & 0x20)
failure = {}
def _send_gp_apdu(apdu_hex, step_name):
nonlocal cntr, encode_error
try:
sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, cntr, apdu_hex, kic_key, kid_key)
except ValueError as e:
encode_error = str(e)
steps.append({'name': step_name, 'por_status': 'encode_error',
'sw': encode_error})
sys.stderr.write('RAM-INSTALL: %s encode failed: %s\n' % (step_name, e))
return False
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:
result = _send_secured_packet(
scc, sp_hex, oa_number=self.server.sms_oa,
sm_sc=self.server.sms_sc, include_cpi=include_cpi,
submit_handler=submit_handler)
if not result['success']:
steps.append({'name': step_name, 'por_status': 'envelope_error',
'sw': result.get('sw') or result.get('error'),
'bytes': result.get('bytes'),
'segments': result.get('segments')})
sys.stderr.write('RAM-INSTALL: %s send failed: %s\n' % (
step_name, result.get('error')))
return False
last_data = result['response_data']
last_sw = result['sw']
# Decode PoR
por_src = 'envelope'
por_hex = last_data
submit_hex = _sms_submit_por(submit_handler)
if submit_hex:
por_hex = submit_hex
por_src = 'sms-submit'
elif submit_handler and submit_handler.submit_tpdu_hex:
# no assembled UD: fall back to a raw RPI packet
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)
step, step_error = _ram_step_result(
step_name, last_sw, por, por_hex,
result['bytes'], result['segments'])
steps.append(step)
sys.stderr.write('RAM-INSTALL: %s PoR[%s] status=%s remote_sw=%s%s (%d B, %d SM)\n' % (
step_name, por_src, step.get('por_status', '?'),
step.get('por_sw', '-'),
(' error=%s' % step_error) if step_error else '',
result['bytes'], result['segments']))
# Advance the counter only for an accepted packet
cntr = _ram_next_cntr(
cntr, step.get('por_status') in ('por_ok', 'no_por'))
if step_error:
failure['error'] = '%s: %s' % (step_name, step_error)
return False
return True
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
state = {'steps': steps, 'encode_error': None, 'failure': {},
'cntr': cntr}
sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': body.get('includeCpi', True)}
# INSTALL [for load] -> LOAD blocks -> INSTALL [for install]
seq = _cap_apdu_sequence(
@@ -6213,11 +6237,12 @@ class PysimHandler(BaseHTTPRequestHandler):
step_name = 'INSTALL [for install]'
else:
step_name = 'LOAD (%d/%d)' % (apdu_idx, len(seq) - 2)
if not _send_gp_apdu(gp_apdu, step_name):
if not _ram_send_gp_apdu(self.server, scc, sp_state, state,
step_name, gp_apdu):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': (encode_error or failure.get('error')
'error': (state['encode_error'] or state['failure'].get('error')
or ('%s failed' % step_name)),
'final_cntr': cntr,
'final_cntr': state['cntr'],
'load_file_aid': loadfile_aid, 'module_aid': module_aid,
'load_block_size': block_size,
'load_block_size_requested': block_size_req,
@@ -6227,12 +6252,12 @@ class PysimHandler(BaseHTTPRequestHandler):
return
resp = {'success': True, 'steps': steps, 'load_file_aid': loadfile_aid,
'module_aid': module_aid, 'final_cntr': cntr,
'module_aid': module_aid, 'final_cntr': state['cntr'],
'load_block_size': block_size,
'load_block_size_requested': block_size_req,
'load_block_size_auto': not block_size_req}
sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
loadfile_aid, module_aid, cntr))
loadfile_aid, module_aid, state['cntr']))
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
@@ -6240,6 +6265,70 @@ class PysimHandler(BaseHTTPRequestHandler):
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/ram-install-app':
# INSTALL [for install] / [for make selectable] for an already
# loaded package (iterate the final step without re-loading).
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:
mode = (body.get('mode') or 'install').strip()
loadfile_aid = (body.get('loadfile_aid') or '').replace(' ', '').upper()
module_aid = (body.get('module_aid') or '').replace(' ', '').upper()
instance_aid = ((body.get('instance_aid') or '').replace(' ', '').upper()
or module_aid)
privileges = (body.get('privileges') or '').replace(' ', '') or '00'
install_params_hex = (body.get('install_params') or '').replace(' ', '')
stk_params_hex = (body.get('stk_params') or '').replace(' ', '')
make_selectable = bool(body.get('make_selectable', True))
if mode == 'make_selectable':
if not instance_aid:
err = {'success': False, 'error': 'instance_aid required'}
self._send_json(err, 400)
self._log_resp(err)
return
apdu = _cap_make_selectable_apdu(instance_aid, privileges)
step_name = 'INSTALL [for make selectable]'
else:
if not loadfile_aid or not module_aid:
err = {'success': False,
'error': 'loadfile_aid and module_aid required'}
self._send_json(err, 400)
self._log_resp(err)
return
apdu = _cap_install_apdu(
loadfile_aid, module_aid, instance_aid=instance_aid,
privileges=privileges, install_params=install_params_hex,
stk_params=stk_params_hex, make_selectable=make_selectable)
step_name = 'INSTALL [for install]'
sp_state = {
'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', ''),
'include_cpi': body.get('includeCpi', True)}
state = {'steps': [], 'encode_error': None, 'failure': {},
'cntr': sp_state['cntr']}
sys.stderr.write('RAM-INSTALL-APP: %s (%s) cntr=%s\n' % (
apdu, step_name, sp_state['cntr']))
ok = _ram_send_gp_apdu(self.server, scc, sp_state, state, step_name, apdu)
resp = {'success': bool(ok), 'steps': state['steps'],
'final_cntr': state['cntr']}
if not ok:
resp['error'] = (state['encode_error'] or state['failure'].get('error')
or ('%s failed' % step_name))
resp['failed_step'] = len(state['steps'])
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
sys.stderr.write('RAM-INSTALL-APP error: %s\n' % e)
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/scp81/bip':
body = self._read_body()
# Never log the pre-shared keys.