From 599d84fd66d1f4c83899c11d98353faed7ccf334 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: Wed, 30 Sep 2026 09:00:33 +0300 Subject: [PATCH] feat: Explore exposes the rest of the registry data + install NV footprint (v3.6.51) The compact (legacy) GET STATUS entry is + (P1=10) ()*, cross-checked against GlobalPlatformPro's populate_legacy (the 4th byte is privileges; 0x00 is required for load files). The parser read it as a 'version' one byte late, so ELF rows showed the module count / the next entry's length as Version: 00/10 - the compact listing has no version at all; it now comes from the expanded CE tag (ramFmtVersion: 2 bytes -> major.minor, GP Table 11-37 Note 1) and merges (the bogus compact value no longer blocked it). New data exposed: - EA SCP Registry Data (TS 102 226 8.2.1.6) -> 80 Menu Parameters -> the applet's STK menu entries: STK menu entry: 0x80 (position 1, Enabled) - unknown registry objects are preserved (rawTlvs) and shown as 'Other registry data: 8F=752B . 90=012D ...' instead of being dropped - 84 parses the GP coding (one Executable Module AID per TLV, repeated; the concatenated 4F form stays as fallback) and the ELF expanded queries add 84 to the tag list (with a tag list the card returns only the listed tags - the fallback path would otherwise lose the module lists and their Install buttons) - life cycle names decode per object kind (GP Tables 11-3..11-6: ISD/card, app, SD - detected via the Security Domain privilege - and ELF), with multi-byte 9F70 (02 0700 on live cards) decoding the first byte and surfacing non-zero extras; the R-APDU parser's LIFECYCLE_MAP labels were aligned with the same tables /api/ram-install measures the NV footprint: a silent GET DATA FF21 read before and after the chain (also on failure) returns nv_before/nv_after/ nv_delta; the reads never fail the operation (no FF21 -> no fields) and the PWA shows 'NV used: ~N B (free A -> B)' in the install result. sw cache simple-v324; tests: 548 Python / 698 Node. --- docs/api.md | 15 +- frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 245 ++++++++++++++++++++++------ frontend/sw.js | 2 +- frontend/tests/ram.test.js | 169 ++++++++++++++++++- frontend/tests/response_map.test.js | 17 +- pyproject.toml | 2 +- pysim_simple_server/server.py | 85 ++++++++-- tests/test_fetch_routes.py | 87 ++++++++++ tests/test_ota_helpers.py | 87 ++++++++++ 11 files changed, 637 insertions(+), 76 deletions(-) diff --git a/docs/api.md b/docs/api.md index 64bef38..8199d44 100644 --- a/docs/api.md +++ b/docs/api.md @@ -501,7 +501,7 @@ Clears a finished run report (409 while a run is active). ### `POST /api/ram-install` -Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE; the PoR verdict and the remote command's own status word are both checked and the sequence aborts on the first failure (a non-`por_ok` PoR, a remote SW outside the success set, or an undecodable PoR). The counter advances only for a packet the card accepted (PoR `por_ok`); `final_cntr` is returned on success **and** on failure, so the caller keeps the card's consumed counter (a rejected packet leaves it unchanged). The RAM command format is detected per operation: a read-only `GET STATUS [ISD]` probe (`FORMAT CHECK (compact)`, then `FORMAT CHECK (expanded)`) decides between the compact C-APDU and the expanded `AA`/`22` form (TS 102 226 §5.2.1), and every INSTALL/LOAD step of the chain then uses the detected format — the response carries `ram_format`. Pass `ram_format` (`compact`/`expanded`) to pin it. The `.cap` archive (a ZIP of nested components) is parsed server-side in `_cap_parse`; no external tooling is required. +Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE; the PoR verdict and the remote command's own status word are both checked and the sequence aborts on the first failure (a non-`por_ok` PoR, a remote SW outside the success set, or an undecodable PoR). The counter advances only for a packet the card accepted (PoR `por_ok`); `final_cntr` is returned on success **and** on failure, so the caller keeps the card's consumed counter (a rejected packet leaves it unchanged). The RAM command format is detected per operation: a read-only `GET STATUS [ISD]` probe (`FORMAT CHECK (compact)`, then `FORMAT CHECK (expanded)`) decides between the compact C-APDU and the expanded `AA`/`22` form (TS 102 226 §5.2.1), and every INSTALL/LOAD step of the chain then uses the detected format — the response carries `ram_format`. Pass `ram_format` (`compact`/`expanded`) to pin it. The **NV footprint** is measured around the chain: a best-effort `GET DATA FF21` (Extended Card Resources, TS 102 226 §8.2.1.7.2) read before the first step and again after the last one (also on failure) reports the free non-volatile memory and its delta — the read is silent (no step record, no failure) and a card without FF21 simply yields no fields. The `.cap` archive (a ZIP of nested components) is parsed server-side in `_cap_parse`; no external tooling is required. **Request body:** ```json @@ -545,7 +545,10 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL "application_aid": "A000000003000000", "load_block_size": 240, "load_block_size_requested": null, - "load_block_size_auto": true} + "load_block_size_auto": true, + "nv_before": 50646, + "nv_after": 23400, + "nv_delta": 27246} ``` `load_block_size` is the effective size used for the LOAD blocks (240 by @@ -558,6 +561,14 @@ word; the RAM results are in `por_status` (the PoR verdict: `por_ok`, `por_sw` (the remote command's own status word from the compact/expanded response, with `por_type`/`por_cntr`/`por_data`/`por_raw` for context). +`nv_before`/`nv_after` are the free non-volatile memory (bytes) read via +`GET DATA FF21` before and after the chain, `nv_delta` their difference (what +the operation consumed; negative = memory was released, e.g. a discarded +partial load). The three fields are omitted when the card has no FF21 +readout; on a failure the values still show what the failed attempt consumed. +The two FF21 reads consume counters like any accepted packet, so +`final_cntr` already includes them. + A step fails when the PoR is not `por_ok`, when `por_sw` is outside the success set (`9000`, `61xx` more data, `62xx`/`63xx` warnings, `CAFE` GP "more data"), or when response data arrived but could not be decoded. The diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 4ae42ad..7ee1373 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -320,7 +320,7 @@ - + diff --git a/frontend/help.html b/frontend/help.html index 02bd81d..707a599 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -319,7 +319,7 @@
ОперацияОписание
Обзор карты (все данные GP)Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 (расширенные ресурсы карты: число установленных приложений и свободная энергонезависимая/энергозависимая память). Если карта отвергает компактный формат листинга (P2.b2=0, устаревший по GP), запросы повторяются в расширенном формате E3; флажок Запрашивать расширенные данные реестра (связи load-файла и SD) дополнительно запрашивает их и объединяет расширенные поля (AID load-файла, AID Security Domain, списки модулей) — он расходует дополнительные защищённые пакеты, и не все карты поддерживают расширенный запрос, поэтому по умолчанию выключен и запоминается в браузере. Результаты отображаются в обзоре (строка успеха минимальна, счётчики — в заголовках разделов) с кнопками Удалить для каждого элемента и определённым форматом команд RAM (подсказка при наведении объясняет compact и expanded).
Обзор карты (все данные GP)Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 (расширенные ресурсы карты: число установленных приложений и свободная энергонезависимая/энергозависимая память). Компактный листинг содержит только AID, состояние жизненного цикла и привилегии (версии в устаревшем формате нет); если карта отвергает его (P2.b2=0, устаревший по GP), запросы повторяются в расширенном формате E3, а флажок Запрашивать расширенные данные реестра (связи load-файла и SD) дополнительно запрашивает их и объединяет расширенные поля: версию исполняемого load-файла, AID load-файла, AID Security Domain, списки модулей, пункты меню STK апплета (SCP Registry Data, TS 102 226 8.2.1.6) и любые другие объекты реестра, которые вернула карта (показываются как есть, без потерь) — он расходует дополнительные защищённые пакеты, и не все карты поддерживают расширенный запрос, поэтому по умолчанию выключен и запоминается в браузере. Результаты отображаются в обзоре (строка успеха минимальна, счётчики — в заголовках разделов) с кнопками Удалить для каждого элемента и определённым форматом команд RAM (подсказка при наведении объясняет compact и expanded).
Установка пакета (.cap файл)Отправка .cap файла на карту через сервер: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера размер блока LOAD (1–240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой .cap просто занимает несколько SMS. Сразу после выбора файл проверяется и в форме показывается оценка требований к памяти: требование NVRAM (образ кода + постоянные данные, сумма в стиле C6+C8 из GP Card Spec) и оценка RAM. Там же показаны библиотеки, с которыми слинкован CAP (его компонент Import: имя библиотеки, AID и минимальная версия ≥ x.y — имена стандартных библиотек и семейства подставляются, а где известно, даётся подсказка о версии Java Card), идентификаторы пакета и апплетов и разбивка по компонентам (размеры и доли в порядке load-файла) — всё сгруппировано по разделам Package, Requires, Memory и Components. Строка Platform показывает минимальный уровень Java Card, который должна предоставить карта (выводится из импорта javacard.framework; флаг int в заголовке дополнительно требует поддержки int — Java Card 2.2+), и релиз ETSI, из которого пришли импорты UICC API (версия uicc.toolkit → REL-N по корпусу sim-tools в рабочем каталоге; основание показано в скобках): JCRE проверяет каждый импорт по своим пакетам при верификации load-файла (JC VM spec 4.5.2), поэтому карта со старой платформой отклоняет LOAD сразу после завершения компонента Import (в строке шага виден remote SW карты и компонент, который завершил блок, например завершает Import) — меньший размер блока LOAD помогает только если блок превышает буфер карты. Повреждённый файл не проходит анализ, и Execute остаётся недоступной до успешного анализа. Оценка носит справочный характер и никогда не используется при установке: NVRAM — это образ кода плюс постоянные данные (сумма в стиле C6+C8 по GP Table 11-48; управление памятью карты, округление и запись в реестре не учитываются, а образ статических полей входит в обе части); рекомендуемый C6 следует за load-файлом, а рекомендуемые квоты — это десятичные значения в байтах (шестнадцатеричное значение в скобках — это кодирование параметров установки C7/C8: C6 — энергонезависимый код, C7 — энергозависимая RAM, C8 — энергонезависимые данные).
Проверка совместимости CAPОтвечает на один вопрос, ничего не устанавливая: может ли эта карта удовлетворить список импортов CAP? Сервер выполняет проверку формата, INSTALL[for load] и блоки LOAD только до блока, завершающего компонент Import — именно там JCRE проверяет список импортов — и останавливается: без флага последнего блока и без INSTALL[for install], поэтому ничего не фиксируется и удалять после этого ничего не нужно. Список «Версии импортов для проверки» предзаполняется из анализа CAP: AID=version переписывает запрошенную версию импорта, +AID=version добавляет синтетический импорт (запрос возможностей карты: LOAD проходит, только если карта экспортирует этот пакет версии ≥ указанной), а «Все 0.0» снимает все требования. Использование в два шага: нажмите «Все 0.0» — если проверка проходит, причиной был список импортов; затем возвращайте по одной строке, чтобы найти пакет и версию, которых не хватает карте. Вердикт явно указывает, что проверяется только этап LOAD/импортов: этап связывания (наличие реально вызываемых API) проверяет настоящая установка на INSTALL[for install].
- + diff --git a/frontend/index.html b/frontend/index.html index a61fd86..d3efe88 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -703,7 +703,7 @@ - + '; @@ -9050,11 +9183,13 @@ function ramRenderExploreHtml(mem, isd, apps, elfs, fmt) { html += '
'; html += '
'; html += '
' + esc(t('Application / Instance AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '
'; - html += '
' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '') + '
'; + html += '
' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', ramEntryKind(o)) + '
'; if (o.privileges) html += '
' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')
'; if (o.implicitSel) html += '
' + esc(t('Implicit sel:')) + ' ' + o.implicitSel + '
'; if (o.elfAid) html += '
' + esc(t('Load File AID / Package AID:')) + ' ' + jcAidHtml(o.elfAid) + '
'; if (o.sdAid) html += '
' + esc(t('SD AID:')) + ' ' + jcAidHtml(o.sdAid) + '
'; + html += ramMenuHtml(o.menuEntries); + html += ramRawTlvsHtml(o.rawTlvs); html += '
'; html += '
'; if (o.aid) { @@ -9074,12 +9209,14 @@ function ramRenderExploreHtml(mem, isd, apps, elfs, fmt) { html += '
'; html += '
'; html += '
' + esc(t('Load File AID / Package AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '
'; - html += '
' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '') + '
'; - if (o.version) html += '
' + esc(t('Version:')) + ' ' + o.version + '
'; + html += '
' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', 'elf') + '
'; + if (o.version) html += '
' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) + '
'; + if (o.privileges && o.privileges !== '00') html += '
' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')
'; if (o.moduleAids && o.moduleAids.length) { html += '
' + esc(t('Executable Module AIDs / Applet Class AIDs:')) + '
'; } if (o.sdAid) html += '
' + esc(t('SD AID:')) + ' ' + jcAidHtml(o.sdAid) + '
'; + html += ramRawTlvsHtml(o.rawTlvs); html += '
'; html += '
'; if (o.aid) { @@ -9210,16 +9347,7 @@ async function ramExplore(sp) { collector.push(...parsed); } else if (parsed.length) { // Keep the compact entries, add the expanded-only fields - const byAid = {}; - collector.forEach(e => { if (e.aid) byAid[e.aid.toUpperCase()] = e; }); - parsed.forEach(p => { - const cur = byAid[(p.aid || '').toUpperCase()]; - if (!cur) return; - ['privileges', 'lifecycle', 'version', 'elfAid', 'sdAid', 'implicitSel'].forEach(k => { - if (p[k] && !cur[k]) cur[k] = p[k]; - }); - if (p.moduleAids && p.moduleAids.length) cur.moduleAids = p.moduleAids; - }); + ramMergeExpanded(collector, parsed); } } // The compact error only surfaces when the expanded pass did not rescue it @@ -9726,6 +9854,17 @@ async function ramInstallApp(sp, op) { ramShowInstallResult(data, t('OK')); } +// Measured NV footprint of an install operation: the server reads GET DATA +// FF21 (free non-volatile memory) before and after the chain; the delta is +// what the operation consumed (negative = memory was freed). Cards without +// FF21 have no values and the line disappears. +function ramNvLine(data) { + if (!data || data.nv_before == null || data.nv_after == null) return ''; + const used = data.nv_before - data.nv_after; + const label = used < 0 ? t('NV freed:') : t('NV used:'); + return label + ' ~' + Math.abs(used) + ' B (' + t('free') + ' ' + data.nv_before + ' \u2192 ' + data.nv_after + ')'; +} + // Shared result rendering for /api/ram-install and /api/ram-install-app: the // per-step lines, the counter the card consumed, and the verdict. function ramShowInstallResult(data, okText, capMem) { @@ -9735,6 +9874,8 @@ function ramShowInstallResult(data, okText, capMem) { const comps = capMem && capMem.components; let txt = ''; (data.steps || []).forEach((s, idx) => { txt += ramStepLine(s, idx, comps, data.load_block_size) + '\n'; }); + const nv = ramNvLine(data); + if (nv) txt += nv + '\n'; stepsEl.textContent = txt; // Persist the counter the card actually consumed (accepted packets only): // even a failed install leaves the accepted steps behind, and replaying @@ -10489,8 +10630,14 @@ function ramGetStatusApdu(p1, p2) { // (GP Card Spec 11.4.2.3/11.4.3.1). Used as the fallback when a card rejects // the deprecated compact format and for the optional details pass; the bytes // are the reference trace form (`80F28002 0C 4F00 5C08 4F9F70C5C4CCCEEA 00`). +// The ELF queries add '84' (Executable Module AIDs, GP Table 11-37): with a +// tag list present the card returns only the listed tags (11.4.3.1), so +// without it the expanded fallback would list packages without their modules +// (no module rows / Install buttons on cards that reject the compact form). function ramExpandedQueryApdu(p1) { - return '80F2' + p1 + '02' + '0C' + '4F005C084F9F70C5C4CCCEEA' + '00'; + const elf = p1 === '10' || p1 === '20'; + const tags = '4F9F70C5C4CCCEEA' + (elf ? '84' : ''); + return '80F2' + p1 + '02' + (elf ? '0D' : '0C') + '4F005C' + (elf ? '09' : '08') + tags + '00'; } // The ISD (80) and memory responses fit the ENVELOPE; the Apps (40) and ELF @@ -18189,7 +18336,13 @@ const LANG_RU = { 'KIc key and KID key are required': 'Укажите ключ KIc и ключ KID', 'RAM command format:': 'Формат команд RAM:', 'Fetch expanded registry details (load-file / SD associations)': 'Запрашивать расширенные данные реестра (связи load-файла и SD)', - 'Runs one extra expanded query per listing (P2=02, tag list 4F 9F70 C5 C4 CC CE EA). The compact listing carries AID/lifecycle/privileges/version only; the expanded pass adds the Load File AID, Security Domain AID and module lists. It costs extra secured packets and some cards reject it - the Explore already uses it automatically when the compact listing returns nothing.': 'Запускает дополнительный расширенный запрос для каждого списка (P2=02, список тегов 4F 9F70 C5 C4 CC CE EA). Компактный список содержит только AID/состояние/привилегии/версию; расширенный проход добавляет AID load-файла, AID Security Domain и списки модулей. Он расходует дополнительные защищённые пакеты, и не все карты его поддерживают — Explore и так применяет его автоматически, когда компактный список ничего не вернул.', + 'Runs one extra expanded query per listing (P2=02, tag list 4F 9F70 C5 C4 CC CE EA, plus 84 for the ELF queries). The compact listing carries AID, life cycle and privileges only - it has no version; the expanded pass adds the Executable Load File version, the Load File AID, the Security Domain AID, the module lists and the applet STK menu entries (SCP Registry Data). It costs extra secured packets and some cards reject it - the Explore already uses it automatically when the compact listing returns nothing.': 'Запускает дополнительный расширенный запрос для каждого списка (P2=02, список тегов 4F 9F70 C5 C4 CC CE EA, плюс 84 для запросов ELF). Компактный список содержит только AID, состояние и привилегии — версии в нём нет; расширенный проход добавляет версию исполняемого load-файла, AID load-файла, AID Security Domain, списки модулей и пункты меню STK апплета (SCP Registry Data). Он расходует дополнительные защищённые пакеты, и не все карты его поддерживают — Explore и так применяет его автоматически, когда компактный список ничего не вернул.', + 'STK menu entry:': 'Пункт меню STK:', + 'position': 'позиция', + 'Other registry data:': 'Прочие данные реестра:', + 'NV used:': 'Занято NV:', + 'NV freed:': 'Освобождено NV:', + 'free': 'свободно', 'compact = the TS 102 226 5.2.1 command string (one C-APDU per secured packet, e.g. GET STATUS + GET RESPONSE); expanded = the AA/22 Command Scripting template (5.2.1.0). Detected per operation with a read-only GET STATUS probe; a card that rejects the deprecated compact P2.b2=0 form answers the expanded one.': 'compact = командная строка по TS 102 226 5.2.1 (один C-APDU на защищённый пакет, например GET STATUS + GET RESPONSE); expanded = шаблон Command Scripting AA/22 (5.2.1.0). Определяется для каждой операции пробным чтением GET STATUS; карта, отклоняющая устаревшую компактную форму P2.b2=0, отвечает в расширенной.', 'Card resources (GET DATA FF21)': 'Ресурсы карты (GET DATA FF21)', 'Extended Card Resources (GP tag FF21, ETSI TS 102 226 8.2.1.7.2): installed applications, free non-volatile and free volatile memory': 'Расширенные ресурсы карты (тег GP FF21, ETSI TS 102 226 8.2.1.7.2): число установленных приложений, свободная энергонезависимая и свободная энергозависимая память', diff --git a/frontend/sw.js b/frontend/sw.js index be246fa..b433dfd 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v323'; +const CACHE = 'simple-v324'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 8dd7f26..5e9f1be 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -22,9 +22,9 @@ function extractFunc(src, name) { } // Extract chain builder functions and dependencies -const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramCompatProbeNote', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu', +const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtVersion', 'ramMenuState', 'ramEntryKind', 'ramMenuHtml', 'ramRawTlvsHtml', 'ramNvLine', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramMergeExpanded', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramCompatProbeNote', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu', 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu', - '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry', + '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries', 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText', 'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml']; let code = ''; @@ -33,8 +33,6 @@ for (const f of FNS) { } const m = html.match(/const _chains = \{\};/); if (m) code += m[0].replace(/^const /, 'var ') + '\n'; -const lc = html.match(/const RAM_LIFECYCLE = \{[\s\S]*?\n\};/); -if (lc) code += lc[0].replace(/^const /, 'var ') + '\n'; const pn = html.match(/const PRIVILEGE_NAMES = \[[\s\S]*?\n\];/); if (pn) code += pn[0].replace(/^const /, 'var ') + '\n'; const an = html.match(/const JC_AID_NAMES = \{[\s\S]*?\n\};/); @@ -222,7 +220,8 @@ test('ramRenderExploreHtml localizes every label and button', () => { const out = ramRenderExploreHtml( { appCount: 5, freeNV: 100, freeV: 50 }, [{ aid: 'A000000151000000', lifecycle: '07', privileges: '', sdAid: 'A000000151000000' }], - [{ aid: 'A1130001180001', lifecycle: '07', privileges: '80', implicitSel: '00', elfAid: 'ELF1' }], + [{ aid: 'A1130001180001', lifecycle: '07', privileges: '80', implicitSel: '00', elfAid: 'ELF1', + menuEntries: [{ pos: '01', id: '80', state: '01' }], rawTlvs: [{ tag: '8F', value: '752B' }] }], [{ aid: 'ELF1', lifecycle: '01', version: '1.0', moduleAids: ['M1'], sdAid: null }] ); delete global.t; @@ -240,6 +239,9 @@ test('ramRenderExploreHtml localizes every label and button', () => { assert.ok(seen.includes('Application / Instance AID:')); assert.ok(seen.includes('Load File AID / Package AID:')); assert.ok(seen.includes('Executable Module AIDs / Applet Class AIDs:')); + assert.ok(seen.includes('STK menu entry:')); + assert.ok(seen.includes('position')); + assert.ok(seen.includes('Other registry data:')); assert.ok(!out.includes('data-l10n'), out); // the well-known ISD AID is annotated (GP Card Spec v2.3.1 H.1.3) assert.ok(out.includes('A000000151000000 (GlobalPlatform Issuer Security Domain)'), out); @@ -544,10 +546,17 @@ test('ramCardIdxAfterRemove keeps the remembered index aligned', () => { test('ramExpandedQueryApdu matches the reference trace bytes', () => { // TCA Loader: `AA14 2212 80F28002 0C 4F00 5C08 4F9F70C5C4CCCEEA 00` - for (const p1 of ['80', '40', '20', '10']) { + for (const p1 of ['80', '40']) { assert.strictEqual(ramExpandedQueryApdu(p1), '80F2' + p1 + '020C4F005C084F9F70C5C4CCCEEA00'); } + // The ELF queries add '84' (Executable Module AIDs): with a tag list the + // card returns only the listed tags, so the expanded fallback would + // otherwise list packages without their modules (v3.6.51). + for (const p1 of ['20', '10']) { + assert.strictEqual(ramExpandedQueryApdu(p1), + '80F2' + p1 + '020D4F005C094F9F70C5C4CCCEEA8400'); + } }); test('ramParseAppStatus parses the expanded E3 listing (vendor traces)', () => { @@ -586,6 +595,10 @@ test('ramParseElfStatus lists the compact ELF and module listings (F0414C4641610 const f041 = elfs.find(r => r.aid === 'F0414C46416101'); assert.ok(f041, JSON.stringify(elfs.map(r => r.aid))); assert.strictEqual(f041.lifecycle, '01'); + // the 4th byte is the privileges byte (the legacy GET STATUS shape); + // the compact listing has no version - it comes from the expanded CE tag + assert.strictEqual(f041.privileges, '00'); + assert.strictEqual(f041.version, undefined); assert.ok(elfs.some(r => r.aid === 'A1130001180002FFF7100E8904000200'), 'A113 ELF missing'); assert.ok(elfs.some(r => r.aid === 'A0000000090005FFFFFFFF8912000000'), 'uicc.toolkit ELF missing'); @@ -596,6 +609,150 @@ test('ramParseElfStatus lists the compact ELF and module listings (F0414C4641610 assert.deepStrictEqual(f041Row.moduleAids, ['F0414C4641610101']); }); +test('_parseE3Entry collects Executable Module AIDs from every 84 TLV', () => { + // GP Table 11-37: one Executable Module AID per '84' TLV (the reference + // GPPro coding), repeated per module. + const one = _parseE3Entry('4F10A1130001180001FFFFFFFF89A10039008410A1130001180001FFFFFFFF89A1003908'); + assert.deepStrictEqual(one.moduleAids, ['A1130001180001FFFFFFFF89A1003908']); + const two = _parseE3Entry('4F10A1130001180002FFF7100E89040002008410A1130001180002FFF7100E89040002088410A1130001180002FFF7100E89494D4508'); + assert.deepStrictEqual(two.moduleAids, + ['A1130001180002FFF7100E8904000208', 'A1130001180002FFF7100E89494D4508']); + // a card concatenating 4F TLVs inside one 84 still parses + const concat = _parseE3Entry('4F10A1130001180002FFF7100E890400020084244F10A1130001180002FFF7100E89040002084F10A1130001180002FFF7100E89494D4508'); + assert.deepStrictEqual(concat.moduleAids, + ['A1130001180002FFF7100E8904000208', 'A1130001180002FFF7100E89494D4508']); +}); + +test('_parseE3Entry reads the SCP Registry Data menu entries (NP trace)', () => { + // Explore_NP.log app entry: ... EA 05 80 03 01 80 01 = menu entry + // position 1, identifier 0x80, enabled (TS 102 226 Tables 8.2/8.3). + const r = _parseE3Entry('4F10A1130001180001FFFFFFFF89A10039089F70020700C503000000C410A1130001180001FFFFFFFF89A1003900CE020903CC07A0000001510000EA058003018001'); + assert.strictEqual(r.aid, 'A1130001180001FFFFFFFF89A1003908'); + assert.strictEqual(r.lifecycle, '0700'); + assert.strictEqual(r.version, '0903'); + assert.strictEqual(r.sdAid, 'A0000001510000'); + assert.strictEqual(r.elfAid, 'A1130001180001FFFFFFFF89A1003900'); + assert.deepStrictEqual(r.menuEntries, [{ pos: '01', id: '80', state: '01' }]); + assert.strictEqual(r.rawTlvs, undefined); + // an empty EA (an ISD with no menu entries) is not an error and adds nothing + const empty = _parseE3Entry('4F08A000000151000000EA00'); + assert.strictEqual(empty.menuEntries, undefined); + assert.strictEqual(empty.rawTlvs, undefined); +}); + +test('_parseE3Entry preserves registry data objects it does not decode', () => { + // Explore_NP.log ISD entry: the card returned 8F/90/91/92 beyond the + // requested GP tags - shown raw, never dropped. + const r = _parseE3Entry('4F07A00000015100009F70020F00C5039AFF80C407A0000001515350CE020001CC07A00000015100008F02752B9002012D910265D592020BD3'); + assert.deepStrictEqual(r.rawTlvs, [ + { tag: '8F', value: '752B' }, { tag: '90', value: '012D' }, + { tag: '91', value: '65D5' }, { tag: '92', value: '0BD3' }, + ]); + assert.strictEqual(r.privileges, '9AFF80'); +}); + +test('ramFmtVersion formats the 2-byte CE version as major.minor', () => { + // GP Table 11-37 Note 1: Java Card CAP major/minor attributes, in order. + assert.strictEqual(ramFmtVersion('0903'), '9.3'); + assert.strictEqual(ramFmtVersion('0100'), '1.0'); + assert.strictEqual(ramFmtVersion('0A0B'), '10.11'); + // other lengths stay raw (the format depends on the load file format) + assert.strictEqual(ramFmtVersion('09'), '09'); + assert.strictEqual(ramFmtVersion(''), ''); +}); + +test('ramFmtLifecycle decodes per GP object kind (Tables 11-3..11-6)', () => { + // ISD inherits the card life cycle (Table 11-6) + assert.strictEqual(ramFmtLifecycle('01', 'isd'), 'OP_READY'); + assert.strictEqual(ramFmtLifecycle('07', 'isd'), 'INITIALIZED'); + assert.strictEqual(ramFmtLifecycle('0F', 'isd'), 'SECURED'); + assert.strictEqual(ramFmtLifecycle('7F', 'isd'), 'CARD_LOCKED'); + assert.strictEqual(ramFmtLifecycle('FF', 'isd'), 'TERMINATED'); + // Executable Load File (Table 11-3) + assert.strictEqual(ramFmtLifecycle('01', 'elf'), 'LOADED'); + // Application (Table 11-4) + assert.strictEqual(ramFmtLifecycle('03', 'app'), 'INSTALLED'); + assert.strictEqual(ramFmtLifecycle('07', 'app'), 'SELECTABLE'); + assert.strictEqual(ramFmtLifecycle('0F', 'app'), 'SELECTABLE (0x0F)'); + assert.strictEqual(ramFmtLifecycle('83', 'app'), 'LOCKED'); + // Security Domain (Table 11-5) + assert.strictEqual(ramFmtLifecycle('0F', 'sd'), 'PERSONALIZED'); + assert.strictEqual(ramFmtLifecycle('8F', 'sd'), 'LOCKED'); + // a card may append extra bytes (NP trace: 9F70 02 0700) + assert.strictEqual(ramFmtLifecycle('0700', 'app'), 'SELECTABLE'); + assert.strictEqual(ramFmtLifecycle('0701', 'app'), 'SELECTABLE (0x01)'); + // unknown values stay visible + assert.strictEqual(ramFmtLifecycle('42', 'app'), '0x42'); + assert.strictEqual(ramFmtLifecycle('', 'app'), ''); +}); + +test('ramMenuState and ramEntryKind decode menu state and SD detection', () => { + global.t = s => s; + assert.strictEqual(ramMenuState('00'), 'Disabled'); + assert.strictEqual(ramMenuState('01'), 'Enabled'); + assert.strictEqual(ramMenuState('02'), '0x02'); + delete global.t; + // GP Table 11-7 byte 1 b8 = Security Domain privilege + assert.strictEqual(ramEntryKind({ privileges: '9AFE80' }), 'sd'); + assert.strictEqual(ramEntryKind({ privileges: '04' }), 'app'); + assert.strictEqual(ramEntryKind({ type: 'elf', privileges: '00' }), 'elf'); + assert.strictEqual(ramEntryKind(null), 'app'); +}); + +test('the explorer shows versions, menu entries and unknown registry data', () => { + global.t = s => s; + const out = ramRenderExploreHtml( + { appCount: 1, freeNV: 10, freeV: 5 }, + [], + [{ aid: 'A1130001180001FFFFFFFF89A1003908', lifecycle: '0700', privileges: '000000', + elfAid: 'A1130001180001FFFFFFFF89A1003900', sdAid: 'A0000001510000', + menuEntries: [{ pos: '01', id: '80', state: '01' }], rawTlvs: [{ tag: '8F', value: '752B' }] }], + [{ aid: 'A1130001180001FFFFFFFF89A1003900', lifecycle: '01', privileges: '00', version: '0309' }] + ); + delete global.t; + // the app row: SELECTABLE from the 2-byte 0700, menu entry, raw TLV + assert.ok(out.includes('SELECTABLE'), out); + assert.ok(out.includes('STK menu entry: 0x80 (position 1, Enabled)'), out); + assert.ok(out.includes('Other registry data: 8F=752B'), out); + // the ELF row: LOADED, formatted version, no privileges noise for '00' + assert.ok(out.includes('LOADED'), out); + assert.ok(out.includes('Version: 3.9'), out); + const elfSection = out.slice(out.indexOf('Executable Load Files')); + assert.ok(!elfSection.includes('Privileges:'), elfSection); +}); + +test('ramMergeExpanded fills only what the compact listing lacks', () => { + // the compact ELF entry has no version (v3.6.51): the expanded CE version + // and SD AID merge in - this was blocked while the bogus compact "version" + // was still set + const compact = [{ type: 'elf', aid: 'A1130001180001FFFFFFFF89A1003900', lifecycle: '01', privileges: '00' }]; + const expanded = [{ aid: 'A1130001180001FFFFFFFF89A1003900', version: '0309', sdAid: 'A0000001510000', lifecycle: '01' }]; + ramMergeExpanded(compact, expanded); + assert.strictEqual(compact[0].version, '0309'); + assert.strictEqual(compact[0].sdAid, 'A0000001510000'); + // a field the compact entry already carries is never overwritten + const apps = [{ aid: 'A1', privileges: '04' }]; + ramMergeExpanded(apps, [{ aid: 'A1', privileges: '9AFE80', menuEntries: [{ pos: '01', id: '80', state: '01' }] }]); + assert.strictEqual(apps[0].privileges, '04'); + assert.deepStrictEqual(apps[0].menuEntries, [{ pos: '01', id: '80', state: '01' }]); + // module lists come from the expanded listing when it has them + const elfs = [{ aid: 'E1' }]; + ramMergeExpanded(elfs, [{ aid: 'E1', moduleAids: ['M1'] }]); + assert.deepStrictEqual(elfs[0].moduleAids, ['M1']); +}); + +test('ramNvLine reports the measured NV footprint', () => { + global.t = s => s; + assert.strictEqual(ramNvLine({ nv_before: 50646, nv_after: 23400 }), + 'NV used: ~27246 B (free 50646 \u2192 23400)'); + assert.strictEqual(ramNvLine({ nv_before: 100, nv_after: 150 }), + 'NV freed: ~50 B (free 100 \u2192 150)'); + assert.strictEqual(ramNvLine({ nv_before: 100 }), ''); + assert.strictEqual(ramNvLine({}), ''); + assert.strictEqual(ramNvLine(null), ''); + delete global.t; +}); + test('ramParseAppStatus keeps 16-byte AIDs (no rawLen-1 truncation)', () => { const page = '08D276000005AA3F010704' + '0FD276000005AA060200000000B000000700' diff --git a/frontend/tests/response_map.test.js b/frontend/tests/response_map.test.js index cc5f16c..7cf4fd7 100644 --- a/frontend/tests/response_map.test.js +++ b/frontend/tests/response_map.test.js @@ -38,14 +38,19 @@ test('gp 6310 label does not reference removed P2=42 option', () => { }); test('LIFECYCLE_MAP per GPC v2.3 life cycles', () => { - assert.strictEqual(LIFECYCLE_MAP['01'], 'OP_READY (card) / LOADED (ELF)'); - assert.strictEqual(LIFECYCLE_MAP['03'], 'INSTALLED'); - assert.strictEqual(LIFECYCLE_MAP['07'], 'SELECTABLE'); - assert.strictEqual(LIFECYCLE_MAP['0F'], 'SECURED (card)'); - assert.strictEqual(LIFECYCLE_MAP['1F'], 'PERSONALIZED (SD)'); + // GP Card Spec v2.3.1 11.1.1 Tables 11-3..11-6: the coding depends on the + // object kind (the ISD inherits the card life cycle), so the shared map + // names every interpretation. The Explore decodes per kind instead + // (ramFmtLifecycle). + assert.strictEqual(LIFECYCLE_MAP['01'], 'OP_READY (card/ISD) / LOADED (ELF)'); + assert.strictEqual(LIFECYCLE_MAP['03'], 'INSTALLED (app/SD)'); + assert.strictEqual(LIFECYCLE_MAP['07'], 'SELECTABLE (app/SD) / INITIALIZED (card/ISD)'); + assert.strictEqual(LIFECYCLE_MAP['0F'], 'SECURED (card/ISD) / PERSONALIZED (SD)'); + assert.strictEqual(LIFECYCLE_MAP['1F'], 'App-specific (app)'); assert.strictEqual(LIFECYCLE_MAP['7F'], 'CARD_LOCKED'); assert.strictEqual(LIFECYCLE_MAP['FF'], 'TERMINATED'); - assert.strictEqual(LIFECYCLE_MAP['83'], 'LOCKED (SD)'); + assert.strictEqual(LIFECYCLE_MAP['83'], 'LOCKED'); + assert.strictEqual(LIFECYCLE_MAP['8F'], 'LOCKED'); }); test('TS 51.011 SIM families resolve (94xx/98xx/92xx/9Exx/9Fxx)', () => { diff --git a/pyproject.toml b/pyproject.toml index 702a002..d6050fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.50" +version = "3.6.51" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index c803860..acf0551 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.6.50' +VERSION = '3.6.51' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1479,13 +1479,18 @@ 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): +def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex, silent=False): """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']).""" + (the step record is the last element of state['steps']). + + `silent=True` (the NV footprint reads) leaves no step record and no + failure note behind: the card's answer is only used by the caller, and a + rejected read must never fail the operation. The decoded step record is + still available as `state['last_step']`.""" spi1, spi2 = sp['spi1'], sp['spi2'] try: sp_hex, _ = _build_secured_packet(spi1, spi2, sp['kic'], sp['kid'], sp['tar'], @@ -1496,9 +1501,10 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex): sys.stderr.write('RAM C-APDU (%s): %s\n' % (step_name, apdu_hex)) sys.stderr.write('RAM SECURED-PACKET (%s): %s\n' % (step_name, sp_hex)) except ValueError as e: - state['encode_error'] = str(e) - state['steps'].append({'name': step_name, 'por_status': 'encode_error', - 'sw': str(e)}) + if not silent: + 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 # Capture the PoR from either transport: inline in the ENVELOPE response @@ -1516,10 +1522,11 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex): 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')}) + if not silent: + 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 @@ -1541,7 +1548,9 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex): 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) + state['last_step'] = step + if not silent: + 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 '', @@ -1550,7 +1559,8 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex): 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) + if not silent: + state['failure']['error'] = '%s: %s' % (step_name, step_error) return False return True finally: @@ -1558,6 +1568,41 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex): scc._tp.proactive_handler = old_proactive +def _ram_free_nv(por_data_hex): + """Free non-volatile memory (bytes) from a GET DATA FF21 R-APDU, or None. + Reuses the FF21 decoder (TS 102 226 8.2.1.7.2: '81' applet count / + '82' free NV / '83' free volatile).""" + try: + rapdu = bytes.fromhex(por_data_hex or '') + except ValueError: + return None + mem = _scp81_decode_memory(rapdu) + return mem.get('free_nv') if mem else None + + +def _ram_read_ff21(server, scc, sp, state, ram_format, label): + """Best-effort GET DATA FF21 (Extended Card Resources) read around an + install: returns the free non-volatile memory in bytes, or None (a card + without FF21 or a rejected read must never fail the operation). The read + is sent through the normal step sender with `silent=True`, so it consumes + and advances the counter correctly but leaves no step record or failure + note behind.""" + apdu = _ram_format_apdu('80CAFF2100', ram_format) + if not _ram_send_gp_apdu(server, scc, sp, state, label, apdu, silent=True): + return None + step = state.get('last_step') or {} + return _ram_free_nv(step.get('por_data')) + + +def _ram_nv_fields(nv_before, nv_after): + """NV footprint fields for the install responses: the free-NV values and + their delta (None when the card has no FF21 readout).""" + if nv_before is None and nv_after is None: + return {} + delta = (nv_before - nv_after) if (nv_before is not None and nv_after is not None) else None + return {'nv_before': nv_before, 'nv_after': nv_after, 'nv_delta': delta} + + def _parse_response_scripting(data): """Parse a Response Scripting template (TS 102 226 5.2.2, tables 5.10/5.10a): `AB ` (definite) or `AF 80 ... 00 00` (indefinite), @@ -6765,6 +6810,8 @@ class PysimHandler(BaseHTTPRequestHandler): # Live progress for the PWA's operation modal (the request holds # _CARD_LOCK until the chain is done; /api/status carries this) _ram_progress_begin('install-cap', len(seq) + 1) + nv_before = None + nv_after = None try: # RAM command format: detected per operation (a read-only # probe step) unless the caller pinned compact/expanded. @@ -6774,6 +6821,10 @@ class PysimHandler(BaseHTTPRequestHandler): ram_format = _ram_detect_format(self.server, scc, sp_state, state) sys.stderr.write('RAM-INSTALL: %d APDUs (INSTALL / %d x LOAD / INSTALL, %s) loadfile_aid=%s\n' % ( len(seq), len(seq) - 2, ram_format, loadfile_aid)) + # NV footprint: free non-volatile memory before the chain + # (best effort - a card without FF21 skips it). + nv_before = _ram_read_ff21(self.server, scc, sp_state, state, + ram_format, 'NV before') for apdu_idx, gp_apdu in enumerate(seq): if apdu_idx == 0: @@ -6786,6 +6837,11 @@ class PysimHandler(BaseHTTPRequestHandler): if not _ram_send_gp_apdu(self.server, scc, sp_state, state, step_name, _ram_format_apdu(gp_apdu, ram_format)): + # What the failed attempt consumed (a rejected load + # may keep or discard its partial file - the delta + # tells which). + nv_after = _ram_read_ff21(self.server, scc, sp_state, state, + ram_format, 'NV after') resp = {'success': False, 'steps': steps, 'failed_step': len(steps), 'error': (state['encode_error'] or state['failure'].get('error') or ('%s failed' % step_name)), @@ -6794,9 +6850,13 @@ class PysimHandler(BaseHTTPRequestHandler): 'load_block_size': block_size, 'load_block_size_requested': block_size_req, 'load_block_size_auto': not block_size_req} + resp.update(_ram_nv_fields(nv_before, nv_after)) self._send_json(resp) self._log_resp(resp) return + # NV footprint: what the successful chain consumed. + nv_after = _ram_read_ff21(self.server, scc, sp_state, state, + ram_format, 'NV after') finally: _ram_progress_end() @@ -6805,6 +6865,7 @@ class PysimHandler(BaseHTTPRequestHandler): 'ram_format': ram_format, 'load_block_size': block_size, 'load_block_size_requested': block_size_req, 'load_block_size_auto': not block_size_req} + resp.update(_ram_nv_fields(nv_before, nv_after)) sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % ( loadfile_aid, module_aid, state['cntr'])) self._send_json(resp) diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py index 21e3d04..2fc0dfb 100644 --- a/tests/test_fetch_routes.py +++ b/tests/test_fetch_routes.py @@ -16,6 +16,7 @@ import threading import unittest import urllib.error import urllib.request +from unittest import mock from pysim_simple_server.__main__ import _build_http_server from pysim_simple_server.server import PysimHandler, _CARD_FREE_GET, _CARD_LOCK @@ -222,5 +223,91 @@ class CapCompatHttpTests(unittest.TestCase): self.assertIn('import probe', resp['error']) +class RamInstallNvFootprintHttpTests(unittest.TestCase): + """The RAM install response carries the measured NV footprint (v3.6.51): + the server reads GET DATA FF21 before and after the chain (best effort) + and reports the free-NV values and their delta.""" + + def setUp(self): + from pysim_simple_server import server as srv + self.srv = srv + self.server = _build_http_server('127.0.0.1', 0, PysimHandler) + self.server.log_requests = False + self.server.app = None + self.server.sl = None + self.server.scc = object() + self.server.sms_oa = '12345' + self.server.sms_sc = '12345678912' + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.port = self.server.server_address[1] + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + + def _post(self, path, body): + req = urllib.request.Request( + 'http://127.0.0.1:%d%s' % (self.port, path), + data=json.dumps(body).encode(), + headers={'Content-Type': 'application/json'}, method='POST') + try: + with urllib.request.urlopen(req, timeout=5) as res: + return res.status, json.loads(res.read() or b'{}') + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or b'{}') + + def _install(self, fail_at=None, nv_before=50000, nv_after=20000): + def fake_send(server, scc, sp, state, name, apdu, silent=False): + if fail_at == name: + state['steps'].append({'name': name, 'por_status': 'por_error', 'por_sw': '6A88'}) + return False + state['steps'].append({'name': name, 'por_status': 'por_ok', 'por_sw': '9000'}) + return True + + def fake_read(server, scc, sp, state, ram_format, label): + return nv_before if label == 'NV before' else nv_after + + patches = [ + mock.patch.object(self.srv, '_cap_parse', + lambda cap_hex: ('F0414C46416101', 'F0414C4641610101', b'\x01\x02')), + mock.patch.object(self.srv, '_cap_apdu_sequence', + lambda *a, **k: ['80E60200', '80E80000', '80E60000']), + mock.patch.object(self.srv, '_ram_detect_format', lambda *a, **k: 'compact'), + mock.patch.object(self.srv, '_ram_send_gp_apdu', fake_send), + mock.patch.object(self.srv, '_ram_read_ff21', fake_read), + ] + for p in patches: + p.start() + try: + return self._post('/api/ram-install', {'cap_hex': 'AA'}) + finally: + for p in reversed(patches): + p.stop() + + def test_success_reports_the_nv_delta(self): + status, resp = self._install() + self.assertEqual(status, 200, resp) + self.assertTrue(resp['success'], resp) + self.assertEqual(resp['nv_before'], 50000) + self.assertEqual(resp['nv_after'], 20000) + self.assertEqual(resp['nv_delta'], 30000) + + def test_failed_install_still_reports_the_nv_delta(self): + status, resp = self._install(fail_at='LOAD (1/1)') + self.assertEqual(status, 200, resp) + self.assertFalse(resp['success'], resp) + self.assertEqual(resp['failed_step'], 2) + self.assertEqual(resp['nv_before'], 50000) + self.assertEqual(resp['nv_after'], 20000) + self.assertEqual(resp['nv_delta'], 30000) + + def test_a_card_without_ff21_has_no_nv_fields(self): + status, resp = self._install(nv_before=None, nv_after=None) + self.assertEqual(status, 200, resp) + self.assertTrue(resp['success'], resp) + for key in ('nv_before', 'nv_after', 'nv_delta'): + self.assertNotIn(key, resp) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 6c3b0d1..c89e787 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -37,6 +37,9 @@ from pysim_simple_server.server import ( _por_remote_sw, _ram_next_cntr, _ram_format_apdu, + _ram_free_nv, + _ram_read_ff21, + _ram_nv_fields, _wrap_expanded_apdu, _ram_remote_sw_ok, _sms_submit_por, @@ -1585,6 +1588,90 @@ class RamSendGpApduLoggingTest(unittest.TestCase): self.assertIn('RAM SECURED-PACKET (LOAD (1/2)): AABB', out) +class RamNvFootprintTests(unittest.TestCase): + """GET DATA FF21 reads around an install (v3.6.51): the free-NV values and + their delta. The reads are best-effort - a card without FF21 (or a + rejected read) must never fail the operation.""" + + def test_free_nv_from_ff21_response(self): + # TS 102 226 8.2.1.7.2: '81' applet count, '82' free NV, '83' free + # volatile (the length byte follows FF21). + data = 'FF210C' + '810102' + '820300C5D6' + '83020064' + self.assertEqual(_ram_free_nv(data), 0xC5D6) + # CR-set tag variants (0x01/0x02/0x03) decode the same + self.assertEqual(_ram_free_nv('FF210C' + '010102' + '020300C5D6' + '03020064'), 0xC5D6) + # not an FF21 response / empty / garbage + self.assertIsNone(_ram_free_nv('6A88')) + self.assertIsNone(_ram_free_nv('')) + self.assertIsNone(_ram_free_nv('not-hex')) + + def test_nv_fields(self): + self.assertEqual(_ram_nv_fields(None, None), {}) + self.assertEqual(_ram_nv_fields(50000, 20000), + {'nv_before': 50000, 'nv_after': 20000, 'nv_delta': 30000}) + # a freed delta is negative (memory released by the operation) + self.assertEqual(_ram_nv_fields(100, 150), + {'nv_before': 100, 'nv_after': 150, 'nv_delta': -50}) + # only one read available: the delta stays None + self.assertEqual(_ram_nv_fields(100, None), + {'nv_before': 100, 'nv_after': None, 'nv_delta': None}) + + def test_read_ff21_uses_the_silent_sender(self): + from pysim_simple_server import server as srv + + calls = [] + + def fake_send(server, scc, sp, state, name, apdu, silent=False): + calls.append((name, apdu, silent)) + if not silent: + state['steps'].append({'name': name}) + state['last_step'] = {'por_data': 'FF210C810102820300C5D683020064'} + return True + + state = {'steps': [], 'encode_error': None, 'failure': {}, 'cntr': '0000000001'} + with mock.patch.object(srv, '_ram_send_gp_apdu', fake_send): + nv = _ram_read_ff21(None, None, {}, state, 'expanded', 'NV before') + self.assertEqual(nv, 0xC5D6) + self.assertEqual(calls, [('NV before', _wrap_expanded_apdu('80CAFF2100'), True)]) + self.assertEqual(state['steps'], []) + self.assertEqual(state['failure'], {}) + + def test_read_ff21_returns_none_when_the_read_fails(self): + from pysim_simple_server import server as srv + + def fake_send(server, scc, sp, state, name, apdu, silent=False): + return False + + state = {'steps': [], 'encode_error': None, 'failure': {}, 'cntr': '0000000001'} + with mock.patch.object(srv, '_ram_send_gp_apdu', fake_send): + self.assertIsNone(_ram_read_ff21(None, None, {}, state, 'compact', 'NV after')) + + def test_silent_send_leaves_no_step_or_failure(self): + import contextlib + import io + from pysim_simple_server import server as srv + + class FakeServer: + sms_oa = '12345' + sms_sc = '12345678912' + + sp = {'spi1': '16', 'spi2': '01', 'kic': '25', 'kid': '25', 'tar': '000000', + 'kic_key': 'AA', 'kid_key': 'BB', 'include_cpi': True} + state = {'steps': [], 'encode_error': None, 'failure': {}, 'cntr': '0000000001'} + with mock.patch.object(srv, '_build_secured_packet', lambda *a, **k: ('AABB', {})), \ + mock.patch.object(srv, '_send_secured_packet', + lambda *a, **k: {'success': False, 'error': 'stub'}): + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + ok = srv._ram_send_gp_apdu(FakeServer(), object(), sp, state, + 'NV before', '80CAFF2100', silent=True) + self.assertFalse(ok) + self.assertEqual(state['steps'], []) + self.assertEqual(state['failure'], {}) + self.assertIsNone(state['encode_error']) + self.assertIn('RAM C-APDU (NV before): 80CAFF2100', buf.getvalue()) + + class RamCommandFormatTests(unittest.TestCase): """RAM command format detection helpers (TS 102 226 5.2.1, v3.6.24)."""
OperationDescription
Explore Card (all GP data)Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 (Extended Card Resources: the number of installed applications and the free non-volatile/volatile memory). If a card rejects the compact listing (P2.b2=0, deprecated by GP) the queries are retried in the expanded E3 form; the Fetch expanded registry details (load-file / SD associations) checkbox also requests it as an extra pass and merges the expanded-only fields (Load File AID, Security Domain AID, module lists) — it costs extra secured packets and some cards reject the expanded query, so it is off by default and remembered per browser. Results appear in an explorer view (the success line stays minimal; the counts are in the section headings) with per-item Delete buttons and the detected RAM command format (hover for what compact vs expanded means).
Explore Card (all GP data)Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 (Extended Card Resources: the number of installed applications and the free non-volatile/volatile memory). The compact listing carries the AID, life cycle and privileges (the legacy format has no version); if a card rejects it (P2.b2=0, deprecated by GP) the queries are retried in the expanded E3 form, and the Fetch expanded registry details (load-file / SD associations) checkbox also requests it as an extra pass and merges the expanded-only fields: the Executable Load File version, Load File AID, Security Domain AID, module lists, the applet STK menu entries (SCP Registry Data, TS 102 226 8.2.1.6) and any other registry data objects the card returns (shown raw, never dropped) — it costs extra secured packets and some cards reject the expanded query, so it is off by default and remembered per browser. Results appear in an explorer view (the success line stays minimal; the counts are in the section headings) with per-item Delete buttons and the detected RAM command format (hover for what compact vs expanded means).
Install Package (.cap file)Sends a .cap file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the LOAD block size (1–240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large .cap simply takes several SMS. As soon as the file is selected it is validated and its memory requirements are estimated in the form: the NVRAM requirement (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the libraries the CAP is linked against (its Import component: library name, AID and the minimum version ≥ x.y, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) — grouped under the Package, Requires, Memory and Components headings. The Platform line names the minimum Java Card level the card must provide (derived from the javacard.framework import; the header int flag additionally requires int support, Java Card 2.2+) and the ETSI release the UICC API imports come from (uicc.toolkit version → REL-N, per the workspace sim-tools corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the Import component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. completes Import) — a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and Execute stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 — card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested C6 follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data).
CAP compatibility testAnswers one question without installing anything: can this card satisfy the CAP's import list? The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the Import component — the point where the JCRE verifies the import list — then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The Import versions to test list is pre-filled from the CAP analysis: AID=version rewrites an import's requested version, +AID=version appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at ≥ that version), and Set all 0.0 drops every requirement. Two-step use: press Set all 0.0 — if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the LOAD/import gate only: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install].