From 86808ce55d8943a66320ad2929e73c7fedb2a111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 22 Sep 2026 00:14:02 +0300 Subject: [PATCH] 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. --- README.md | 2 +- README_RUS.md | 2 +- docs/api.md | 31 +++++--- frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 23 ++++-- frontend/sw.js | 2 +- frontend/tests/esim.test.js | 16 +++- pysim_simple_server/esim.py | 72 +++++++++++++---- pysim_simple_server/server.py | 129 +++++++++++++++++++++++------- tests/test_esim.py | 144 +++++++++++++++++++++++++++++----- 11 files changed, 338 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 2270a6c..fbb12e0 100644 --- a/README.md +++ b/README.md @@ -598,7 +598,7 @@ Values persist on the server until restart. Apply → hex updates; Save → POST **Network state monitor** — a compact **Network state** panel next to the simulation buttons shows what the card currently holds and what was last simulated. Its header carries the **simulated service state** (*Undefined* until a scenario or a Location status event sets it, then *Normal service* / *Limited service* / *No service*) with a *PLMN not allowed* marker when the location files or EF.FPLMN show a rejection, plus the current location: PLMN, country and operator, the LAI/RAI/TAI, and the **roaming class** (*Home* when the PLMN equals the HPLMN, *Home equivalent* when it is in EF.EHPLMN, otherwise *Guest*). Below it, one compact line per monitored file (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) with its decoded summary and how it was last updated (`init`, `write`, `read`, `refresh`); hover for the full decoded fields — long PLMN lists are abbreviated (EF.HPLMNwAcT shows only the first network plus a `… +N` counter). The panel reads the files once at equip (only when the ICCID was readable), updates them in place from the bytes the simulator wrote, re-reads EF.IMSI after every scenario and Location-status event, and never polls the card — **Refresh** re-reads all files on demand. -**eSIM** — for an eUICC (SGP.22/SGP.32) the **eSIM** pill reads the chip and manages the installed profiles through the local ES10 interface (via pySim, no SM-DP+ contact): **Chip** (EID, EUICCInfo1/2 with decoded capability bit lists, card resources, CI PKI identifiers, category, forbidden PPRs, certification data; configured default SM-DP+ / root DS addresses; the rules authorisation table from ES10b GetRat), **Profiles** (state, nickname, provider, ICCID, ISD-P AID, class, owner; **Enable**/**Disable** switches a profile — the card usually sends REFRESH first and the card session is then re-initialized like an equip, so the ICCID, network state and every cached card view are re-read) and **Notifications** (read-only pending list). No profile downloads, no notification handling and no SM-DP+ interaction — only the local ES10a/b/c functions are used; a non-eUICC card is reported as such. +**eSIM** — for an eUICC (SGP.22/SGP.32) the **eSIM** pill reads the chip and manages the installed profiles through the local ES10 interface (via pySim, no SM-DP+ contact): **Chip** (EID, EUICCInfo1/2 with decoded capability bit lists, card resources, CI PKI identifiers, category, forbidden PPRs, certification data; configured default SM-DP+ / root DS addresses; the rules authorisation table from ES10b GetRat), **Profiles** (state, nickname, provider, ICCID, ISD-P AID, class, owner; **Enable**/**Disable** switches a profile — the card usually sends REFRESH first and the card session is then re-initialized like an equip, so the ICCID, network state and every cached card view are re-read, and the requested state is verified against the re-read profile list) and **Notifications** (read-only pending list). No profile downloads, no notification handling and no SM-DP+ interaction — only the local ES10a/b/c functions are used; a non-eUICC card is reported as such. ## SCP81 diff --git a/README_RUS.md b/README_RUS.md index b146674..4ebfd38 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -569,7 +569,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на **Монитор сетевого состояния** — компактная панель **«Сетевое состояние»** рядом с кнопками симуляции показывает, что сейчас хранит карта и что было сэмулировано последним. В заголовке — **сэмулированное состояние сервиса** (*Не определено*, пока его не задаст сценарий или событие Location status, затем *Обычный сервис* / *Ограниченный сервис* / *Нет сервиса*) с пометкой *PLMN не разрешён*, если location-файлы или EF.FPLMN указывают на отказ, плюс текущее местоположение: PLMN, страна и оператор, LAI/RAI/TAI и **класс роуминга** (*Домашняя сеть*, если PLMN совпадает с HPLMN; *Эквивалентная домашней*, если он есть в EF.EHPLMN; иначе *Гостевая (роуминг)*). Ниже — по одной компактной строке на контролируемый файл (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) с декодированной сводкой и признаком последнего обновления (`init`, `write`, `read`, `refresh`); при наведении — все декодированные поля; длинные списки PLMN сокращаются (EF.HPLMNwAcT показывает только первую сеть и пометку `… +N`). Панель читает файлы один раз при подключении карты (только если ICCID читается), обновляет их на месте по записанным симулятором байтам, перечитывает EF.IMSI после каждого сценария и события Location status и никогда не опрашивает карту — кнопка **«Обновить»** перечитывает все файлы по требованию. -**eSIM** — для eUICC (SGP.22/SGP.32) подвкладка **«eSIM»** читает чип и управляет установленными профилями через локальный интерфейс ES10 (через pySim, без обращения к SM-DP+): **Chip** (EID, EUICCInfo1/2 с декодированными битовыми списками возможностей, ресурсами карты, идентификаторами CI PKI, категорией, запрещёнными PPR, данными сертификации; настроенные адреса SM-DP+ по умолчанию / корневого DS; таблица авторизации правил из ES10b GetRat), **Profiles** (состояние, nickname, провайдер, ICCID, AID ISD-P, класс, владелец; **Enable**/**Disable** переключает профиль — карта обычно сначала присылает REFRESH, после чего сессия карты переинициализируется как при equip, и ICCID, сетевое состояние и все кэшированные представления перечитываются) и **Notifications** (список ожидающих уведомлений, только чтение). Никаких загрузок профилей, обработки уведомлений и взаимодействия с SM-DP+ — используются только локальные функции ES10a/b/c; для карты не-eUICC подвкладка сообщает об этом. +**eSIM** — для eUICC (SGP.22/SGP.32) подвкладка **«eSIM»** читает чип и управляет установленными профилями через локальный интерфейс ES10 (через pySim, без обращения к SM-DP+): **Chip** (EID, EUICCInfo1/2 с декодированными битовыми списками возможностей, ресурсами карты, идентификаторами CI PKI, категорией, запрещёнными PPR, данными сертификации; настроенные адреса SM-DP+ по умолчанию / корневого DS; таблица авторизации правил из ES10b GetRat), **Profiles** (состояние, nickname, провайдер, ICCID, AID ISD-P, класс, владелец; **Enable**/**Disable** переключает профиль — карта обычно сначала присылает REFRESH, после чего сессия карты переинициализируется как при equip, и ICCID, сетевое состояние и все кэшированные представления перечитываются, а запрошенное состояние проверяется по перечитанному списку профилей) и **Notifications** (список ожидающих уведомлений, только чтение). Никаких загрузок профилей, обработки уведомлений и взаимодействия с SM-DP+ — используются только локальные функции ES10a/b/c; для карты не-eUICC подвкладка сообщает об этом. ## SCP81 diff --git a/docs/api.md b/docs/api.md index 0ad4e56..d1d7fea 100644 --- a/docs/api.md +++ b/docs/api.md @@ -230,16 +230,29 @@ lock; `GET /api/status` reports `euicc` and `eid` for the PWA. "operations": ["enable"], "address": "smdp.example.org", "iccid": "8970…"}], "error": null}`. - `POST /api/esim/profile` — `{"action": "enable"|"disable", "iccid"?: …, - "isdp_aid"?: …, "refresh"?: true}` (one identifier required). The card - usually answers with a REFRESH proactive command first (logged in - `/api/proactive-log`); after a successful switch — or whenever a REFRESH - was seen — the server re-initializes the card like an equip (reset, - re-read ICCID/network state, new `card_session`) and returns - `{"ok": true, "result": "ok", "refresh_seen": true, "reinitialized": true, - "iccid": …, "card_session": N}`. Failures carry the ES10c result code - (`iccidOrAidNotFound`, `profileNotInDisabledState`, + "isdp_aid"?: …, "refresh"?: true}` (one identifier required). 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` answer is that OK, so the server + answers the REFRESH proactive command (logged in `/api/proactive-log`), + **never retries the STORE DATA** (the mid-switch card answers `6985`) and + re-initializes the card like an equip (physical reset, re-read + ICCID/network state, new `card_session`). It then re-reads the profile + list and reports the verified result: + + ```json + {"ok": true, "result": "ok", "refresh_seen": true, "reinitialized": true, + "verified": true, "state_after": "disabled", "iccid": "8970…", + "card_session": 7} + ``` + + `verified` is `false` when the target profile is not in the requested state + after the re-init (`null` when the state could not be read). Failures carry + the ES10c result code (`iccidOrAidNotFound`, `profileNotInDisabledState`, `profileNotInEnabledState`, `disallowedByPolicy`, `wrongProfileReenabling`, - `catBusy`, `undefinedError`) and a short `message`. + `catBusy`, `undefinedError`) and a short `message`; an unexpected status + word is returned as `{"ok": false, "result": "undefinedError", "sw": "6985", + "message": "SW 6985"}`. ### `POST /api/help` diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 1ce8d43..4813661 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -498,7 +498,7 @@

Для eUICC (SGP.22/SGP.32) подвкладка «eSIM» читает чип и управляет установленными профилями через локальный интерфейс ES10 (через pySim, без обращения к SM-DP+):

Никаких загрузок профилей, обработки уведомлений и взаимодействия с SM-DP+ — используются только локальные функции ES10a/b/c. Если карта не eUICC, подвкладка сообщает об этом.

diff --git a/frontend/help.html b/frontend/help.html index a5c9e67..cdb8b1a 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -498,7 +498,7 @@

For an eUICC (SGP.22/SGP.32) the eSIM pill reads the chip and manages the installed profiles through the local ES10 interface (via pySim, no SM-DP+ contact):

No profile downloads, no notification handling and no SM-DP+ interaction — only the local ES10a/b/c functions are used. The card must be an eUICC; otherwise the pill says so.

diff --git a/frontend/index.html b/frontend/index.html index 1a7f1d2..8546af4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1612,6 +1612,20 @@ function esimResultText(res) { return t(ESIM_RESULT_KEYS[res.result] || res.message || res.result || 'Error'); } +// Status text after an Enable/Disable switch: the card result, the re-init and +// the verified resulting profile state. +function esimSwitchStatus(res) { + if (!res || !res.ok) return t('Profile switch failed') + ': ' + esimResultText(res); + let msg = t('Profile switched'); + if (res.reinitialized) msg += ' — ' + t('card re-initialized'); + if (res.refresh_seen) msg += ' (' + t('REFRESH received') + ')'; + if (res.verified === false) { + msg += ' — ' + t('not confirmed'); + if (res.state_after) msg += ' (' + esimStateLabel(res.state_after) + ')'; + } + return msg; +} + function esimStateLabel(state) { if (state === 'enabled') return t('Enabled'); if (state === 'disabled') return t('Disabled'); @@ -1757,13 +1771,7 @@ async function esimSetProfile(iccid, isdpAid, action) { if (iccid) body.iccid = iccid; if (isdpAid) body.isdp_aid = isdpAid; const res = await pysimFetch('/api/esim/profile', body); - if (res && res.ok) { - if (statusEl) statusEl.textContent = t('Profile switched') + - (res.reinitialized ? ' — ' + t('card re-initialized') : '') + - (res.refresh_seen ? ' (' + t('REFRESH received') + ')' : ''); - } else if (statusEl) { - statusEl.textContent = t('Profile switch failed') + ': ' + esimResultText(res); - } + if (statusEl) statusEl.textContent = esimSwitchStatus(res); await esimFetchAll(); } catch (e) { if (statusEl) statusEl.textContent = t('Error') + ': ' + e.message; @@ -13687,6 +13695,7 @@ const LANG_RU = { 'Profile switched': 'Профиль переключён', 'card re-initialized': 'карта переинициализирована', 'REFRESH received': 'получен REFRESH', + 'not confirmed': 'не подтверждено', 'Profile switch failed': 'Не удалось переключить профиль', 'No commands logged yet.': 'Команды пока не регистрировались.', 'Send event notification to the card?': 'Отправить уведомление о событии на карту?', diff --git a/frontend/sw.js b/frontend/sw.js index 172223a..8cc14d1 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v229'; +const CACHE = 'simple-v230'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/esim.test.js b/frontend/tests/esim.test.js index 2e21522..b834ee9 100644 --- a/frontend/tests/esim.test.js +++ b/frontend/tests/esim.test.js @@ -23,7 +23,7 @@ function extractFunc(src, name) { let code = ''; for (const fn of ['esimLabel', 'esimGroupEid', 'esimFieldRows', 'esimResultText', - 'esimStateLabel', 'esimOperationsText', 'esimProfileRows']) { + 'esimStateLabel', 'esimOperationsText', 'esimProfileRows', 'esimSwitchStatus']) { code += extractFunc(html, fn) + '\n'; } for (const c of ['ESIM_CHIP_LABELS', 'ESIM_RESULT_KEYS']) { @@ -98,6 +98,20 @@ test('esimResultText localizes result codes and passes errors through', () => { assert.strictEqual(esimResultText(null), ''); }); +test('esimSwitchStatus reports the switch, re-init, REFRESH and verification', () => { + assert.strictEqual(esimSwitchStatus({ ok: true }), 'Profile switched'); + assert.strictEqual( + esimSwitchStatus({ ok: true, reinitialized: true, refresh_seen: true }), + 'Profile switched — card re-initialized (REFRESH received)'); + assert.strictEqual( + esimSwitchStatus({ ok: true, reinitialized: true, verified: false, state_after: 'disabled' }), + 'Profile switched — card re-initialized — not confirmed (Disabled)'); + assert.strictEqual(esimSwitchStatus({ ok: false, result: 'catBusy' }), + 'Profile switch failed: card is busy with a CAT session'); + assert.strictEqual(esimSwitchStatus({ ok: false, error: 'SW 6985' }), + 'Profile switch failed: SW 6985'); +}); + test('esimStateLabel and esimOperationsText map the profile metadata', () => { assert.strictEqual(esimStateLabel('enabled'), 'Enabled'); assert.strictEqual(esimStateLabel('disabled'), 'Disabled'); diff --git a/pysim_simple_server/esim.py b/pysim_simple_server/esim.py index f2fc24c..11a86b4 100644 --- a/pysim_simple_server/esim.py +++ b/pysim_simple_server/esim.py @@ -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} diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index f2ef630..6c931ac 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -255,20 +255,34 @@ def _parse_help_text(text): 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() + try: + 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() + except Exception: + # No usable FCP (an ADF, or a failed select while the card has no + # active profile) - report no file type instead of crashing. + return None return None +def _fcp_value(lchan, name): + """One FCP-derived selection value, or None when the card delivered no + usable FCP (an ADF, or a failed select) - never raises.""" + try: + return getattr(lchan, name)() + except Exception: + return None + + def _fid4(sel): """True if sel is a 4-digit hex FID.""" return bool(re.fullmatch(r'[0-9a-fA-F]{4}', str(sel or ''))) @@ -2589,6 +2603,52 @@ def _esim_reinit(server): server.equipping = False +def _esim_refresh_chain(server, sw91): + """Answer the proactive command(s) that accompany a profile switch. + + With the refresh flag set the card sends REFRESH (SGP.22 §5.7.16 step 7); + it is answered and the chain stops - the card reset that follows performs + the switch. No STATUS poll is sent: the card is mid-switch and refuses + further commands (6985) until the reset.""" + seen = {'refresh': False} + + def on_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst): + if cmd_type == 0x01: + seen['refresh'] = True + return 'exit' + return None + + _handle_proactive_chain(server.scc, sw91, on_fetch=on_fetch, + status_poll=False) + return seen['refresh'] + + +def _norm_iccid(value): + """ICCID comparison form: hex digits without the trailing F pad.""" + s = re.sub(r'[^0-9a-fA-F]', '', str(value or '')).upper() + return s[:-1] if s.endswith('F') else s + + +def _esim_verify_switch(app, action, iccid=None, isdp_aid=None): + """Re-read the profile list and check the requested state took effect.""" + expected = 'enabled' if action == 'enable' else 'disabled' + try: + profs = esim.profiles(app).get('profiles') or [] + except Exception as e: + sys.stderr.write('ESIM: state verification failed: %s\n' % e) + return {'verified': None, 'state_after': None} + want_iccid = _norm_iccid(iccid) if iccid else None + want_aid = re.sub(r'[^0-9a-fA-F]', '', str(isdp_aid or '')).upper() or None + state_after = None + for p in profs: + if (want_iccid and _norm_iccid(p.get('iccid')) == want_iccid) or \ + (want_aid and re.sub(r'[^0-9a-fA-F]', '', + str(p.get('isdp_aid') or '')).upper() == want_aid): + state_after = p.get('state') + break + return {'verified': state_after == expected, 'state_after': state_after} + + _AUTO_EQUIP = True _AUTO_EQUIP_BUSY = False @@ -3112,18 +3172,20 @@ def _parse_setup_menu_items(raw): return items -def _handle_proactive_chain(scc, sw91, on_fetch=None): +def _handle_proactive_chain(scc, sw91, on_fetch=None, status_poll=True): """Run a FETCH/TERMINAL RESPONSE chain; marks the card as busy so that - terminal-initiated ENVELOPEs (Data available, Channel status, timers) wait.""" + terminal-initiated ENVELOPEs (Data available, Channel status, timers) wait. + ``status_poll=False`` skips the trailing STATUS (used by the profile + switch, where the card is mid-switch and must not be queried further).""" global _PROACTIVE_BUSY _PROACTIVE_BUSY = True try: - return _run_proactive_chain(scc, sw91, on_fetch) + return _run_proactive_chain(scc, sw91, on_fetch, status_poll) finally: _PROACTIVE_BUSY = False -def _run_proactive_chain(scc, sw91, on_fetch=None): +def _run_proactive_chain(scc, sw91, on_fetch=None, status_poll=True): sys.stderr.write('91XX chain: sw=%s\n' % sw91) sw = sw91 paused = False @@ -3155,7 +3217,7 @@ def _run_proactive_chain(scc, sw91, on_fetch=None): 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': + if sw == '9000' and status_poll: sys.stderr.write('STATUS poll (chain ended)\n') st_data, st_sw = _send_status(scc) sys.stderr.write('STATUS -> %s\n' % st_sw) @@ -3508,9 +3570,9 @@ class PysimHandler(BaseHTTPRequestHandler): '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, + 'file_size': _fcp_value(lchan, 'selected_file_size'), + 'record_len': _fcp_value(lchan, 'selected_file_record_len'), + 'num_of_rec': _fcp_value(lchan, 'selected_file_num_of_rec'), } if cur_file else None, 'channels': [str(i) for i, ch in rs.lchan.items() if ch] if rs else [], } @@ -3798,24 +3860,33 @@ class PysimHandler(BaseHTTPRequestHandler): with _CARD_LOCK: _finish_pending_menu(self.server, self.server.scc) cursor = _PROACTIVE_ENTRY_ID - resp = esim.set_profile_state( - app, action, - iccid=body.get('iccid'), isdp_aid=body.get('isdp_aid'), + resp = esim.switch_profile( + self.server.scc._tp.send_apdu, + lambda sw: _esim_refresh_chain(self.server, sw), + action, iccid=body.get('iccid'), + isdp_aid=body.get('isdp_aid'), refresh=body.get('refresh', True)) # A REFRESH during the command means the card wants the - # terminal to re-initialize; a lost STORE DATA response - # (T=0 after REFRESH) is covered by the same re-init. - resp['refresh_seen'] = any( + # terminal to re-initialize; the switch itself completes on + # the TERMINAL RESPONSE or the reset (SGP.22 5.7.16 step 8). + resp['refresh_seen'] = resp.get('refresh_seen') or any( e.get('type_hex') == '01' and e.get('id', 0) > cursor for e in _PROACTIVE_LOG) resp['reinitialized'] = False + resp['verified'] = None + resp['state_after'] = None if resp['ok'] or resp['refresh_seen']: resp['reinitialized'] = _esim_reinit(self.server) + if resp['reinitialized']: + resp.update(_esim_verify_switch( + app, action, iccid=body.get('iccid'), + isdp_aid=body.get('isdp_aid'))) resp['iccid'] = getattr(self.server, 'iccid', None) resp['card_session'] = getattr(self.server, 'card_session', None) self._send_json(resp) self._log_resp({k: resp.get(k) for k in - ('ok', 'result', 'refresh_seen', 'reinitialized')}) + ('ok', 'result', 'refresh_seen', 'reinitialized', + 'verified', 'state_after')}) except esim.EsimError as e: resp = {'ok': False, 'error': e.code, 'message': str(e)} self._send_json(resp, 400) @@ -4005,9 +4076,9 @@ class PysimHandler(BaseHTTPRequestHandler): '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, + 'file_size': _fcp_value(lchan, 'selected_file_size'), + 'record_len': _fcp_value(lchan, 'selected_file_record_len'), + 'num_of_rec': _fcp_value(lchan, 'selected_file_num_of_rec'), 'fci_hex': (lchan.selected_file_fcp_hex or '').upper() if lchan and lchan.selected_file_fcp_hex else None, 'apdu_times': apdu_times, 'exists': True, diff --git a/tests/test_esim.py b/tests/test_esim.py index 77af965..6cb36c4 100644 --- a/tests/test_esim.py +++ b/tests/test_esim.py @@ -171,32 +171,73 @@ class EsimTests(unittest.TestCase): self.assertEqual(out['rat'], [{'ppr_ids': ['ppr1', 'ppr2'], 'allowed_operators': [], 'ppr_flags': []}]) - def test_set_profile_state_enable_uses_iccid_and_refresh(self): - app, _ = make_app() - self.patch([EnableProfileResp(children=[EnableResult(decoded='ok')])]) - out = esim.set_profile_state(app, 'enable', iccid='8970119000004002667') - self.assertTrue(out['ok']) - self.assertEqual(out['result'], 'ok') - tlv = self.calls[0].to_tlv().hex().upper() - self.assertIn('5A0A980711090000042066F7', tlv) # ProfileIdentifier/ICCID - self.assertTrue(tlv.endswith('810101')) # RefreshFlag = 1 + def test_build_switch_apdu_encodes_iccid_and_refresh(self): + apdu = esim.build_switch_apdu('enable', iccid='8970119000004002667') + self.assertTrue(apdu.startswith('80E29100')) + self.assertTrue(apdu.endswith('00')) + self.assertIn('5A0A980711090000042066F7', apdu) # ProfileIdentifier/ICCID + self.assertIn('810101', apdu) # RefreshFlag = 1 + apdu = esim.build_switch_apdu( + 'disable', isdp_aid='A0000005591010FFFFFFFF8900000100', refresh=False) + self.assertIn('4F10A0000005591010FFFFFFFF8900000100', apdu) + self.assertIn('810100', apdu) # RefreshFlag = 0 - def test_set_profile_state_maps_cat_busy(self): - app, _ = make_app() - self.patch([DisableProfileResp(children=[DisableResult(decoded='catBusy')])]) - out = esim.set_profile_state(app, 'disable', iccid='8970119000004002667') + def test_build_switch_apdu_validation(self): + with self.assertRaises(esim.EsimError): + esim.build_switch_apdu('delete', iccid='8970119000004002667') + with self.assertRaises(esim.EsimError): + esim.build_switch_apdu('enable') + with self.assertRaises(esim.EsimError): + esim.build_switch_apdu('enable', iccid='abc') + + def test_parse_switch_response_maps_result_codes(self): + resp = DisableProfileResp(children=[DisableResult(decoded='catBusy')]) + out = esim.parse_switch_response('disable', resp.to_tlv().hex()) self.assertFalse(out['ok']) self.assertEqual(out['result'], 'catBusy') self.assertIn('busy', out['message']) + out = esim.parse_switch_response('disable', '') + self.assertFalse(out['ok']) + self.assertEqual(out['result'], 'undefinedError') - def test_set_profile_state_validation(self): - app, _ = make_app() - with self.assertRaises(esim.EsimError): - esim.set_profile_state(app, 'delete', iccid='8970119000004002667') - with self.assertRaises(esim.EsimError): - esim.set_profile_state(app, 'enable') - with self.assertRaises(esim.EsimError): - esim.set_profile_state(app, 'enable', iccid='abc') + def test_switch_profile_parses_ok_response(self): + sent = [] + resp = EnableProfileResp(children=[EnableResult(decoded='ok')]) + out = esim.switch_profile( + lambda apdu: sent.append(apdu) or (resp.to_tlv().hex(), '9000'), + lambda sw: self.fail('no chain expected'), + 'enable', iccid='8970119000004002667') + self.assertTrue(out['ok']) + self.assertEqual(out['result'], 'ok') + self.assertFalse(out['refresh_seen']) + self.assertEqual(len(sent), 1) + + def test_switch_profile_treats_91xx_as_ok_and_runs_the_chain(self): + chain = [] + out = esim.switch_profile( + lambda apdu: ('', '9111'), + lambda sw: chain.append(sw) or True, + 'disable', iccid='8970119000004002667') + self.assertTrue(out['ok']) + self.assertEqual(out['result'], 'ok') + self.assertTrue(out['refresh_seen']) + self.assertEqual(chain, ['9111']) + + def test_switch_profile_chain_failure_keeps_the_accepted_switch(self): + def boom(sw): + raise RuntimeError('fetch failed') + out = esim.switch_profile(lambda apdu: ('', '910f'), boom, + 'disable', iccid='8970119000004002667') + self.assertTrue(out['ok']) + self.assertFalse(out['refresh_seen']) + + def test_switch_profile_reports_error_sw(self): + out = esim.switch_profile(lambda apdu: ('', '6985'), + lambda sw: self.fail('no chain expected'), + 'disable', iccid='8970119000004002667') + self.assertFalse(out['ok']) + self.assertEqual(out['sw'], '6985') + self.assertEqual(out['result'], 'undefinedError') def test_select_isdr_requires_an_euicc(self): app, _ = make_app(isdr=False) @@ -296,6 +337,67 @@ class EsimInfoDecodeTests(unittest.TestCase): self.assertEqual(out['root_ds_address'], 'testrootsmds.gsma.com') +class SelectionMetadataTests(unittest.TestCase): + """Status/select FCP metadata must never crash when the card has no usable + FCP (an ADF selected, or a failed select while the active profile is + disabled).""" + + class FakeLchan: + def __init__(self, fcp): + self.selected_file_fcp = fcp + + def selected_file_size(self): + return self.selected_file_fcp.get('file_size') + + def selected_file_record_len(self): + return self.selected_file_fcp['file_descriptor'].get('record_len') + + def selected_file_num_of_rec(self): + return self.selected_file_fcp['file_descriptor'].get('num_of_rec') + + def selected_file_structure(self): + return self.selected_file_fcp['file_descriptor']['file_descriptor_byte']['structure'] + + def selected_file_type(self): + return self.selected_file_fcp['file_descriptor']['file_descriptor_byte'].get('file_type', 'ef') + + def test_missing_fcp_yields_none(self): + from pysim_simple_server import server + lchan = self.FakeLchan(None) + self.assertIsNone(server._fcp_value(lchan, 'selected_file_size')) + self.assertIsNone(server._fcp_value(lchan, 'selected_file_record_len')) + self.assertIsNone(server._fcp_value(lchan, 'selected_file_num_of_rec')) + # an EF without FCP falls back to 'transparent' (the historical default) + self.assertEqual(server._get_file_type(lchan, SimpleNamespace(name='EF.ICCID')), + 'transparent') + + def test_adf_fcp_without_file_descriptor_yields_none(self): + from pysim_simple_server import server + lchan = self.FakeLchan({'file_size': None}) + self.assertIsNone(server._fcp_value(lchan, 'selected_file_record_len')) + self.assertIsNone(server._fcp_value(lchan, 'selected_file_num_of_rec')) + self.assertIsNone(server._get_file_type(lchan, SimpleNamespace(name='EF.ICCID'))) + + def test_usable_fcp_reports_values(self): + from pysim_simple_server import server + fcp = {'file_size': 10, + 'file_descriptor': {'record_len': 5, 'num_of_rec': 2, + 'file_descriptor_byte': {'structure': 'linear_fixed', + 'file_type': 'ef'}}} + lchan = self.FakeLchan(fcp) + self.assertEqual(server._fcp_value(lchan, 'selected_file_size'), 10) + self.assertEqual(server._fcp_value(lchan, 'selected_file_record_len'), 5) + self.assertEqual(server._get_file_type(lchan, SimpleNamespace(name='EF.ADN')), + 'linear_fixed') + + def test_norm_iccid_drops_the_f_pad(self): + from pysim_simple_server import server + self.assertEqual(server._norm_iccid('8970119000004002667'), + '8970119000004002667') + self.assertEqual(server._norm_iccid('98 90 71 11 90 00 00 40 02 66 7F'), + '989071119000004002667') + + class EsimRoutingTests(unittest.TestCase): def test_esim_routes_are_in_the_right_http_handlers(self): from pysim_simple_server import server