From 82c573390603e88f19ebf40d6ab574d77eba529e 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: Mon, 5 Oct 2026 23:01:42 +0300 Subject: [PATCH] v3.20.0 (fix): the install-parameter composition - one System Specific Parameters EF Two reported SCP80/RAM issues, both confirmed against the specs: - docs/api.md called `stk_params` a "Hex CA TLV", but TS 102 226 8.2.1.3.2.1 puts the CA *inside* the System Specific Parameters EF ('EF'), and the PWA builds `EF CA ...` (the SIM access domain leads the CA value as `01 ` / `00`). A client following the doc sent a CA at the wrong level. - the RAM composition appended the STK part after a caller-composed C9+EF(quotas) prefix, producing TWO EF objects. The card read the first (the quotas) and ignored the CA: the install was accepted (por_ok) but the toolkit parameters were never registered, so no STK menu appeared (live 2026-10-05). Per GPD_SPE_013 v1.1 Table 6-5 (pySim's gen_install_parameters) the quotas (C7/C8) and the CA share ONE EF; an EA-form STK part is a sibling of EF (the reference TCA loader's `C9 00 EF 00 EA ...`). - server: `_compose_install_params()` (used by `_cap_install_apdu` -> `_cap_apdu_sequence`, hence /api/ram-install, /api/ram-install-app, /api/scp81/gen-install and the PWA's install script template) enforces the rule: a caller-composed EF plus an EF-form `stk_params`, a bare CA, a malformed EF and quotas next to a non-empty `install_params` are refused (400 / `{"ok": false}` with the fix named); with an empty `install_params` the quotas and an EF-form CA are composed into one EF (`C900 EF{C7?,C8?,CA}`), an EA part stays a sibling. - the documented `nv_quota`/`volatile_quota` fields are now real (they were named in the docs but implemented nowhere): integers composing C8/C7. - PWA: `composeRamInstallParams()` composes the same single EF itself (a CA-form STK field takes the quotas into its EF, the merged EF travels in `stk_params`), the RAM install bodies send it, the placeholder shows it with a note when the quotas moved into the STK EF, and a refused request (no steps) reports the error instead of "FAILED at step: undefined". - docs/api.md: the corrected `stk_params` description with both accepted shapes, the composition rule paragraph with the refusal cases, the fixed example (`C90000` -> `C900`), and the /api/ram-install-app and /api/scp81/gen-install cross-references. - tests: composition unit tests (merge/refusals/EA-sibling) and the ram-install-app 400 paths (Python); `ramQuotaTlvs`/`composeRamInstallParams`/ `ramInstallParamsCompose` pins incl. the CA merge (Node). Tests: 824 frontend + 740 Python; sw cache simple-v395. --- docs/api.md | 37 +++++-- frontend/index.html | 107 ++++++++++++++------ frontend/sw.js | 2 +- frontend/tests/stk_params.test.js | 67 ++++++++++--- pysim_simple_server/server.py | 156 ++++++++++++++++++++++++++---- tests/test_fetch_routes.py | 25 +++++ tests/test_ota_helpers.py | 57 +++++++++++ 7 files changed, 382 insertions(+), 69 deletions(-) diff --git a/docs/api.md b/docs/api.md index 91f77c3..e5b777c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -812,7 +812,7 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL { "cap_hex": "DECAFFED...", "sd_aid": "A000000003000000", - "install_params": "C90000", + "install_params": "C900", "stk_params": "", "nv_quota": 0, "volatile_quota": 0, @@ -830,14 +830,31 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL |---|---|---| | `cap_hex` | yes | Even-length hex of the `.cap` file (zipped Java Card CAP), max 48 kB (98304 hex chars) | | `sd_aid` | no | Security Domain AID for INSTALL[for load]; empty → default ISD `A000000003000000` | -| `install_params` | no | Hex C9 TLV install parameters; if empty, `gen_install_parameters()` is used with the quota/stk params | -| `stk_params` | no | Hex CA TLV (TS 102 226 §8.2.1.3.2.1) for SIM toolkit app-specific params | -| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) for `gen_install_parameters()` | +| `install_params` | no | Hex of the install-parameter field — the mandatory `C9` TLV (application-specific parameters; `C900` when empty), the System Specific Parameters `EF`, extra raw TLVs. When empty the server **composes** it from `volatile_quota`/`nv_quota` and `stk_params` (see below) | +| `stk_params` | no | The STK part appended to the install parameters: the **EF-wrapped** CA (`EF CA …`, TS 102 226 §8.2.1.3.2.1 — the SIM access domain leads the CA value as `01 ` / `00`) or the UICC template (`EA 80 … [81 …]`). A bare `CA` is refused (it belongs inside the EF) | +| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) coded as `C8` / `C7` in the install EF; only composed when `install_params` is empty | | `make_selectable` | no | If true (default), final INSTALL uses P1=`0C` (install + make selectable) | | `load_block_size` | no | Bytes of load-file payload per LOAD APDU, 1–240 (default 240 when omitted). SCP80 concatenation carries a secured packet larger than one SMS over up to 5 SMs, so the block size is no longer clamped to fit a single SMS. | | `ram_format` | no | RAM command format: `auto` (default — the read-only probe decides, recorded as `FORMAT CHECK (...)` steps), `compact` or `expanded` to pin it | | `includeCpi` | no | As in `/api/send-ota`: `true` (default) marks the delivered SMS with the OTA CPI IE; `false` drops it (the full 140-octet single-SM budget then applies) | +**Install-parameter composition (TS 102 226 §8.2.1.3.2.1; GPD_SPE_013 v1.1 +Table 6-5).** The memory quotas (`C7`/`C8`) and an EF-form STK part (the CA) +must share **one** System Specific Parameters `EF`; an EA-form STK part is a +sibling of `EF` (the reference TCA loader sends `C9 00 EF 00 EA …`). A +second `EF` makes the card read the quotas and ignore the CA (live +2026-10-05: `por_ok` but no toolkit registration and no menu), so the server +refuses the combination with a 400 and names the fix: + +- caller-composed `install_params` that already carry an `EF` **plus** an + EF-form `stk_params` → refused; +- `nv_quota`/`volatile_quota` next to non-empty `install_params` → refused; +- with an empty `install_params` the server composes + `C900 EF{C7?,C8?,CA}` (or `C900 EF{quotas} EA…` for the EA form). + +The PWA composes the same single EF itself, so its RAM installs never hit the +refusal. + **Response (success):** ```json {"success": true, "failed_step": null, @@ -922,7 +939,8 @@ Run the single **INSTALL [for install]** (or **INSTALL [for make selectable]**) | `module_aid` | cond | Executable module / applet class AID (required for `mode: "install"`) | | `instance_aid` | no | Application AID; empty → the module AID | | `privileges` | no | Hex privileges value (1 or 3 bytes), default `00` | -| `install_params` / `stk_params` | no | As in `/api/ram-install` (the PWA composes `C9`+`EF`+raw and appends the STK part) | +| `install_params` / `stk_params` | no | As in `/api/ram-install`: the `C9` field (with any extra TLVs) and the STK part; an EF-form STK part must not follow a caller-composed `EF` (the quotas and the CA share one `EF`, v3.20.0) | +| `nv_quota` / `volatile_quota` | no | As in `/api/ram-install`: composed into the install `EF` only when `install_params` is empty | | `ram_format` | no | As in `/api/ram-install`: `auto` (default — a read-only probe step decides), `compact` or `expanded` | | `includeCpi` | no | As in `/api/ram-install`: `true` (default) marks the delivered SMS with the OTA CPI IE | @@ -1502,10 +1520,15 @@ the returned list. The `.cap` is parsed server-side (same parser as ```json {"cap_hex": "504B0304...", "sd_aid": "A000000003000000", "privileges": "00", - "install_params": "", "stk_params": "", "make_selectable": true} + "install_params": "", "stk_params": "", "nv_quota": 0, "volatile_quota": 0, + "make_selectable": true} ``` -`sd_aid` empty = the ISD. Responds with `{"ok": true, "apdus": [...], +`sd_aid` empty = the ISD. `install_params`, `stk_params` and the +`nv_quota`/`volatile_quota` primitives follow the composition rule of +`/api/ram-install` (the quotas and an EF-form STK part share one System +Specific Parameters `EF`); an invalid combination answers +`{"ok": false, "error": "..."}`. Responds with `{"ok": true, "apdus": [...], "load_file_aid": ..., "module_aid": ...}`. ### `GET /api/scp81/script` diff --git a/frontend/index.html b/frontend/index.html index 468437e..c2e9eb4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2783,10 +2783,13 @@ function ramResetGrants() { // Pure SIM/UICC toolkit install-parameter builder shared by the RAM install // form (buildRcToolkitParams) and the RAM/GP chain rows (buildTkParams). -// `v` carries the field values; returns the CA/EA TLV hex, or null when a -// value cannot be coded: each TAR is 3 bytes (6 hex digits), and menu item -// identifiers 128..255 are reserved for the toolkit framework, so only -// 01..7F may be requested (TS 102 226 8.2.1.3.2.3). In EA mode `v.fsAccess` +// `v` carries the field values; returns the STK part of the install +// parameters - the EF-wrapped CA (`EF { CA ... }`, TS 102 226 8.2.1.3.2.1; +// composeRamInstallParams() merges the memory quotas into that EF) or the +// top-level EA template - or null when a value cannot be coded: each TAR is +// 3 bytes (6 hex digits), and menu item identifiers 128..255 are reserved for +// the toolkit framework, so only 01..7F may be requested (TS 102 226 +// 8.2.1.3.2.3). In EA mode `v.fsAccess` // adds the UICC file-access parameters (tag '81') and `v.adfAccess` extends // them with an ADF entry (AID from `v.adfAid`, default ADF.USIM); the SIM path // grants access via the CA Access Domain field instead. @@ -2888,6 +2891,7 @@ function updateStkParamsHex() { // field is edited again (this clears the flag). if (el.dataset) delete el.dataset.manual; // A rejected value must not vanish silently: say why the field is empty. + updateInstallParamsHex(); const hint = document.getElementById('ram-stk-hint'); if (!hint) return; if (tk === null) { @@ -2956,35 +2960,68 @@ function ramQuotaValue(n) { : '04' + v.toString(16).padStart(8, '0').toUpperCase(); } -// The applet-specific prefix of the install parameters, in the vendor's -// order: `C9 ` + `EF `. -// The server appends the STK (`EA`/`EF+CA`) part after it. C9 is mandatory -// in the Install Parameters (GP Table 11-49), so an empty field is sent as -// `C9 00` - like the reference tools; a prefix without C9 makes the card -// reject the data field with 6A80 (live 2026-09-28). -function ramParamsPrefix(c9Hex, c7, c8) { - const c9 = (c9Hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); - let out = 'C9' + berLenStr(c9.length / 2) + c9; - let ef = ''; - if (String(c7 ?? '') !== '' && !isNaN(parseInt(c7, 10))) ef += 'C7' + ramQuotaValue(c7); - if (String(c8 ?? '') !== '' && !isNaN(parseInt(c8, 10))) ef += 'C8' + ramQuotaValue(c8); - if (ef) out += 'EF' + berLenStr(ef.length / 2) + ef; +// The quota TLVs of the install EF: `C7 ` (volatile) and +// `C8 ` (non-volatile), in the vendor's order. +function ramQuotaTlvs(c7, c8) { + let out = ''; + if (String(c7 ?? '') !== '' && !isNaN(parseInt(c7, 10))) out += 'C7' + ramQuotaValue(c7); + if (String(c8 ?? '') !== '' && !isNaN(parseInt(c8, 10))) out += 'C8' + ramQuotaValue(c8); return out; } -function ramInstallParamsPrefix() { - return ramParamsPrefix( - document.getElementById('rc-c9').value, - document.getElementById('rc-quota-c7').value, - document.getElementById('rc-quota-c8').value); +// Compose the INSTALL [for install] parameters. The memory quotas (C7/C8) +// and an EF-form STK part (the CA) share ONE System Specific Parameters EF +// (TS 102 226 8.2.1.3.2.1; GPD_SPE_013 v1.1 Table 6-5 - pySim's +// gen_install_parameters); an EA-form (or opaque) STK part is a sibling of +// EF, like the reference TCA loader's `C9 00 EF 00 EA ...`. A second EF +// makes the card read the quotas and ignore the CA (live 2026-10-05: por_ok +// but no toolkit registration, no menu), so the combination is refused +// server-side: never send two. +// returns {install_params, stk_params} +function composeRamInstallParams(c9Hex, c7, c8, extraHex, stkHex) { + const c9 = (c9Hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + const extra = (extraHex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + const stk = (stkHex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + const quota = ramQuotaTlvs(c7, c8); + const installParams = 'C9' + berLenStr(c9.length / 2) + c9; + // C9 is mandatory (GP Table 11-49): an empty field is sent as `C9 00`. + const stkTlvs = stk ? parseTlvList(stk) : []; + const stkEf = (stkTlvs.length === 1 && stkTlvs[0].tag === 'EF' && + stkTlvs[0].raw === stk) ? stkTlvs[0] : null; + if (stkEf) { + // the quotas belong inside this EF; the STK field carries the merged + // EF and the server appends it (one EF in the final parameters) + const inner = quota + stkEf.value; + return { + install_params: installParams + extra, + stk_params: 'EF' + berLenStr(inner.length / 2) + inner, + quota_in_stk: !!quota, + }; + } + return { + install_params: installParams + (quota ? 'EF' + berLenStr(quota.length / 2) + quota : '') + extra, + stk_params: stk, + quota_in_stk: false, + }; +} + +// The RAM install form's composed install parameters. +function ramInstallParamsCompose() { + const val = id => (document.getElementById(id) || {}).value || ''; + return composeRamInstallParams( + val('rc-c9'), val('rc-quota-c7'), val('rc-quota-c8'), + val('ram-install-params-hex'), val('ram-stk-params')); } function updateInstallParamsHex() { - const prefix = ramInstallParamsPrefix(); const el = document.getElementById('ram-install-params-hex'); - // The field carries extra raw TLVs; the generated prefix is shown in the - // placeholder so the composed parameters stay visible. - el.placeholder = prefix ? prefix + ' …' : 'extra TLVs (optional)'; + if (!el) return; + const composed = ramInstallParamsCompose(); + // The field carries extra raw TLVs; the generated prefix (with an EF-form + // STK part already carrying the quotas) is shown in the placeholder. + let text = composed.install_params ? composed.install_params + ' …' : 'extra TLVs (optional)'; + if (composed.quota_in_stk) text += ' (' + t('quotas in the STK EF') + ')'; + el.placeholder = text; } // ===== GSM 7-bit alphabet ===== @@ -11210,14 +11247,14 @@ async function ramInstallCap(sp) { // never be sent silently. updateRcPriv(); + const composed = ramInstallParamsCompose(); const body = { cap_hex: capHex, preset_id: sp.preset_id, sd_aid: (document.getElementById('ram-sd-aid').value || '').replace(/[^0-9a-fA-F]/g, ''), privileges: (document.getElementById('rc-priv').value || '00').replace(/[^0-9a-fA-F]/g, ''), - install_params: (ramInstallParamsPrefix() + - (document.getElementById('ram-install-params-hex').value || '').replace(/[^0-9a-fA-F]/g, '')).toUpperCase(), - stk_params: (document.getElementById('ram-stk-params').value || '').replace(/[^0-9a-fA-F]/g, ''), + install_params: composed.install_params, + stk_params: composed.stk_params, make_selectable: document.getElementById('ram-make-sel').checked, spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid, tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey, @@ -11263,6 +11300,7 @@ async function ramInstallApp(sp, op) { if (document.getElementById('rc-toolkit-enable').checked && !(stkEl.dataset && stkEl.dataset.manual)) { updateStkParamsHex(); } + const composed = ramInstallParamsCompose(); const body = { mode: mode, preset_id: sp.preset_id, @@ -11270,9 +11308,8 @@ async function ramInstallApp(sp, op) { module_aid: module, instance_aid: instance, privileges: (document.getElementById('rc-priv').value || '00').replace(/[^0-9a-fA-F]/g, ''), - install_params: (ramInstallParamsPrefix() + - (document.getElementById('ram-install-params-hex').value || '').replace(/[^0-9a-fA-F]/g, '')).toUpperCase(), - stk_params: (document.getElementById('ram-stk-params').value || '').replace(/[^0-9a-fA-F]/g, ''), + install_params: composed.install_params, + stk_params: composed.stk_params, make_selectable: document.getElementById('ram-make-sel').checked, spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid, tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey, @@ -11719,6 +11756,10 @@ function ramShowInstallResult(data, okText, capMem) { if (data.success) { resultEl.textContent = (okText || t('OK')) + probeNote; resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600'); + } else if (!data.steps && data.error) { + // a refused request (400): nothing was sent to the card + resultEl.textContent = t('Install FAILED:') + ' ' + data.error; + resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600'); } else { const hint = ramInstallFailHint(data, capMem || null); resultEl.textContent = t('Install FAILED at step:') + ' ' + data.failed_step + (data.error ? ' (' + data.error + ')' : '') + @@ -20824,6 +20865,8 @@ const LANG_RU = { 'Step': 'Шаг', 'Install OK': 'Установка OK', 'Install FAILED at step:': 'Установка не удалась на шаге:', + 'Install FAILED:': 'Установка не удалась:', + 'quotas in the STK EF': 'квоты в EF STK-параметров', 'Hint:': 'Подсказка:', 'Set all 0.0': 'Все 0.0', 'CAP compatibility test': 'Проверка совместимости CAP', diff --git a/frontend/sw.js b/frontend/sw.js index e129576..56a87cd 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v394'; +const CACHE = 'simple-v395'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/stk_params.test.js b/frontend/tests/stk_params.test.js index 08cbde3..ef0b3cd 100644 --- a/frontend/tests/stk_params.test.js +++ b/frontend/tests/stk_params.test.js @@ -23,8 +23,9 @@ function extractFunc(src, name) { let code = ''; for (const fn of ['berLenStr', 'stkParamsBuild', 'buildRcToolkitParams', - 'updateStkParamsHex', 'adfAidFromFci', 'ramParamsPrefix', 'ramQuotaValue', - 'ramInstallParamsPrefix']) code += extractFunc(html, fn) + '\n'; + 'updateStkParamsHex', 'updateInstallParamsHex', 'adfAidFromFci', 'ramQuotaValue', 'ramQuotaTlvs', + 'composeRamInstallParams', 'ramInstallParamsCompose', + 'parseTlvList', 'parseBerLen']) code += extractFunc(html, fn) + '\n'; eval(code); const base = { mode: 'ea', priority: '0', timers: '0', textLen: '0', menus: '0', @@ -128,24 +129,61 @@ test('adfAidFromFci extracts the DF name (tag 84) from an FCI', () => { assert.strictEqual(adfAidFromFci('ZZZZ'), ''); }); -test('ramParamsPrefix composes C9 + EF (C7/C8) like the vendor scripts', () => { +test('composeRamInstallParams composes C9 + EF (C7/C8) like the vendor scripts', () => { // The vendor's working install (samples/uicc/applets/STK3): // C9 22 EF 08 C7 02 0000 C8 02 0000 EA ... // C9 is mandatory (GP Table 11-49): an empty field is sent as C9 00 - // without it the card rejects the data field with 6A80 (live 2026-09-28). - assert.strictEqual(ramParamsPrefix('', '', ''), 'C900'); - assert.strictEqual(ramParamsPrefix('082905112000012066', '', ''), + assert.strictEqual(composeRamInstallParams('', '', '', '', '').install_params, 'C900'); + assert.strictEqual( + composeRamInstallParams('082905112000012066', '', '', '', '').install_params, 'C909082905112000012066'); - assert.strictEqual(ramParamsPrefix('', '0', '0'), 'C900EF08C7020000C8020000'); - assert.strictEqual(ramParamsPrefix('', '32768', ''), 'C900EF06C70400008000'); - assert.strictEqual(ramParamsPrefix('AA', '', '1234'), 'C901AAEF04C80204D2'); + assert.strictEqual(composeRamInstallParams('', '0', '0', '', '').install_params, + 'C900EF08C7020000C8020000'); + assert.strictEqual(composeRamInstallParams('', '32768', '', '', '').install_params, + 'C900EF06C70400008000'); + assert.strictEqual(composeRamInstallParams('AA', '', '1234', '', '').install_params, + 'C901AAEF04C80204D2'); + // extra raw TLVs follow the generated prefix + assert.strictEqual(composeRamInstallParams('', '', '', 'CB02AABB', '').install_params, + 'C900CB02AABB'); }); -test('the RAM form composes the C9/quota prefix into the install parameters', () => { +test('an EF-form STK part takes the quotas into its own EF (one EF, TS 102 226 8.2.1.3.2.1)', () => { + // Two EFs made the card read the quotas and ignore the CA (live + // 2026-10-05: por_ok but no STK menu): the CA and the quotas share one + // System Specific Parameters EF (GPD_SPE_013 Table 6-5). + const ca = stkParamsBuild(vals({ mode: 'ca' })); // EF0ACA08... + assert.strictEqual(ca, 'EF0ACA080000000000000000'); + const c = composeRamInstallParams('', '100', '', '', ca); + assert.strictEqual(c.install_params, 'C900'); + assert.strictEqual(c.stk_params, 'EF0EC7020064CA080000000000000000'); + assert.strictEqual(c.quota_in_stk, true); + // without quotas the EF passes through unchanged + const c2 = composeRamInstallParams('', '', '', '', ca); + assert.strictEqual(c2.install_params, 'C900'); + assert.strictEqual(c2.stk_params, ca); + assert.strictEqual(c2.quota_in_stk, false); + // an EA-form STK part is a sibling of EF: the quotas keep their own EF + const ea = stkParamsBuild(vals({ mode: 'ea' })); + const c3 = composeRamInstallParams('', '100', '', '', ea); + assert.strictEqual(c3.install_params, 'C900EF04C7020064'); + assert.strictEqual(c3.stk_params, ea); + assert.strictEqual(c3.quota_in_stk, false); +}); + +test('the RAM form composes the install parameters from its fields', () => { const els = fakeForm({ 'rc-c9': 'AA', 'rc-quota-c7': '0' }); - assert.strictEqual(ramInstallParamsPrefix(), 'C901AAEF04C7020000'); + assert.deepStrictEqual(ramInstallParamsCompose(), + { install_params: 'C901AAEF04C7020000', stk_params: '', quota_in_stk: false }); els['rc-quota-c8'].value = '5'; - assert.strictEqual(ramInstallParamsPrefix(), 'C901AAEF08C7020000C8020005'); + assert.strictEqual(ramInstallParamsCompose().install_params, 'C901AAEF08C7020000C8020005'); + // a CA-form STK field takes the quotas into its EF + els['ram-stk-params'].value = 'EF0ACA080000000000000000'; + const c = ramInstallParamsCompose(); + assert.strictEqual(c.install_params, 'C901AA'); + assert.strictEqual(c.stk_params, 'EF12C7020000C8020005CA080000000000000000'); + assert.strictEqual(c.quota_in_stk, true); }); test('updateStkParamsHex names the rejection reason', () => { @@ -166,7 +204,12 @@ test('updateStkParamsHex names the rejection reason', () => { test('the install body composes the C9/quota prefix', () => { const fn = extractFunc(html, 'ramInstallCap'); - assert.ok(/install_params: \(ramInstallParamsPrefix\(\)/.test(fn), fn); + assert.ok(/const composed = ramInstallParamsCompose\(\)/.test(fn), fn); + assert.ok(/install_params: composed\.install_params/.test(fn), fn); + assert.ok(/stk_params: composed\.stk_params/.test(fn), fn); + const app = extractFunc(html, 'ramInstallApp'); + assert.ok(/const composed = ramInstallParamsCompose\(\)/.test(app), app); + assert.ok(/install_params: composed\.install_params/.test(app), app); }); test('the ADF AID From-card button is wired', () => { diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 52e3e13..845920a 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -724,16 +724,112 @@ def _encode_scts(dt=None): ]) +def _single_ef_value(hex_str): + """The value of a lone EF (System Specific Parameters) TLV, or None when + the input is not EF-form at all. A blob that starts with the EF tag but + does not parse as exactly one TLV is refused - appending it could add a + second EF object (TS 102 226 8.2.1.3.2.1), and a bare CA is refused too: + the CA belongs *inside* the EF.""" + if not hex_str: + return None + try: + data = bytes.fromhex(hex_str) + except ValueError: + raise ValueError('stk_params is not valid hex') + if data[0] == 0xCA: + raise ValueError('the CA (SIM file access and toolkit parameters) ' + 'belongs inside the System Specific Parameters EF ' + '(TS 102 226 8.2.1.3.2.1): send EF CA ...') + if data[0] != 0xEF: + return None + ln, off = _ber_len_at(data, 1) + if off + ln != len(data): + raise ValueError('stk_params: malformed EF TLV (the length does not ' + 'match the value)') + return data[off:off + ln].hex().upper() + + +def _quota_tlv(tag, value): + """A C7/C8 memory-quota TLV: the value is a 2-byte integer up to 32767 + and a 4-byte integer above (GP Card Spec 9.7 / Table 11-49; pySim's + ``StripHeaderAdapter(GreedyBytes, 4, steps=[2,4])``).""" + try: + v = int(str(value).strip(), 10) + except (TypeError, ValueError): + raise ValueError('%s quota must be an integer in bytes' % tag) + if not 0 <= v <= 0xFFFFFFFF: + raise ValueError('%s quota out of range (0..4294967295): %d' % (tag, v)) + return tag + ('%02X%04X' % (2, v) if v <= 0x7FFF else '%02X%08X' % (4, v)) + + +def _compose_install_params(install_params, stk_params, nv_quota=None, + volatile_quota=None): + """The INSTALL [for install] parameter field, with the composition the + spec mandates enforced: + + - the memory quotas (C7 volatile / C8 non-volatile) and an EF-form STK + part (the CA) share **one** System Specific Parameters EF (TS 102 226 + 8.2.1.3.2.1; GPD_SPE_013 v1.1 Table 6-5 - pySim's + ``gen_install_parameters``); + - an EF-form STK part next to a caller-composed EF is refused: the card + takes the first EF (quotas) and the CA is ignored - live 2026-10-05: + por_ok but no toolkit registration, no menu; + - an EA-form (or opaque) STK part is a sibling of EF (the reference TCA + loader sends ``C9 00 EF 00 EA ...``); + - the quotas are composed from the primitives only when no + ``install_params`` were given (there is no EF to place them into + otherwise). + + Raises ValueError (-> HTTP 400) on a refusal. + """ + raw = (install_params or '').replace(' ', '') + stk = (stk_params or '').replace(' ', '') + has_quota = (str(nv_quota or '').strip() != '' + or str(volatile_quota or '').strip() != '') + stk_ef = _single_ef_value(stk) + if raw: + if has_quota: + raise ValueError('memory quotas are composed only when ' + 'install_params is empty; merge them into the ' + 'install EF instead') + try: + tlvs = _ber_tlv_list(bytes.fromhex(raw)) + except ValueError: + raise ValueError('install_params is not valid hex') + if stk_ef is not None and any(tag == 0xEF for tag, _ in tlvs): + raise ValueError('the EF-form STK part would add a second System ' + 'Specific Parameters (EF) object (TS 102 226 ' + '8.2.1.3.2.1): merge the CA into the install ' + 'parameters\' EF, or pass the quotas via ' + 'volatile_quota/nv_quota with an empty ' + 'install_params') + return (raw + stk).upper() + # Compose from the primitives: C9 is mandatory (GP Table 11-49). + inner = '' + if str(volatile_quota or '').strip() != '': + inner += _quota_tlv('C7', volatile_quota) + if str(nv_quota or '').strip() != '': + inner += _quota_tlv('C8', nv_quota) + if stk_ef is not None: + inner += stk_ef + stk = '' + params = 'C900' + if inner: + params += 'EF' + _ber_len(len(inner) // 2) + inner + return (params + stk).upper() + + def _cap_install_apdu(loadfile_aid, module_aid, instance_aid='', privileges='00', - install_params='', stk_params='', make_selectable=True): + install_params='', stk_params='', make_selectable=True, + nv_quota=None, volatile_quota=None): """INSTALL [for install] APDU (GP Card Spec 11.5.2.3.2, Table 11-43) - the final step of `_cap_apdu_sequence` and the /api/ram-install-app operation. Case 3: no trailing Le (a trailing byte becomes a phantom command on the card's SCP80 layer).""" instance = instance_aid or module_aid - params = install_params if install_params else 'C900' - if stk_params: - params += stk_params + params = _compose_install_params(install_params, stk_params, + nv_quota=nv_quota, + volatile_quota=volatile_quota) p1 = 0x0C if make_selectable else 0x04 data = (_lv(loadfile_aid) + _lv(module_aid) + _lv(instance) + _lv(privileges or '00') + _lv(params) + '00') @@ -750,7 +846,8 @@ def _cap_make_selectable_apdu(instance_aid, privileges='00'): def _cap_apdu_sequence(loadfile_aid, module_aid, loadfile_data, sd_aid='', privileges='00', install_params='', stk_params='', - make_selectable=True, block_size=240, instance_aid=None): + make_selectable=True, block_size=240, instance_aid=None, + nv_quota=None, volatile_quota=None): """RAM (GP) APDU sequence for a parsed .cap: INSTALL [for load], LOAD blocks (240-byte payloads, block counter in P2, last block P1=0x80), INSTALL [for install]. Shared by the SCP80 delivery path and the SCP81 @@ -776,7 +873,8 @@ def _cap_apdu_sequence(loadfile_aid, module_aid, loadfile_data, sd_aid='', apdus.append(_cap_install_apdu( loadfile_aid, module_aid, instance_aid=instance_aid, privileges=privileges, install_params=install_params, - stk_params=stk_params, make_selectable=make_selectable)) + stk_params=stk_params, make_selectable=make_selectable, + nv_quota=nv_quota, volatile_quota=volatile_quota)) return apdus @@ -3677,7 +3775,11 @@ def _scp81_gen_install(body): privileges=re.sub(r'\s', '', body.get('privileges') or '') or '00', install_params=re.sub(r'\s', '', body.get('install_params') or ''), stk_params=re.sub(r'\s', '', body.get('stk_params') or ''), - make_selectable=bool(body.get('make_selectable', True))) + make_selectable=bool(body.get('make_selectable', True)), + nv_quota=body.get('nv_quota'), volatile_quota=body.get('volatile_quota')) + except ValueError as e: + # the install-parameter composition rule (or a CAP parse refusal) + return {'ok': False, 'error': str(e)} except Exception as e: return {'ok': False, 'error': 'cap parse failed: %s' % e} _BIP.log('gen-install', apdus=len(seq), load_file_aid=loadfile_aid, @@ -8111,12 +8213,24 @@ class PysimHandler(BaseHTTPRequestHandler): 'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key, 'include_cpi': body.get('includeCpi', True)} - # INSTALL [for load] -> LOAD blocks -> INSTALL [for install] - seq = _cap_apdu_sequence( - loadfile_aid, module_aid, loadfile_data, sd_aid=sd_aid, - privileges=privileges_hex, - install_params=install_params_hex, stk_params=stk_params_hex, - make_selectable=make_selectable, block_size=block_size) + # INSTALL [for load] -> LOAD blocks -> INSTALL [for install]. + # The install parameters are composed per the spec: an + # EF-form STK part shares one System Specific Parameters EF + # with the quotas (TS 102 226 8.2.1.3.2.1; v3.20.0) - an + # invalid combination is refused before anything is sent. + try: + seq = _cap_apdu_sequence( + loadfile_aid, module_aid, loadfile_data, sd_aid=sd_aid, + privileges=privileges_hex, + install_params=install_params_hex, stk_params=stk_params_hex, + make_selectable=make_selectable, block_size=block_size, + nv_quota=body.get('nv_quota'), + volatile_quota=body.get('volatile_quota')) + except ValueError as e: + err = {'success': False, 'error': str(e)} + self._send_json(err, 400) + self._log_resp(err) + return # Live progress for the PWA's operation modal (the request holds # _CARD_LOCK until the chain is done; /api/status carries this) @@ -8226,10 +8340,18 @@ class PysimHandler(BaseHTTPRequestHandler): self._send_json(err, 400) self._log_resp(err) return - apdu = _cap_install_apdu( - loadfile_aid, module_aid, instance_aid=instance_aid, - privileges=privileges, install_params=install_params_hex, - stk_params=stk_params_hex, make_selectable=make_selectable) + try: + apdu = _cap_install_apdu( + loadfile_aid, module_aid, instance_aid=instance_aid, + privileges=privileges, install_params=install_params_hex, + stk_params=stk_params_hex, make_selectable=make_selectable, + nv_quota=body.get('nv_quota'), + volatile_quota=body.get('volatile_quota')) + except ValueError as e: + err = {'success': False, 'error': str(e)} + self._send_json(err, 400) + self._log_resp(err) + return step_name = 'INSTALL [for install]' # SCP80 params: the SPI1 comes from the MSL of the packet's # TAR (the operation uses the preset's table); a caller-supplied diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py index 5a0b814..612c3f6 100644 --- a/tests/test_fetch_routes.py +++ b/tests/test_fetch_routes.py @@ -715,6 +715,31 @@ class PresetStoreHttpTests(unittest.TestCase): self.assertEqual(resp['final_cntr'], '0000000002') self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000002') + def test_ram_install_app_rejects_the_two_ef_combination(self): + """v3.20.0: an EF-form STK part next to a caller-composed EF is + refused before anything is built (TS 102 226 8.2.1.3.2.1).""" + status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) + self.assertEqual(status, 200, resp) + pid = resp['preset']['id'] + self.server.app = object() + status, resp = self._post('/api/ram-install-app', { + 'mode': 'install', + 'preset_id': pid, 'kic': '15', 'kid': '15', + 'kicKey': 'AA', 'kidKey': 'BB', + 'loadfile_aid': 'F0414C46416101', 'module_aid': 'F0414C4641610101', + 'install_params': 'C900EF04C7020064', 'stk_params': 'EF04CA0201F0'}) + self.assertEqual(status, 400, resp) + self.assertIn('second System Specific Parameters', resp['error']) + # quotas next to a caller-composed install_params are refused too + status, resp = self._post('/api/ram-install-app', { + 'mode': 'install', + 'preset_id': pid, 'kic': '15', 'kid': '15', + 'kicKey': 'AA', 'kidKey': 'BB', + 'loadfile_aid': 'F0414C46416101', 'module_aid': 'F0414C4641610101', + 'install_params': 'C900EF04C7020064', 'volatile_quota': 100}) + self.assertEqual(status, 400, resp) + self.assertIn('composed only when install_params is empty', resp['error']) + def test_create_update_delete_round_trip(self): status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) self.assertEqual(status, 200, resp) diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 7124caa..316e3da 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -1514,6 +1514,63 @@ class CapApduSequenceTest(unittest.TestCase): 'F0414C46416101', 'F0414C4641610101', make_selectable=False).startswith('80E60400')) + def test_install_params_composition_enforces_one_ef(self): + # TS 102 226 8.2.1.3.2.1 / GPD_SPE_013 v1.1 Table 6-5: the memory + # quotas (C7/C8) and an EF-form STK part (CA) share ONE System + # Specific Parameters EF. Two EFs made the card read the quotas and + # ignore the CA (live 2026-10-05: por_ok, no toolkit registration). + from pysim_simple_server.server import _compose_install_params + # primitives: quotas and the CA are placed in one EF + self.assertEqual(_compose_install_params('', '', nv_quota=200, + volatile_quota=100), + 'C900EF08C7020064C80200C8') + self.assertEqual(_compose_install_params('', 'EF04CA0201F0'), + 'C900EF04CA0201F0') + self.assertEqual(_compose_install_params('', 'EF04CA0201F0', nv_quota=200, + volatile_quota=100), + 'C900EF0CC7020064C80200C8CA0201F0') + # a 4-byte quota above 32767 (GP Card Spec 9.7) + self.assertEqual(_compose_install_params('', '', volatile_quota=32768), + 'C900EF06C70400008000') + # an EA-form STK part is a sibling of EF (the reference TCA form) + self.assertEqual(_compose_install_params('', 'EA0480000000', + volatile_quota=100), + 'C900EF04C7020064EA0480000000') + # a caller-composed EF plus an EF-form STK part is refused + with self.assertRaises(ValueError): + _compose_install_params('C900EF04C7020064', 'EF04CA0201F0') + # a bare CA is refused: it belongs inside the EF + with self.assertRaises(ValueError): + _compose_install_params('', 'CA0401F00000') + # a malformed EF is refused + with self.assertRaises(ValueError): + _compose_install_params('', 'EF05CA0100') + # quotas next to raw install parameters are refused + with self.assertRaises(ValueError): + _compose_install_params('C900', '', nv_quota=100) + # a bad quota value is refused + with self.assertRaises(ValueError): + _compose_install_params('', '', volatile_quota='-1') + # a caller-composed EF with an EA part stays as it was + self.assertEqual(_compose_install_params('C900EF04C7020064', 'EA0480000000'), + 'C900EF04C7020064EA0480000000') + + def test_install_apdu_carries_the_composed_parameters(self): + from pysim_simple_server.server import _cap_install_apdu + # CA-form STK + quotas: `C9 00 EF{C7,C8,CA}` - one EF (the form the + # PWA composes; an external caller gets the same via the primitives). + apdu = _cap_install_apdu('F0414C46416101', 'F0414C4641610101', + stk_params='EF0ACA080000000000000000', + volatile_quota=100) + self.assertEqual( + apdu, + '80E60C0030' + '07F0414C46416101' + '08F0414C4641610101' + + '08F0414C4641610101' + '0100' + + '12C900EF0EC7020064CA080000000000000000' + '00') + # case 3: the length is header + Lc data, no trailing Le + lc = int(apdu[8:10], 16) + self.assertEqual(len(apdu), 10 + 2 * lc) + def test_make_selectable_apdu(self): # GP Card Spec 11.5.2.3.3, Table 11-44: '00' '00' lv(AID) # lv(privileges) lv(params) lv(token); case 3.