esim: drive the profile switch ourselves; guard the FCP metadata

Disabling a profile failed with 6985 and left the card stuck until an
equip.  pySim's send_apdu_checksw auto-handler keeps flushing proactive
commands after the REFRESH TERMINAL RESPONSE; the card is then mid-switch
and answers 6985 to the next FETCH, which propagated as a 500 and skipped
the re-initialization.  Per SGP.22 v2.6 5.7.16/5.7.17 a 91XX answer is
the ISD-R's 'result OK before REFRESH' (step 6) and the switch completes
on the TERMINAL RESPONSE or the following RESET (step 8) - lpac treats
91XX the same way and never retries.

- esim.py: build_switch_apdu/parse_switch_response/switch_profile split
  out of set_profile_state; the switch is one raw STORE DATA via
  scc._tp.send_apdu, a 91XX runs our own FETCH/TR chain (status_poll=False)
  and is reported as ok, the STORE DATA is never retried and a chain
  failure still counts the accepted switch.
- server.py: /api/esim/profile answers the REFRESH with our chain, then
  re-initializes the card and re-reads the profile list, returning
  verified/state_after; _handle_proactive_chain grew status_poll.
- /api/status and /api/select: FCP metadata via _fcp_value - an ADF or a
  failed select (card with the active profile disabled) has no
  file_descriptor and used to crash the request handler; _get_file_type
  no longer raises either.
- esim._restore logs a failed selection restore instead of swallowing it.
- PWA: esimSwitchStatus shows the verified state / not-confirmed warning.
- tests: the switch flow (9000 / 91XX / error SW / chain failure), the
  FCP guards and the status helper; docs and sw simple-v230.
This commit is contained in:
2026-09-22 00:14:02 +03:00
parent 91c642a646
commit 86808ce55d
11 changed files with 338 additions and 87 deletions
+57 -15
View File
@@ -13,6 +13,7 @@ restored afterwards. The caller holds ``_CARD_LOCK``.
"""
import re
import sys
from osmocom.tlv import BER_TLV_IE, bertlv_parse_one_rawtag, flatten_dict_lists
from pySim.euicc import (
@@ -65,11 +66,15 @@ def _select_isdr(app):
def _restore(app):
"""Return to MF so later server operations start from a known selection."""
"""Return to MF so later server operations start from a known selection.
Best effort: a card whose active profile is disabled has no filesystem to
select, so the restore can legitimately fail - the selection metadata is
then guarded by the status endpoint instead of crashing it."""
try:
app.rs.soft_reset()
except Exception:
pass
except Exception as e:
sys.stderr.write('ESIM: selection restore failed: %s\n' % e)
def _run(app, fn):
@@ -449,8 +454,8 @@ def notifications(app):
return {'notifications': out, 'error': None}
def set_profile_state(app, action, iccid=None, isdp_aid=None, refresh=True):
"""ES10c Enable/DisableProfile for one profile (by ICCID or ISD-P AID)."""
def build_switch_apdu(action, iccid=None, isdp_aid=None, refresh=True):
"""STORE DATA APDU hex for ES10c Enable/DisableProfile (no sending)."""
if action not in ('enable', 'disable'):
raise EsimError('bad_action')
ident = []
@@ -469,14 +474,51 @@ def set_profile_state(app, action, iccid=None, isdp_aid=None, refresh=True):
else:
raise EsimError('missing_profile')
flag = RefreshFlag(decoded=1 if refresh else 0)
if action == 'enable':
cmd = EnableProfileReq(children=[ProfileIdentifier(children=ident), flag])
resp_cls, key = EnableProfileResp, 'enable_result'
else:
cmd = DisableProfileReq(children=[ProfileIdentifier(children=ident), flag])
resp_cls, key = DisableProfileResp, 'disable_result'
flat = _flatten(_transceive(app, cmd, resp_cls))
result = flat.get(key)
ok = result == 'ok'
return {'ok': ok, 'result': result if isinstance(result, str) else 'undefinedError',
req_cls = EnableProfileReq if action == 'enable' else DisableProfileReq
tx_do = req_cls(children=[ProfileIdentifier(children=ident), flag]).to_tlv()
return '80E29100%02x%s00' % (len(tx_do), tx_do.hex().upper())
def parse_switch_response(action, data_hex):
"""STORE DATA response TLV -> {'ok', 'result', 'message'}."""
resp_cls = EnableProfileResp if action == 'enable' else DisableProfileResp
key = 'enable_result' if action == 'enable' else 'disable_result'
result = None
if data_hex:
resp = resp_cls()
resp.from_tlv(bytes.fromhex(data_hex))
result = _flatten(resp).get(key)
return {'ok': result == 'ok',
'result': result if isinstance(result, str) else 'undefinedError',
'message': _error_text(result)}
def switch_profile(send_apdu, run_chain, action, iccid=None, isdp_aid=None,
refresh=True):
"""Run an ES10c Enable/DisableProfile switch.
``send_apdu(apdu_hex) -> (data_hex, sw)`` performs one raw STORE DATA and
``run_chain(sw91)`` answers the proactive command(s) the card sends
alongside the switch (True when a REFRESH was answered).
With the refresh flag set the ISD-R returns OK *before* the REFRESH
(SGP.22 v2.6 §5.7.16/§5.7.17 step 6) and the switch completes upon the
TERMINAL RESPONSE or the following RESET (step 8). A 91XX status is that
OK: the STORE DATA is never retried (the mid-switch card answers 6985 to
the retry) and the caller re-initializes the card afterwards."""
apdu = build_switch_apdu(action, iccid, isdp_aid, refresh)
data, sw = send_apdu(apdu)
if sw == '9000':
out = parse_switch_response(action, data)
out['refresh_seen'] = False
return out
if sw and sw.startswith('91'):
refresh_seen = False
try:
refresh_seen = bool(run_chain(sw))
except Exception as e:
sys.stderr.write('ESIM: REFRESH chain failed: %s\n' % e)
return {'ok': True, 'result': 'ok', 'message': 'ok',
'refresh_seen': refresh_seen}
return {'ok': False, 'result': 'undefinedError', 'sw': sw,
'message': 'SW %s' % sw}