diff --git a/README.md b/README.md index 6cd5052..2270a6c 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, configured default SM-DP+ / root DS addresses), **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 **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 b4d1925..b146674 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, настроенные адреса SM-DP+ / root DS), **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 cf31966..0ad4e56 100644 --- a/docs/api.md +++ b/docs/api.md @@ -180,10 +180,48 @@ eUICC"}`. They use pySim's ES10 static API — no lpac, no SM-DP+ contact, no profile downloads and no notification processing. All run under the card lock; `GET /api/status` reports `euicc` and `eid` for the PWA. -- `GET /api/esim/chip` — `{"eid": "8904…", "info1": {…}, "info2": {…}, +- `GET /api/esim/chip` — `{"eid": "8908…", "info1": {…}, "info2": {…}, "addresses": {"default_dp_address": …, "root_ds_address": …}, - "errors": {"": ""}}` (a part the card does not support is - reported in `errors` instead of failing the whole request). + "rat": [{…}], "errors": {"": ""}}` (a part the card does not + support is reported in `errors` instead of failing the whole request). + The EUICCInfo TLVs are requested raw and decoded per SGP.22 v2.6 §5.7.8 + (pySim's classes are incomplete), `rat` comes from the ES10b GetRat command + (§5.7.13) and undecoded TLVs are preserved in a `raw_tlvs` map: + + ```json + {"eid": "89086030202200000026000024920451", + "info1": {"svn": "2.2.2", + "euicc_ci_pki_list_for_verification": ["8137…FB"], + "euicc_ci_pki_list_for_signing": ["8137…FB"]}, + "info2": {"profile_version": "2.3.1", "svn": "2.2.2", + "euicc_firmware_ver": "4.2.0", + "ext_card_resource": {"installed_application": 0, + "free_non_volatile_memory": 439084, + "free_volatile_memory": 9798}, + "uicc_capability": ["usimSupport", "isimSupport", "…"], + "ts102241_version": "9.2.0", + "globalplatform_version": "2.3.0", + "rsp_capability": ["additionalProfile", "testProfileSupport"], + "euicc_category": "other", + "forbidden_profile_policy_rules": ["ppr1"], + "pp_version": "1.0.0", + "ss_acreditation_number": "ED-ZI-UP-0826"}, + "addresses": {"default_dp_address": null, + "root_ds_address": "testrootsmds.gsma.com"}, + "rat": [{"ppr_ids": ["ppr1", "ppr2"], + "allowed_operators": [{"plmn": "EEEEEE", "gid1": null, "gid2": null}], + "ppr_flags": ["consentRequired"]}], + "errors": {}} + ``` + + `uicc_capability`, `rsp_capability`, `forbidden_profile_policy_rules` and + `ppr_flags` are ASN.1 BIT STRINGs decoded to the names of the set bits (the + first content octet is the unused-bit count, bits are MSB-first); + `euicc_category` accepts both the implicit (`0x8B`) and explicit (`0xAB`) + tag encodings; `pp_version` (`0x04`) and `ss_acreditation_number` (`0x0C`) + are the bare, untagged SGP.22 types; `tre_properties`, + `tre_product_reference`, `additional_euicc_profile_package_versions` and + `certification_data_object` are decoded when a card sends them. - `GET /api/esim/profiles` — `{"profiles": [{"iccid": "8970…", "isdp_aid": "A000…", "state": "enabled"|"disabled", "nickname": …, "provider": …, "name": …, "class": "test"|"provisioning"|"operational", diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 3ccebe1..1ce8d43 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -497,7 +497,7 @@

8.8 eSIM / LPA (локальные операции)

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

    -
  • Чип — EID, EUICCInfo1/2 (версия профиля, SVN, прошивка, версия GlobalPlatform, возможности RSP, категория, …) и настроенные адреса SM-DP+ по умолчанию / корневого DS.
  • +
  • Чип — EID, EUICCInfo1/2 со всеми декодированными полями (версия профиля, SVN, прошивка, расширенные ресурсы карты — число установленных приложений и свободная энергонезависимая/энергозависимая память —, битовые списки возможностей UICC и RSP, идентификаторы CI PKI, категория, запрещённые PPR, версия PP, номер аккредитации SS, данные сертификации), настроенные адреса SM-DP+ по умолчанию / корневого DS и таблица авторизации правил (ES10b GetRat: идентификаторы PPR, разрешённые операторы, флаг согласия). Неизвестные TLV сохраняются как сырой hex.
  • Профили — все профили с состоянием (включён/отключён), ником, оператором, именем, ICCID, ISD-P AID, классом, владельцем и типом значка. Кнопки «Включить»/«Отключить» переключают профиль; карта обычно сначала отправляет REFRESH (виден в журнале проактивных команд), а после переключения сессия карты переинициализируется как при equip — ICCID, сетевое состояние и все кэшированные данные перечитываются.
  • Уведомления — список ожидающих уведомлений только для чтения (номер последовательности, операция, адрес, ICCID); ничего не обрабатывается и не удаляется.
diff --git a/frontend/help.html b/frontend/help.html index 4666d42..a5c9e67 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -497,7 +497,7 @@

8.8 eSIM / LPA (local operations)

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 (profile version, SVN, firmware, GlobalPlatform version, RSP capabilities, category, …) and the configured default SM-DP+ / root DS addresses.
  • +
  • Chip — EID, EUICCInfo1/2 with every field decoded (profile version, SVN, firmware, extended card resource — installed applications and free non-volatile/volatile memory —, UICC and RSP capability bit lists, CI PKI identifiers, category, forbidden profile policy rules, PP version, SS accreditation number, certification data), the configured default SM-DP+ / root DS addresses, and the rules authorisation table (ES10b GetRat: PPR IDs, allowed operators, consent flag). Unknown TLVs are preserved as raw hex.
  • Profiles — every profile with its state (enabled/disabled), nickname, provider, name, ICCID, ISD-P AID, class, owner and icon type. Enable/Disable switches a profile; the card usually sends REFRESH first (logged in the proactive command log) and after the switch the card session is re-initialized like an equip, so the ICCID, network state and every cached card view are re-read.
  • Notifications — read-only list of pending notifications (sequence number, operation, address, ICCID); nothing is processed or removed.
diff --git a/frontend/index.html b/frontend/index.html index 78d2181..1a7f1d2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1533,13 +1533,25 @@ let _esimNotifications = []; const ESIM_CHIP_LABELS = { eid: 'EID', svn: 'SVN', profile_version: 'Profile version', euicc_firmware_ver: 'Firmware version', ext_card_resource: 'Card resource', + installed_application: 'Installed applications', + free_non_volatile_memory: 'Free non-volatile memory', + free_volatile_memory: 'Free volatile memory', uicc_capability: 'UICC capability', ts102241_version: 'TS 102 241 version', globalplatform_version: 'GlobalPlatform version', rsp_capability: 'RSP capability', - euicc_category: 'Category', pp_version: 'PP version', - ss_acreditation_number: 'SS accreditation number', + euicc_category: 'Category', forbidden_profile_policy_rules: 'Forbidden PPRs', + pp_version: 'PP version', ss_acreditation_number: 'SS accreditation number', + certification_data_object: 'Certification data', + platform_label: 'Platform label', discovery_base_url: 'Discovery base URL', + tre_properties: 'TRE properties', tre_product_reference: 'TRE product reference', + additional_euicc_profile_package_versions: 'Additional profile package versions', + raw_tlvs: 'Raw TLVs', default_dp_address: 'Default SM-DP+ address', root_ds_address: 'Root DS address', euicc_ci_pki_list_for_verification: 'CI PKI (verification)', euicc_ci_pki_list_for_signing: 'CI PKI (signing)', + subject_key_identifier: 'Subject key identifier', + rat: 'Rules authorisation table', ppr_ids: 'PPR IDs', + allowed_operators: 'Allowed operators', ppr_flags: 'PPR flags', + plmn: 'PLMN', gid1: 'GID1', gid2: 'GID2', iot_specific_info: 'IoT specific info', }; @@ -1563,25 +1575,32 @@ function esimGroupEid(eid) { return s.replace(/(.{4})(?=.)/g, '$1 '); } -// Chip-info value -> [label, value] rows; nested dicts recurse, arrays are -// joined, empty values skipped. +// Chip-info value -> [label, value] rows; nested dicts recurse, arrays of +// scalars are joined, arrays of objects recurse with an index label, empty +// values skipped. Labels are mapped and translated per path component. function esimFieldRows(obj, prefix) { const rows = []; if (obj === null || obj === undefined) return rows; if (typeof obj !== 'object') { - rows.push([prefix ? esimLabel(prefix) : '', String(obj)]); + rows.push([prefix || '', String(obj)]); return rows; } for (const k of Object.keys(obj)) { const v = obj[k]; if (v === null || v === undefined || v === '') continue; - const label = prefix ? prefix + ' / ' + k : k; + const label = prefix ? prefix + ' / ' + t(esimLabel(k)) : t(esimLabel(k)); if (Array.isArray(v)) { - rows.push([esimLabel(label), v.map(x => (typeof x === 'object' ? JSON.stringify(x) : String(x))).join(', ')]); + if (v.length && typeof v[0] === 'object') { + v.forEach((item, i) => { + rows.push(...esimFieldRows(item, label + ' ' + (i + 1))); + }); + } else { + rows.push([label, v.map(x => String(x)).join(', ')]); + } } else if (typeof v === 'object') { rows.push(...esimFieldRows(v, label)); } else { - rows.push([esimLabel(label), String(v)]); + rows.push([label, String(v)]); } } return rows; @@ -1621,11 +1640,27 @@ function esimRenderChip() { const c = _esimChip || {}; const rows = []; if (c.eid) rows.push(['EID', esimGroupEid(c.eid)]); - rows.push(...esimFieldRows(c.info1 || {}, '')); - rows.push(...esimFieldRows(c.info2 || {}, '')); - rows.push(...esimFieldRows(c.addresses || {}, '')); + const sections = [ + ['EUICCInfo1', c.info1], ['EUICCInfo2', c.info2], + ['Addresses', c.addresses], ['Rules authorisation table', c.rat], + ]; + for (const [name, data] of sections) { + if (!data || (Array.isArray(data) && !data.length)) continue; + rows.push(['§ ' + t(name), '']); + if (Array.isArray(data)) { + data.forEach((item, i) => { + rows.push(...esimFieldRows(item, t('Rule') + ' ' + (i + 1))); + }); + } else { + rows.push(...esimFieldRows(data, '')); + } + } let html = ''; for (const [k, v] of rows) { + if (k.startsWith('§ ')) { + html += '
' + esc(k.slice(2)) + '
'; + continue; + } html += '
' + esc(k) + '' + '' + esc(v) + '
'; } @@ -13573,6 +13608,42 @@ const LANG_RU = { 'Not available': 'Недоступно', 'No profiles': 'Нет профилей', 'No notifications': 'Нет уведомлений', + 'EID': 'EID', + 'SVN': 'SVN', + 'Profile version': 'Версия профиля', + 'Firmware version': 'Версия прошивки', + 'Card resource': 'Ресурсы карты', + 'Installed applications': 'Установленных приложений', + 'Free non-volatile memory': 'Свободная энергонезависимая память', + 'Free volatile memory': 'Свободная энергозависимая память', + 'UICC capability': 'Возможности UICC', + 'TS 102 241 version': 'Версия TS 102 241', + 'GlobalPlatform version': 'Версия GlobalPlatform', + 'RSP capability': 'Возможности RSP', + 'Category': 'Категория', + 'Forbidden PPRs': 'Запрещённые PPR', + 'PP version': 'Версия PP', + 'SS accreditation number': 'Номер аккредитации SS', + 'Certification data': 'Данные сертификации', + 'Platform label': 'Метка платформы', + 'Discovery base URL': 'Базовый URL Discovery', + 'TRE properties': 'Свойства TRE', + 'TRE product reference': 'Ссылка на продукт TRE', + 'Additional profile package versions': 'Доп. версии пакетов профилей', + 'Raw TLVs': 'Сырые TLV', + 'CI PKI (verification)': 'CI PKI (проверка)', + 'CI PKI (signing)': 'CI PKI (подпись)', + 'Subject key identifier': 'Идентификатор ключа субъекта', + 'Rules authorisation table': 'Таблица авторизации правил (RAT)', + 'Rule': 'Правило', + 'PPR IDs': 'Идентификаторы PPR', + 'Allowed operators': 'Разрешённые операторы', + 'PPR flags': 'Флаги PPR', + 'PLMN': 'PLMN', + 'Addresses': 'Адреса', + 'Default SM-DP+ address': 'Адрес SM-DP+ по умолчанию', + 'Root DS address': 'Адрес корневого DS', + 'IoT specific info': 'IoT-специфичная информация', 'Enabled': 'Включён', 'Disabled': 'Отключён', 'Enable': 'Включить', diff --git a/frontend/sw.js b/frontend/sw.js index 4999a35..172223a 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v228'; +const CACHE = 'simple-v229'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/esim.test.js b/frontend/tests/esim.test.js index a54589a..2e21522 100644 --- a/frontend/tests/esim.test.js +++ b/frontend/tests/esim.test.js @@ -42,6 +42,9 @@ test('esimGroupEid groups the hex digits in fours', () => { test('esimLabel maps known chip keys and prettifies the rest', () => { assert.strictEqual(esimLabel('svn'), 'SVN'); assert.strictEqual(esimLabel('default_dp_address'), 'Default SM-DP+ address'); + assert.strictEqual(esimLabel('installed_application'), 'Installed applications'); + assert.strictEqual(esimLabel('forbidden_profile_policy_rules'), 'Forbidden PPRs'); + assert.strictEqual(esimLabel('allowed_operators'), 'Allowed operators'); assert.strictEqual(esimLabel('some_unknown_key'), 'Some unknown key'); }); @@ -52,12 +55,42 @@ test('esimFieldRows flattens nested values and skips empties', () => { }); assert.deepStrictEqual(rows, [ ['SVN', '1.2.3'], - ['Nested / profile version', '2.2'], + ['Nested / Profile version', '2.2'], ['List', 'a, b'], ['Zero', '0'], ]); }); +test('esimFieldRows labels nested keys per path component', () => { + const rows = esimFieldRows({ + euicc_ci_pki_list_for_verification: { subject_key_identifier: '8137AB' }, + ext_card_resource: { + installed_application: 0, + free_non_volatile_memory: 439084, + }, + }, ''); + assert.deepStrictEqual(rows, [ + ['CI PKI (verification) / Subject key identifier', '8137AB'], + ['Card resource / Installed applications', '0'], + ['Card resource / Free non-volatile memory', '439084'], + ]); +}); + +test('esimFieldRows recurses into arrays of objects with index labels', () => { + const rows = esimFieldRows({ + rat: [{ + ppr_ids: ['ppr1', 'ppr2'], + allowed_operators: [{ plmn: 'EEEEEE', gid1: null, gid2: null }], + ppr_flags: ['consentRequired'], + }], + }, ''); + assert.deepStrictEqual(rows, [ + ['Rules authorisation table 1 / PPR IDs', 'ppr1, ppr2'], + ['Rules authorisation table 1 / Allowed operators 1 / PLMN', 'EEEEEE'], + ['Rules authorisation table 1 / PPR flags', 'consentRequired'], + ]); +}); + test('esimResultText localizes result codes and passes errors through', () => { assert.strictEqual(esimResultText({ result: 'catBusy' }), 'card is busy with a CAT session'); assert.strictEqual(esimResultText({ result: 'ok' }), 'ok'); diff --git a/pysim_simple_server/esim.py b/pysim_simple_server/esim.py index 5adc9f4..f2fc24c 100644 --- a/pysim_simple_server/esim.py +++ b/pysim_simple_server/esim.py @@ -14,7 +14,7 @@ restored afterwards. The caller holds ``_CARD_LOCK``. import re -from osmocom.tlv import flatten_dict_lists +from osmocom.tlv import BER_TLV_IE, bertlv_parse_one_rawtag, flatten_dict_lists from pySim.euicc import ( AID_ISD_R, CardApplicationISDR, DisableProfileReq, DisableProfileResp, EnableProfileReq, EnableProfileResp, EuiccConfiguredAddresses, EuiccInfo1, @@ -128,20 +128,256 @@ def _error_text(result): return RESULT_MESSAGES.get(result, result) +# ---- SGP.22 EUICCInfo decoding ------------------------------------------- +# +# pySim's EuiccInfo1/2 classes are incomplete (the capability fields are raw +# GreedyBytes, several SGP.22 TLVs are missing from the class), so the chip +# endpoint requests the raw TLVs and decodes them here, per SGP.22 v2.6 +# §5.7.8/§5.7.13 and cross-checked against lpac's euicc/es10c_ex.c. Unknown +# TLVs are preserved in `raw_tlvs`, never dropped. + +UICC_CAPABILITY_BITS = [ + 'contactlessSupport', 'usimSupport', 'isimSupport', 'csimSupport', + 'akaMilenage', 'akaCave', 'akaTuak128', 'akaTuak256', 'rfu1', 'rfu2', + 'gbaAuthenUsim', 'gbaAuthenISim', 'mbmsAuthenUsim', 'eapClient', + 'javacard', 'multos', 'multipleUsimSupport', 'multipleIsimSupport', + 'multipleCsimSupport', 'berTlvFileSupport', 'dfLinkSupport', 'catTp', + 'getIdentity', 'profile-a-x25519', 'profile-b-p256', 'suciCalculatorApi', +] +RSP_CAPABILITY_BITS = [ + 'additionalProfile', 'crlSupport', 'rpmSupport', 'testProfileSupport', + 'deviceInfoExtensibilitySupport', 'serviceSpecificDataSupport', +] +PPR_ID_BITS = ['pprUpdateControl', 'ppr1', 'ppr2', 'ppr3'] +PPR_FLAG_BITS = ['consentRequired'] +TRE_PROPERTY_BITS = ['isDiscrete', 'isIntegrated', 'usesRemoteMemory'] +EUICC_CATEGORIES = {0: 'other', 1: 'basicEuicc', 2: 'mediumEuicc', + 3: 'contactlessEuicc'} + + +class _GetRatRequest(BER_TLV_IE, tag=0xbf43): + """ES10b GetRat request (no input data, SGP.22 §5.7.13).""" + + +def _tlvs(data): + """Walk a BER-TLV buffer -> [(tag, value)]; multi-byte tags kept raw.""" + out = [] + rest = bytes(data or b'') + while rest: + tag, _length, value, rest = bertlv_parse_one_rawtag(rest) + out.append((tag, value)) + return out + + +def _tlv_value(data, tag): + """Value bytes of the first `tag` TLV in `data` (b'' when absent).""" + for t, value in _tlvs(data): + if t == tag: + return value + return b'' + + +def _decode_version(data): + """VersionType: major/minor/revision bytes -> 'M.m.r'.""" + if len(data) != 3: + return None + return '%d.%d.%d' % (data[0], data[1], data[2]) + + +def _decode_bit_string(data, names): + """ASN.1 BIT STRING content -> list of set bit names. + + The first octet is the number of unused bits in the final octet; bits are + numbered MSB-first within each octet (SGP.22 v2.6 §5.7.8).""" + if not data: + return [] + unused = data[0] + body = data[1:] + out = [] + for j, byte in enumerate(body): + b = byte + if j == len(body) - 1 and unused: + b &= ~(0xFF >> (8 - unused)) & 0xFF + for i in range(8): + idx = j * 8 + i + if idx >= len(names): + break + if b & 0x80: + out.append(names[idx]) + b = (b << 1) & 0xFF + return out + + +def _decode_ski_list(data): + """SEQUENCE OF SubjectKeyIdentifier -> hex strings.""" + return [value.hex().upper() for _tag, value in _tlvs(data)] + + +def _decode_ext_card_resource(data): + """ETSI TS 102 226 Extended Card Resource Information (inner 81/82/83).""" + out = {} + raw = {} + for tag, value in _tlvs(data): + if tag == 0x81: + out['installed_application'] = int.from_bytes(value, 'big') + elif tag == 0x82: + out['free_non_volatile_memory'] = int.from_bytes(value, 'big') + elif tag == 0x83: + out['free_volatile_memory'] = int.from_bytes(value, 'big') + else: + raw['%02X' % tag] = value.hex().upper() + if raw: + out['raw_tlvs'] = raw + return out + + +def _decode_certification_data_object(data): + """CertificationDataObject (SGP.22 v2.6 §5.7.8): platform label + DLOA URL.""" + out = {} + raw = {} + for tag, value in _tlvs(data): + if tag == 0x80: + out['platform_label'] = value.decode('utf-8', 'replace') + elif tag == 0x81: + out['discovery_base_url'] = value.decode('utf-8', 'replace') + else: + raw['%02X' % tag] = value.hex().upper() + if raw: + out['raw_tlvs'] = raw + return out + + +def _decode_info1(raw_hex): + """EUICCInfo1 (BF20): SVN and the CI PKI lists.""" + out = {'svn': None, 'euicc_ci_pki_list_for_verification': [], + 'euicc_ci_pki_list_for_signing': []} + raw = {} + for tag, value in _tlvs(_tlv_value(bytes.fromhex(raw_hex or ''), 0xBF20)): + if tag == 0x82: + out['svn'] = _decode_version(value) + elif tag == 0xA9: + out['euicc_ci_pki_list_for_verification'] = _decode_ski_list(value) + elif tag == 0xAA: + out['euicc_ci_pki_list_for_signing'] = _decode_ski_list(value) + else: + raw['%02X' % tag] = value.hex().upper() + if raw: + out['raw_tlvs'] = raw + return out + + +def _decode_info2(raw_hex): + """EUICCInfo2 (BF22) with every SGP.22 v2.6 field decoded.""" + out = {} + raw = {} + for tag, value in _tlvs(_tlv_value(bytes.fromhex(raw_hex or ''), 0xBF22)): + if tag == 0x81: + out['profile_version'] = _decode_version(value) + elif tag == 0x82: + out['svn'] = _decode_version(value) + elif tag == 0x83: + out['euicc_firmware_ver'] = _decode_version(value) + elif tag == 0x84: + out['ext_card_resource'] = _decode_ext_card_resource(value) + elif tag == 0x85: + out['uicc_capability'] = _decode_bit_string(value, UICC_CAPABILITY_BITS) + elif tag == 0x86: + out['ts102241_version'] = _decode_version(value) + elif tag == 0x87: + out['globalplatform_version'] = _decode_version(value) + elif tag == 0x88: + out['rsp_capability'] = _decode_bit_string(value, RSP_CAPABILITY_BITS) + elif tag == 0xA9: + out['euicc_ci_pki_list_for_verification'] = _decode_ski_list(value) + elif tag == 0xAA: + out['euicc_ci_pki_list_for_signing'] = _decode_ski_list(value) + elif tag in (0x8B, 0xAB): # implicit and explicit category encodings + out['euicc_category'] = EUICC_CATEGORIES.get( + int.from_bytes(value, 'big') if value else 0, 'other') + elif tag == 0x99: + out['forbidden_profile_policy_rules'] = _decode_bit_string(value, PPR_ID_BITS) + elif tag == 0x04: # ppVersion has no context tag + out['pp_version'] = _decode_version(value) + elif tag == 0x0C: # sasAcreditationNumber is a bare UTF8String + out['ss_acreditation_number'] = value.decode('utf-8', 'replace') + elif tag == 0xAC: + out['certification_data_object'] = _decode_certification_data_object(value) + elif tag == 0xAD: + out['tre_properties'] = _decode_bit_string(value, TRE_PROPERTY_BITS) + elif tag == 0xAE: + out['tre_product_reference'] = value.decode('utf-8', 'replace') + elif tag == 0xAF: + out['additional_euicc_profile_package_versions'] = [ + _decode_version(v) for _t, v in _tlvs(value)] + else: + raw['%02X' % tag] = value.hex().upper() + if raw: + out['raw_tlvs'] = raw + return out + + +def _decode_addresses(raw_hex): + """ES10a GetEuiccConfiguredAddresses (BF3C).""" + out = {'default_dp_address': None, 'root_ds_address': None} + raw = {} + for tag, value in _tlvs(_tlv_value(bytes.fromhex(raw_hex or ''), 0xBF3C)): + if tag == 0x80: + out['default_dp_address'] = value.decode('utf-8', 'replace') + elif tag == 0x81: + out['root_ds_address'] = value.decode('utf-8', 'replace') + else: + raw['%02X' % tag] = value.hex().upper() + if raw: + out['raw_tlvs'] = raw + return out + + +def _decode_rat(raw_hex): + """ES10b GetRat (BF43): the Rules Authorisation Table (SGP.22 §5.7.13).""" + out = [] + table = _tlv_value(_tlv_value(bytes.fromhex(raw_hex or ''), 0xBF43), 0xA0) + for _tag, rule in _tlvs(table): + entry = {'ppr_ids': [], 'allowed_operators': [], 'ppr_flags': []} + for tag, value in _tlvs(rule): + if tag == 0x80: + entry['ppr_ids'] = _decode_bit_string(value, PPR_ID_BITS) + elif tag == 0xA1: + operators = [] + for _t, op in _tlvs(value): + ident = {'plmn': None, 'gid1': None, 'gid2': None} + for t2, v2 in _tlvs(op): + if t2 == 0x80: + ident['plmn'] = v2.hex().upper() + elif t2 == 0x81: + ident['gid1'] = v2.hex().upper() + elif t2 == 0x82: + ident['gid2'] = v2.hex().upper() + operators.append(ident) + entry['allowed_operators'] = operators + elif tag == 0x82: + entry['ppr_flags'] = _decode_bit_string(value, PPR_FLAG_BITS) + out.append(entry) + return out + + +def _raw_request(scc, cmd_cls): + """Raw response hex of a request TLV (no pySim response decoding).""" + return CardApplicationISDR.store_data_tlv(scc, cmd_cls(), None) + + def chip_info(app): - """EID (ES10b GetEuiccData), EUICCInfo1/2 and the configured addresses.""" + """EID (ES10c GetEuiccData), EUICCInfo1/2, configured addresses and RAT.""" out = {'eid': None, 'info1': None, 'info2': None, 'addresses': None, - 'errors': {}} + 'rat': None, 'errors': {}} scc = _select_isdr(app) try: parts = ( ('eid', lambda: CardApplicationISDR.get_eid(scc)), - ('info1', lambda: _flatten(CardApplicationISDR.store_data_tlv( - scc, EuiccInfo1(), EuiccInfo1))), - ('info2', lambda: _flatten(CardApplicationISDR.store_data_tlv( - scc, EuiccInfo2(), EuiccInfo2))), - ('addresses', lambda: _flatten(CardApplicationISDR.store_data_tlv( - scc, EuiccConfiguredAddresses(), EuiccConfiguredAddresses))), + ('info1', lambda: _decode_info1(_raw_request(scc, EuiccInfo1))), + ('info2', lambda: _decode_info2(_raw_request(scc, EuiccInfo2))), + ('addresses', lambda: _decode_addresses( + _raw_request(scc, EuiccConfiguredAddresses))), + ('rat', lambda: _decode_rat(_raw_request(scc, _GetRatRequest))), ) for key, fn in parts: try: diff --git a/tests/test_esim.py b/tests/test_esim.py index 8895d99..77af965 100644 --- a/tests/test_esim.py +++ b/tests/test_esim.py @@ -12,7 +12,7 @@ from types import SimpleNamespace from pySim.euicc import ( AID_ISD_R, CardApplicationISDR, DisableProfileResp, DisableResult, - EnableProfileResp, EnableResult, EuiccConfiguredAddresses, EuiccInfo1, + EnableProfileResp, EnableResult, Iccid, IsdpAid, ListNotificationResp, NotificationAddress, NotificationMetadata, NotificationMetadataList, ProfileClass, ProfileInfo, ProfileInfoListResp, ProfileInfoSeq, ProfileMgmtOperation, ProfileNickname, @@ -23,6 +23,12 @@ from pySim.utils import h2b from pysim_simple_server import esim +def _tlv(tag, value): + """Encode one BER-TLV with a single-byte length (test fixtures only).""" + tag_hex = '%02X' % tag if tag <= 0xFF else '%04X' % tag + return '%s%02X%s' % (tag_hex, len(value) // 2, value) + + class FakeLchan: def __init__(self): self.scc = SimpleNamespace(name='lchan-scc') @@ -148,17 +154,22 @@ class EsimTests(unittest.TestCase): def test_chip_info_collects_parts_and_errors(self): app, _ = make_app() self.patch_eid('89049032000000000000000000000001') - info1 = EuiccInfo1() - info1.from_tlv(h2b('BF20058203010203')) - addresses = EuiccConfiguredAddresses() - addresses.from_tlv(h2b('BF3C13800E736D64702E6578616D706C652E6F72678100')) - self.patch([info1, RuntimeError('no info2'), addresses]) + info1 = _tlv(0xBF20, _tlv(0x82, '010203')) + addresses = _tlv(0xBF3C, _tlv(0x80, '736D64702E6578616D706C652E6F7267') + + _tlv(0x81, '')) + rat = _tlv(0xBF43, _tlv(0xA0, _tlv(0x30, _tlv(0x80, '0460')))) + self.patch([info1, RuntimeError('no info2'), addresses, rat]) out = esim.chip_info(app) self.assertEqual(out['eid'], '89049032000000000000000000000001') - self.assertEqual(out['info1'], {'svn': '1.2.3'}) + self.assertEqual(out['info1'], { + 'svn': '1.2.3', 'euicc_ci_pki_list_for_verification': [], + 'euicc_ci_pki_list_for_signing': []}) self.assertIsNone(out['info2']) self.assertIn('info2', out['errors']) - self.assertEqual(out['addresses'].get('default_dp_address'), 'smdp.example.o') + self.assertEqual(out['addresses'], {'default_dp_address': 'smdp.example.org', + 'root_ds_address': ''}) + 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() @@ -193,6 +204,98 @@ class EsimTests(unittest.TestCase): esim.chip_info(app) +SKI = '81370F5125D0B1D408D4C3B232E6D25E795BEBFB' + + +class EsimInfoDecodeTests(unittest.TestCase): + """EUICCInfo1/2 and RAT decoders against a real consumer eUICC's values.""" + + def test_bit_string_decodes_unused_bits_msb_first(self): + self.assertEqual( + esim._decode_bit_string(bytes.fromhex('077F3E1F80'), + esim.UICC_CAPABILITY_BITS), + ['usimSupport', 'isimSupport', 'csimSupport', 'akaMilenage', + 'akaCave', 'akaTuak128', 'akaTuak256', 'gbaAuthenUsim', + 'gbaAuthenISim', 'mbmsAuthenUsim', 'eapClient', 'javacard', + 'berTlvFileSupport', 'dfLinkSupport', 'catTp', 'getIdentity', + 'profile-a-x25519', 'profile-b-p256']) + self.assertEqual( + esim._decode_bit_string(bytes.fromhex('0490'), + esim.RSP_CAPABILITY_BITS), + ['additionalProfile', 'testProfileSupport']) + self.assertEqual( + esim._decode_bit_string(bytes.fromhex('0640'), esim.PPR_ID_BITS), + ['ppr1']) + self.assertEqual(esim._decode_bit_string(b'', esim.PPR_ID_BITS), []) + + def test_info2_decodes_every_field(self): + ski = _tlv(0x04, SKI) + raw = _tlv(0xBF22, ''.join(( + _tlv(0x81, '020301'), _tlv(0x82, '020202'), _tlv(0x83, '040200'), + _tlv(0x84, '81010082040006B32C83022646'), + _tlv(0x85, '077F3E1F80'), _tlv(0x86, '090200'), + _tlv(0x87, '020300'), _tlv(0x88, '0490'), + _tlv(0xA9, ski), _tlv(0xAA, ski), _tlv(0x8B, '00'), + _tlv(0x99, '0640'), _tlv(0x04, '010000'), + _tlv(0x0C, '45442D5A492D55502D30383236'), + ))) + out = esim._decode_info2(raw) + self.assertEqual(out['profile_version'], '2.3.1') + self.assertEqual(out['svn'], '2.2.2') + self.assertEqual(out['euicc_firmware_ver'], '4.2.0') + self.assertEqual(out['ext_card_resource'], { + 'installed_application': 0, 'free_non_volatile_memory': 439084, + 'free_volatile_memory': 9798}) + self.assertEqual(out['uicc_capability'][:3], + ['usimSupport', 'isimSupport', 'csimSupport']) + self.assertEqual(out['ts102241_version'], '9.2.0') + self.assertEqual(out['globalplatform_version'], '2.3.0') + self.assertEqual(out['rsp_capability'], + ['additionalProfile', 'testProfileSupport']) + self.assertEqual(out['euicc_ci_pki_list_for_verification'], [SKI]) + self.assertEqual(out['euicc_ci_pki_list_for_signing'], [SKI]) + self.assertEqual(out['euicc_category'], 'other') + self.assertEqual(out['forbidden_profile_policy_rules'], ['ppr1']) + self.assertEqual(out['pp_version'], '1.0.0') + self.assertEqual(out['ss_acreditation_number'], 'ED-ZI-UP-0826') + self.assertNotIn('raw_tlvs', out) + + def test_info2_accepts_both_category_tags_and_keeps_unknown_tlvs(self): + out = esim._decode_info2(_tlv(0xBF22, _tlv(0xAB, '02') + _tlv(0xE0, 'AABB'))) + self.assertEqual(out['euicc_category'], 'mediumEuicc') + self.assertEqual(out['raw_tlvs'], {'E0': 'AABB'}) + + def test_info2_decodes_certification_data_object(self): + out = esim._decode_info2(_tlv(0xBF22, _tlv(0xAC, _tlv(0x80, '504C') + + _tlv(0x81, '68747470733A2F2F642E6578616D706C65')))) + self.assertEqual(out['certification_data_object'], + {'platform_label': 'PL', + 'discovery_base_url': 'https://d.example'}) + + def test_info1_decodes_svn_and_ski_lists(self): + out = esim._decode_info1(_tlv(0xBF20, _tlv(0x82, '020202') + + _tlv(0xA9, _tlv(0x04, SKI)))) + self.assertEqual(out, {'svn': '2.2.2', + 'euicc_ci_pki_list_for_verification': [SKI], + 'euicc_ci_pki_list_for_signing': []}) + + def test_rat_decodes_rules(self): + raw = _tlv(0xBF43, _tlv(0xA0, _tlv(0x30, + _tlv(0x80, '0460') + + _tlv(0xA1, _tlv(0x30, _tlv(0x80, 'EEEEEE'))) + + _tlv(0x82, '0180')))) + self.assertEqual(esim._decode_rat(raw), [{ + 'ppr_ids': ['ppr1', 'ppr2'], + 'allowed_operators': [{'plmn': 'EEEEEE', 'gid1': None, 'gid2': None}], + 'ppr_flags': ['consentRequired']}]) + + def test_addresses_decode(self): + out = esim._decode_addresses(_tlv(0xBF3C, _tlv( + 0x81, '74657374726F6F74736D64732E67736D612E636F6D'))) + self.assertIsNone(out['default_dp_address']) + self.assertEqual(out['root_ds_address'], 'testrootsmds.gsma.com') + + class EsimRoutingTests(unittest.TestCase): def test_esim_routes_are_in_the_right_http_handlers(self): from pysim_simple_server import server