From 755470e4ba08f7c882018f7f3f8e5e2e4290bed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 29 Sep 2026 03:41:07 +0300 Subject: [PATCH] feat: RAM-install import probe for LOAD rejections (v3.6.42) Cards that cannot satisfy the CAP's import list reject the LOAD as soon as the Import component is complete (live 2026-09-29: 6438/6985 in the block containing its last byte, on several cards incl. an eUICC profile whose ISD is not the default AID and which does not answer GET DATA 0066 - no direct platform-version readout exists). _probe_import_versions (pure) rewrites the Import component's two version bytes per entry in the assembled load file: same length, component sizes, Directory and offsets untouched; unknown AIDs and bad versions are rejected. /api/ram-install takes probe_imports ({'all': '0.0'} or per-AID), echoes the applied changes in the response and logs them. The PWA's RAM install form gains a collapsed Import probe fieldset (checkbox, AID=version lines prefilled from the CAP analysis, 'Set all 0.0') and the result line reports the applied changes. Diagnostic only - never used for a real install. Also verified _cap_parse's component order against the reference TCA load trace (Header, Directory, Import, Applet, Class, Method, StaticField, ConstantPool, RefLocation, Descriptor - Import before Applet, not ascending tag order), so the order is correct and the import list remains the prime suspect. sw cache simple-v315; tests: 530 Python / 676 Node. --- frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 92 +++++++++++++++++++++++++++++-- frontend/sw.js | 2 +- frontend/tests/capmem.test.js | 6 +- frontend/tests/ram.test.js | 18 +++++- pyproject.toml | 2 +- pysim_simple_server/server.py | 101 +++++++++++++++++++++++++++++++++- tests/test_cap_memory.py | 74 ++++++++++++++++++++++++- 9 files changed, 284 insertions(+), 15 deletions(-) diff --git a/frontend/help-ru.html b/frontend/help-ru.html index f9a0fb1..d2713fe 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -321,7 +321,7 @@ ОперацияОписание Обзор карты (все данные GP)Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 (расширенные ресурсы карты: число установленных приложений и свободная энергонезависимая/энергозависимая память). Если карта отвергает компактный формат листинга (P2.b2=0, устаревший по GP), запросы повторяются в расширенном формате E3; флажок Запрашивать расширенные данные реестра (E3) дополнительно запрашивает их и объединяет расширенные поля (привилегии, связи ELF/SD, списки модулей). Результаты отображаются в обзоре с кнопками Удалить для каждого элемента и определённым форматом команд RAM. - Установка пакета (.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+): 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 ×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+): JCRE проверяет каждый импорт по своим пакетам при верификации load-файла (JC VM spec 4.5.2), поэтому карта со старой платформой отклоняет LOAD сразу после завершения компонента Import (в строке шага виден remote SW карты и компонент, который завершил блок, например завершает Import) — меньший размер блока LOAD помогает только если блок превышает буфер карты. Набор «Проба импортов» (диагностика) переписывает запрошенные версии импортов CAP перед загрузкой — все записи в 0.0 или по одной: карта проверяет этот список при верификации load-файла, поэтому это показывает, является ли он причиной отказа, а перебором — какой пакет и какую версию карта не поддерживает. Меняются только два байта версии на запись (длина load-файла, размеры компонентов и все смещения остаются прежними), проба никогда не используется для настоящей установки, а применённые изменения видны в строке результата и в журнале сервера. Повреждённый файл не проходит анализ, и Execute остаётся недоступной до успешного анализа. Оценка носит справочный характер и никогда не используется при установке: NVRAM — это образ кода плюс постоянные данные (сумма в стиле C6+C8 по GP Table 11-48; управление памятью карты, округление и запись в реестре не учитываются, а образ статических полей входит в обе части); рекомендуемый C6 следует за load-файлом, а рекомендуемые квоты — это десятичные значения в байтах (шестнадцатеричное значение в скобках — это кодирование параметров установки C7/C8: C6 — энергонезависимый код, C7 — энергозависимая RAM, C8 — энергонезависимые данные). diff --git a/frontend/help.html b/frontend/help.html index 47bc700..faa40c0 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -320,7 +320,7 @@ 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 Query expanded registry details (E3) checkbox also requests it as an extra pass and merges the expanded-only fields (privileges, ELF/SD associations, module lists). Results appear in an explorer view with per-item Delete buttons and the detected RAM command format. - 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+): 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). + 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+): 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. The Import probe fieldset (diagnostic) rewrites the CAP's requested import versions before the load — all entries to 0.0, or one at a time: since the card checks that list while it verifies the load file, this shows whether it is the rejection cause and, by bisection, which package and version the card lacks. Only the two version bytes per entry change (the load file length, the component sizes and every offset stay untouched), the probe is never used for a real install, and the applied changes are reported in the result line and the server log. 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). diff --git a/frontend/index.html b/frontend/index.html index d2d3882..3371ee4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -713,6 +713,20 @@ CAP details
+
+ Import probe (diagnostic) + + +
@@ -1732,7 +1746,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.6.41'; +const SIMPLE_VERSION = '3.6.42'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -8602,7 +8616,7 @@ async function capAnalyzeFile(file, kind) { if (resp && resp.ok) { st.status = 'ok'; st.memory = resp.memory || {}; - if (kind === 'ram') ramApplyCapToolkitMode(); + if (kind === 'ram') { ramApplyCapToolkitMode(); ramProbeFill(st.memory); } } else { st.status = 'error'; st.error = (resp && resp.error) || t('Analysis failed'); @@ -9443,12 +9457,64 @@ function ramInstallFailHint(data, mem) { return hint; } +// ===== Import probe (diagnostic, v3.6.42) ===== +// The card checks the CAP's import list while it verifies the load file, so +// rewriting the requested versions shows whether that list is what it refuses +// - and, by lowering one entry at a time, which package and version it lacks. + +// 'AID=version' per line (or 'all=x.y'); blank/comment lines are ignored. +// Pure: returns {map} or {error}. +function ramProbeParse(text) { + const out = {}; + for (const rawLine of String(text || '').split('\n')) { + const line = rawLine.split('#')[0].trim(); + if (!line) continue; + const m = /^([0-9a-fA-F]{2,32}|all)\s*=\s*(\d{1,3})\.(\d{1,3})$/.exec(line); + if (!m) return { error: t('Invalid line:') + ' ' + line }; + const aid = m[1].toLowerCase() === 'all' ? 'all' : m[1].toUpperCase(); + out[aid] = m[2] + '.' + m[3]; + } + if (!Object.keys(out).length) return { error: t('No import versions given') }; + return { map: out }; +} + +function ramProbeChanged() { + const on = document.getElementById('ram-probe-enable').checked; + document.getElementById('ram-probe-body').classList.toggle('hidden', !on); +} + +// Prefill from the analysed CAP's import list unless the user edited it. +function ramProbeFill(mem) { + const el = document.getElementById('ram-probe-versions'); + if (!el || (el.dataset && el.dataset.manual)) return; + const imports = (mem && mem.imports) || []; + el.value = imports.map(im => im.aid + '=' + (Number(im.major) || 0) + '.' + (Number(im.minor) || 0)).join('\n'); +} + +function ramProbeAllZero() { + const el = document.getElementById('ram-probe-versions'); + if (!el) return; + el.value = String(el.value || '').split('\n').map(line => { + const m = /^([0-9a-fA-F]{2,32}|all)\s*=/.exec(line.trim()); + return m ? m[1] + '=0.0' : line; + }).join('\n'); + el.dataset.manual = '1'; +} + async function ramInstallCap(sp) { const fileInput = document.getElementById('ram-cap-file'); const file = fileInput.files[0]; if (!file) { alert(t('Select a .cap file')); return; } if (file.size > 48 * 1024) { alert(t('CAP file exceeds 48 kB limit')); return; } + // Import probe (diagnostic): validated before the modal opens + let probe = null; + if (document.getElementById('ram-probe-enable').checked) { + const parsed = ramProbeParse(document.getElementById('ram-probe-versions').value); + if (parsed.error) { alert(parsed.error); return; } + probe = parsed.map; + } + const capPkg = (_capAnalysis.ram.memory && _capAnalysis.ram.memory.package_aid) || ''; ramOpModalBegin(t('Installing package') + (capPkg ? ' \u2014 ' + capPkg : '')); ramOpModalStatus(t('Reading CAP file...')); @@ -9484,6 +9550,7 @@ async function ramInstallCap(sp) { if (!(n >= 1 && n <= 240)) { alert(t('LOAD block size must be 1..240')); return; } body.load_block_size = n; } + if (probe) body.probe_imports = probe; const data = await pysimFetch('/api/ram-install', body); @@ -9554,13 +9621,21 @@ function ramShowInstallResult(data, okText, capMem) { // even a failed install leaves the accepted steps behind, and replaying // their counter would make the card reject the next attempt. if (data.final_cntr) ramSaveCntr(data.final_cntr); + // The import probe's applied changes (diagnostic runs only) + let probeNote = ''; + const pa = data.probe_imports; + if (pa && pa.length) { + const targets = Array.from(new Set(pa.map(p => p.to))); + probeNote = ' \u2014 ' + t('probe') + ': ' + pa.length + ' ' + t('imports') + + ' \u2192 ' + (targets.length === 1 ? targets[0] : t('mixed')); + } if (data.success) { - resultEl.textContent = okText || t('OK'); + resultEl.textContent = (okText || t('OK')) + probeNote; resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600'); } else { const hint = ramInstallFailHint(data, capMem || null); resultEl.textContent = t('Install FAILED at step:') + ' ' + data.failed_step + (data.error ? ' (' + data.error + ')' : '') + - (hint ? ' \u2014 ' + t('Hint:') + ' ' + hint : ''); + probeNote + (hint ? ' \u2014 ' + t('Hint:') + ' ' + hint : ''); resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600'); } ramOpModalFinish(resultEl.textContent, !!data.success, txt); @@ -17367,6 +17442,15 @@ const LANG_RU = { 'Install OK': 'Установка OK', 'Install FAILED at step:': 'Установка не удалась на шаге:', 'Hint:': 'Подсказка:', + 'Import probe (diagnostic)': 'Проба импортов (диагностика)', + "Rewrite the CAP's import versions before the load": 'Переписать версии импортов CAP перед загрузкой', + 'Set all 0.0': 'Все 0.0', + 'Diagnostic only: the card checks the import list while loading, so lowering the versions shows whether it is the rejection cause.': 'Только диагностика: карта проверяет список импортов при загрузке, поэтому понижение версий показывает, является ли он причиной отказа.', + 'Invalid line:': 'Некорректная строка:', + 'No import versions given': 'Версии импортов не заданы', + 'probe': 'проба', + 'imports': 'импортов', + 'mixed': 'разные', 'completes': 'завершает', 'inside': 'внутри', 'the failed step completes': 'неудавшийся шаг завершает', diff --git a/frontend/sw.js b/frontend/sw.js index abc2fb3..22d4e5c 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v314'; +const CACHE = 'simple-v315'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/capmem.test.js b/frontend/tests/capmem.test.js index 1d27868..9885fc0 100644 --- a/frontend/tests/capmem.test.js +++ b/frontend/tests/capmem.test.js @@ -42,9 +42,11 @@ eval(extractFunc(html, 'capMemBytes')); eval(extractFunc(html, 'capQuotaText')); eval(extractFunc(html, 'capMemHtml')); eval(extractFunc(html, 'capAnalyzeFile', true)); -// The RAM CAP analysis also applies the detected toolkit mode (DOM-bound; its -// own logic is covered in ram.test.js) - stub it for these state tests. +// The RAM CAP analysis also applies the detected toolkit mode and prefills +// the import-probe field (DOM-bound; their own logic is covered in +// ram.test.js) - stub them for these state tests. globalThis.ramApplyCapToolkitMode = () => {}; +globalThis.ramProbeFill = () => {}; globalThis.esc = s => s; globalThis.t = s => s; diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 37300b8..cd809d4 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -22,7 +22,7 @@ function extractFunc(src, name) { } // Extract chain builder functions and dependencies -const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramGetStatusApdu', 'ramDeleteApdu', +const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramGetStatusApdu', 'ramDeleteApdu', 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry', 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText', @@ -772,3 +772,19 @@ test('ramDeleteFromExplorer advances but does not re-explore on a failed remote assert.strictEqual(calls.explored, null, 'a failed DELETE must not re-explore'); unstubDeleteEnv(); }); + +test('ramProbeParse reads AID=version lines', () => { + globalThis.t = s => s; + let r = ramProbeParse('A0000000620101=0.0\n\n# comment\n0102030405 = 1.2'); + assert.deepStrictEqual(r.map, { 'A0000000620101': '0.0', '0102030405': '1.2' }); + // 'all' and lower-case AIDs are accepted, normalized to upper case + r = ramProbeParse('all=0.0'); + assert.deepStrictEqual(r.map, { all: '0.0' }); + r = ramProbeParse('a0000000620101=1.3'); + assert.deepStrictEqual(r.map, { 'A0000000620101': '1.3' }); + // malformed lines and an empty field are errors + assert.ok(ramProbeParse('A0000000620101').error); + assert.ok(ramProbeParse('A0000000620101=x.y').error); + assert.ok(ramProbeParse('').error); + assert.ok(ramProbeParse(' \n# only comments\n').error); +}); diff --git a/pyproject.toml b/pyproject.toml index f362cdc..24248f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.41" +version = "3.6.42" 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 eff6e99..b306202 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.41' +VERSION = '3.6.42' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -845,6 +845,87 @@ def _cap_parse(cap_hex): return loadfile_aid, module_aid, loadfile_data +def _probe_import_versions(load_file_hex, overrides): + """Diagnostic probe: rewrite the version bytes of the load file's Import + component (JC VM spec 4.5.2 - the card must export every imported package + at >= the recorded version). + + The JCRE checks the import list while it verifies the load file, so a card + that cannot satisfy one of the CAP's export versions rejects the LOAD as + soon as the Import component is complete (live 2026-09-29: remote SW + 6438/6985 exactly in the block containing the component's last byte). + Lowering the versions shows whether that list is the cause and, by + bisection, which package the card refuses. Only the two version bytes per + entry change: the load file length, the component sizes, the Directory and + every offset stay untouched. + + `overrides` is {'all': 'x.y'} or {aid_hex: 'x.y'} (lower/upper case and + spaces are tolerated). Returns (patched_hex, applied) where `applied` + lists the entries actually changed. Diagnostic only - never used for a + real install.""" + data = bytearray.fromhex(load_file_hex or '') + if not data: + raise ValueError('empty load file') + + norm = {} + for key, value in (overrides or {}).items(): + k = str(key).replace(' ', '') + norm['all' if k.lower() == 'all' else k.upper()] = value + + def version_pair(text): + try: + major, minor = str(text).strip().split('.') + major, minor = int(major), int(minor) + except (TypeError, ValueError): + raise ValueError('invalid version %r (expected major.minor)' % (text,)) + if not (0 <= major <= 255 and 0 <= minor <= 255): + raise ValueError('version out of range: %r' % (text,)) + return minor, major # the CAP stores [minor][major] + + # Walk the components to the Import one (tag 0x04; sizes exclude the + # 3-byte component header). + start = None + off = 0 + while off + 3 <= len(data): + tag = data[off] + size = int.from_bytes(data[off + 1:off + 3], 'big') + if tag == 0x04: + start = off + 3 + end = start + size + break + off += 3 + size + if start is None: + raise ValueError('no Import component in the load file') + if end > len(data): + raise ValueError('truncated Import component') + count = data[start] + p = start + 1 + matched = 0 + applied = [] + for _ in range(count): + if p + 3 > end: + raise ValueError('truncated Import entry') + minor, major, aid_len = data[p], data[p + 1], data[p + 2] + aid_hex = bytes(data[p + 3:p + 3 + aid_len]).hex().upper() + if p + 3 + aid_len > end: + raise ValueError('truncated Import entry AID') + override = norm.get(aid_hex, norm.get('all')) + if override is not None: + matched += 1 + new_minor, new_major = version_pair(override) + if (new_minor, new_major) != (minor, major): + applied.append({'aid': aid_hex, + 'from': '%d.%d' % (major, minor), + 'to': '%d.%d' % (new_major, new_minor)}) + data[p] = new_minor + data[p + 1] = new_major + p += 3 + aid_len + if not matched: + raise ValueError('no import matched the probe (give AIDs from the ' + 'CAP analysis or "all")') + return data.hex().upper(), applied + + def _build_sms_tpdu(chunk_hex, chunk_total=1, chunk_num=1, oa_number='12345', include_cpi=True, chunk_ref=SCP80_CONCAT_REF): chunk = bytes.fromhex(chunk_hex) @@ -6499,6 +6580,21 @@ class PysimHandler(BaseHTTPRequestHandler): # Parse .cap file loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex) + # Optional diagnostic: rewrite the Import versions the card is + # asked to satisfy before the LOAD chain is built (v3.6.42) + probe_applied = None + probe = body.get('probe_imports') + if probe: + try: + loadfile_data, probe_applied = _probe_import_versions(loadfile_data, probe) + except ValueError as e: + resp = {'error': 'import probe: %s' % e} + self._send_json(resp, 400) + self._log_resp(resp) + return + sys.stderr.write('RAM-INSTALL: import probe applied: %s\n' + % json.dumps(probe_applied)) + # SCP80 params spi1 = body.get('spi1', '16') spi2 = body.get('spi2', '01') @@ -6579,6 +6675,7 @@ class PysimHandler(BaseHTTPRequestHandler): resp = {'success': False, 'steps': steps, 'failed_step': len(steps), 'error': (state['encode_error'] or state['failure'].get('error') or ('%s failed' % step_name)), + 'probe_imports': probe_applied, 'final_cntr': state['cntr'], 'ram_format': ram_format, 'load_file_aid': loadfile_aid, 'module_aid': module_aid, 'load_block_size': block_size, @@ -6592,7 +6689,7 @@ class PysimHandler(BaseHTTPRequestHandler): resp = {'success': True, 'steps': steps, 'load_file_aid': loadfile_aid, 'module_aid': module_aid, 'final_cntr': state['cntr'], - 'ram_format': ram_format, + 'ram_format': ram_format, 'probe_imports': probe_applied, 'load_block_size': block_size, 'load_block_size_requested': block_size_req, 'load_block_size_auto': not block_size_req} diff --git a/tests/test_cap_memory.py b/tests/test_cap_memory.py index c58ee8c..44059da 100644 --- a/tests/test_cap_memory.py +++ b/tests/test_cap_memory.py @@ -8,7 +8,7 @@ import unittest import zipfile from pysim_simple_server import capmem -from pysim_simple_server.server import _cap_info_body +from pysim_simple_server.server import _cap_info_body, _cap_parse, _probe_import_versions # ─── synthetic CAP builder ─────────────────────────────────────────────── @@ -26,7 +26,9 @@ def _u4(v): def _component(tag, payload): - return bytes([tag]) + _u2(len(payload) + 3) + payload + # Real CAP components carry the *payload* length (the 3-byte component + # header is not counted), e.g. the live Header is `01 00 11` + 17 bytes. + return bytes([tag]) + _u2(len(payload)) + payload def _header(aid, flags=0, name=None): @@ -161,6 +163,74 @@ def rich_cap(with_static=True): ) +def _import_entries(load_file): + """[(aid_hex, major, minor)] of the load file's Import component.""" + data = bytes.fromhex(load_file) if isinstance(load_file, str) else load_file + off = 0 + while off + 3 <= len(data): + tag = data[off] + size = int.from_bytes(data[off + 1:off + 3], 'big') + if tag == 0x04: + start = off + 3 + count = data[start] + p = start + 1 + out = [] + for _ in range(count): + minor, major, aid_len = data[p], data[p + 1], data[p + 2] + aid = bytes(data[p + 3:p + 3 + aid_len]).hex().upper() + out.append((aid, major, minor)) + p += 3 + aid_len + return out + off += 3 + size + raise AssertionError('no Import component') + + +class ImportProbeTests(unittest.TestCase): + """The RAM-install Import probe (v3.6.42, diagnostic only).""" + + OTHER_AID = bytes.fromhex('0102030405') + + def _load_file(self): + cap = build_cap(imports=[(3, 1, JAVACARD_FRAMEWORK), (0, 1, self.OTHER_AID)]) + _aid, _module, load = _cap_parse(cap.hex().upper()) + return load + + def test_probe_all_lowers_every_version_keeping_the_length(self): + load = self._load_file() + before = _import_entries(load) + self.assertEqual([(maj, min_) for _a, maj, min_ in before], [(1, 3), (1, 0)]) + patched, applied = _probe_import_versions(load, {'all': '0.0'}) + self.assertEqual(len(patched), len(load)) # same byte length + self.assertEqual(len(applied), 2) + self.assertEqual([(maj, min_) for _a, maj, min_ in _import_entries(patched)], + [(0, 0), (0, 0)]) + self.assertEqual(applied[0], + {'aid': JAVACARD_FRAMEWORK.hex().upper(), 'from': '1.3', 'to': '0.0'}) + + def test_probe_single_aid_leaves_the_others(self): + load = self._load_file() + patched, applied = _probe_import_versions( + load, {JAVACARD_FRAMEWORK.hex().lower(): '1.0'}) + self.assertEqual(applied, [{'aid': JAVACARD_FRAMEWORK.hex().upper(), + 'from': '1.3', 'to': '1.0'}]) + self.assertEqual([(maj, min_) for _a, maj, min_ in _import_entries(patched)], + [(1, 0), (1, 0)]) + # overrides that change nothing are matches, not errors + _same, applied2 = _probe_import_versions( + load, {JAVACARD_FRAMEWORK.hex().upper(): '1.3', '0102030405': '1.0'}) + self.assertEqual(applied2, []) + self.assertEqual(_same, load) + + def test_probe_rejects_unknown_aids_and_bad_versions(self): + load = self._load_file() + with self.assertRaises(ValueError): + _probe_import_versions(load, {'DEADBEEF': '0.0'}) + with self.assertRaises(ValueError): + _probe_import_versions(load, {'all': 'x.y'}) + with self.assertRaises(ValueError): + _probe_import_versions(load, {'all': '1.300'}) + + class TestCapAnalyzer(unittest.TestCase): def test_minimal_cap_reports_zero_code_and_applet_overhead(self): report, memory = capmem.analyze_bytes(build_cap())