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 <len> CA <len> ...` (the SIM access domain leads the CA value as
  `01 <ad>` / `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.
This commit is contained in:
2026-10-05 23:01:42 +03:00
parent ff3feb6079
commit 82c5733906
7 changed files with 382 additions and 69 deletions
+30 -7
View File
@@ -812,7 +812,7 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL
{ {
"cap_hex": "DECAFFED...", "cap_hex": "DECAFFED...",
"sd_aid": "A000000003000000", "sd_aid": "A000000003000000",
"install_params": "C90000", "install_params": "C900",
"stk_params": "", "stk_params": "",
"nv_quota": 0, "nv_quota": 0,
"volatile_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) | | `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` | | `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 | | `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 | Hex CA TLV (TS 102 226 §8.2.1.3.2.1) for SIM toolkit app-specific params | | `stk_params` | no | The STK part appended to the install parameters: the **EF-wrapped** CA (`EF <len> CA <len> …`, TS 102 226 §8.2.1.3.2.1 — the SIM access domain leads the CA value as `01 <ad>` / `00`) or the UICC template (`EA <len> 80 … [81 …]`). A bare `CA` is refused (it belongs inside the EF) |
| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) for `gen_install_parameters()` | | `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) | | `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. | | `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 | | `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) | | `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):** **Response (success):**
```json ```json
{"success": true, "failed_step": null, {"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"`) | | `module_aid` | cond | Executable module / applet class AID (required for `mode: "install"`) |
| `instance_aid` | no | Application AID; empty → the module AID | | `instance_aid` | no | Application AID; empty → the module AID |
| `privileges` | no | Hex privileges value (1 or 3 bytes), default `00` | | `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` | | `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 | | `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 ```json
{"cap_hex": "504B0304...", "sd_aid": "A000000003000000", "privileges": "00", {"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": ...}`. "load_file_aid": ..., "module_aid": ...}`.
### `GET /api/scp81/script` ### `GET /api/scp81/script`
+75 -32
View File
@@ -2783,10 +2783,13 @@ function ramResetGrants() {
// Pure SIM/UICC toolkit install-parameter builder shared by the RAM install // Pure SIM/UICC toolkit install-parameter builder shared by the RAM install
// form (buildRcToolkitParams) and the RAM/GP chain rows (buildTkParams). // form (buildRcToolkitParams) and the RAM/GP chain rows (buildTkParams).
// `v` carries the field values; returns the CA/EA TLV hex, or null when a // `v` carries the field values; returns the STK part of the install
// value cannot be coded: each TAR is 3 bytes (6 hex digits), and menu item // parameters - the EF-wrapped CA (`EF { CA ... }`, TS 102 226 8.2.1.3.2.1;
// identifiers 128..255 are reserved for the toolkit framework, so only // composeRamInstallParams() merges the memory quotas into that EF) or the
// 01..7F may be requested (TS 102 226 8.2.1.3.2.3). In EA mode `v.fsAccess` // 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 // 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 // them with an ADF entry (AID from `v.adfAid`, default ADF.USIM); the SIM path
// grants access via the CA Access Domain field instead. // grants access via the CA Access Domain field instead.
@@ -2888,6 +2891,7 @@ function updateStkParamsHex() {
// field is edited again (this clears the flag). // field is edited again (this clears the flag).
if (el.dataset) delete el.dataset.manual; if (el.dataset) delete el.dataset.manual;
// A rejected value must not vanish silently: say why the field is empty. // A rejected value must not vanish silently: say why the field is empty.
updateInstallParamsHex();
const hint = document.getElementById('ram-stk-hint'); const hint = document.getElementById('ram-stk-hint');
if (!hint) return; if (!hint) return;
if (tk === null) { if (tk === null) {
@@ -2956,35 +2960,68 @@ function ramQuotaValue(n) {
: '04' + v.toString(16).padStart(8, '0').toUpperCase(); : '04' + v.toString(16).padStart(8, '0').toUpperCase();
} }
// The applet-specific prefix of the install parameters, in the vendor's // The quota TLVs of the install EF: `C7 <len> <quota>` (volatile) and
// order: `C9 <applet-specific parameters>` + `EF <C7/C8 memory quotas>`. // `C8 <len> <quota>` (non-volatile), in the vendor's order.
// The server appends the STK (`EA`/`EF+CA`) part after it. C9 is mandatory function ramQuotaTlvs(c7, c8) {
// in the Install Parameters (GP Table 11-49), so an empty field is sent as let out = '';
// `C9 00` - like the reference tools; a prefix without C9 makes the card if (String(c7 ?? '') !== '' && !isNaN(parseInt(c7, 10))) out += 'C7' + ramQuotaValue(c7);
// reject the data field with 6A80 (live 2026-09-28). if (String(c8 ?? '') !== '' && !isNaN(parseInt(c8, 10))) out += 'C8' + ramQuotaValue(c8);
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;
return out; return out;
} }
function ramInstallParamsPrefix() { // Compose the INSTALL [for install] parameters. The memory quotas (C7/C8)
return ramParamsPrefix( // and an EF-form STK part (the CA) share ONE System Specific Parameters EF
document.getElementById('rc-c9').value, // (TS 102 226 8.2.1.3.2.1; GPD_SPE_013 v1.1 Table 6-5 - pySim's
document.getElementById('rc-quota-c7').value, // gen_install_parameters); an EA-form (or opaque) STK part is a sibling of
document.getElementById('rc-quota-c8').value); // 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() { function updateInstallParamsHex() {
const prefix = ramInstallParamsPrefix();
const el = document.getElementById('ram-install-params-hex'); const el = document.getElementById('ram-install-params-hex');
// The field carries extra raw TLVs; the generated prefix is shown in the if (!el) return;
// placeholder so the composed parameters stay visible. const composed = ramInstallParamsCompose();
el.placeholder = prefix ? prefix + ' …' : 'extra TLVs (optional)'; // 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 ===== // ===== GSM 7-bit alphabet =====
@@ -11210,14 +11247,14 @@ async function ramInstallCap(sp) {
// never be sent silently. // never be sent silently.
updateRcPriv(); updateRcPriv();
const composed = ramInstallParamsCompose();
const body = { const body = {
cap_hex: capHex, cap_hex: capHex,
preset_id: sp.preset_id, preset_id: sp.preset_id,
sd_aid: (document.getElementById('ram-sd-aid').value || '').replace(/[^0-9a-fA-F]/g, ''), 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, ''), privileges: (document.getElementById('rc-priv').value || '00').replace(/[^0-9a-fA-F]/g, ''),
install_params: (ramInstallParamsPrefix() + install_params: composed.install_params,
(document.getElementById('ram-install-params-hex').value || '').replace(/[^0-9a-fA-F]/g, '')).toUpperCase(), stk_params: composed.stk_params,
stk_params: (document.getElementById('ram-stk-params').value || '').replace(/[^0-9a-fA-F]/g, ''),
make_selectable: document.getElementById('ram-make-sel').checked, make_selectable: document.getElementById('ram-make-sel').checked,
spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid, spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid,
tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey, 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)) { if (document.getElementById('rc-toolkit-enable').checked && !(stkEl.dataset && stkEl.dataset.manual)) {
updateStkParamsHex(); updateStkParamsHex();
} }
const composed = ramInstallParamsCompose();
const body = { const body = {
mode: mode, mode: mode,
preset_id: sp.preset_id, preset_id: sp.preset_id,
@@ -11270,9 +11308,8 @@ async function ramInstallApp(sp, op) {
module_aid: module, module_aid: module,
instance_aid: instance, instance_aid: instance,
privileges: (document.getElementById('rc-priv').value || '00').replace(/[^0-9a-fA-F]/g, ''), privileges: (document.getElementById('rc-priv').value || '00').replace(/[^0-9a-fA-F]/g, ''),
install_params: (ramInstallParamsPrefix() + install_params: composed.install_params,
(document.getElementById('ram-install-params-hex').value || '').replace(/[^0-9a-fA-F]/g, '')).toUpperCase(), stk_params: composed.stk_params,
stk_params: (document.getElementById('ram-stk-params').value || '').replace(/[^0-9a-fA-F]/g, ''),
make_selectable: document.getElementById('ram-make-sel').checked, make_selectable: document.getElementById('ram-make-sel').checked,
spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid, spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid,
tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey, tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey,
@@ -11719,6 +11756,10 @@ function ramShowInstallResult(data, okText, capMem) {
if (data.success) { if (data.success) {
resultEl.textContent = (okText || t('OK')) + probeNote; resultEl.textContent = (okText || t('OK')) + probeNote;
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600'); 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 { } else {
const hint = ramInstallFailHint(data, capMem || null); const hint = ramInstallFailHint(data, capMem || null);
resultEl.textContent = t('Install FAILED at step:') + ' ' + data.failed_step + (data.error ? ' (' + data.error + ')' : '') + resultEl.textContent = t('Install FAILED at step:') + ' ' + data.failed_step + (data.error ? ' (' + data.error + ')' : '') +
@@ -20824,6 +20865,8 @@ const LANG_RU = {
'Step': 'Шаг', 'Step': 'Шаг',
'Install OK': 'Установка OK', 'Install OK': 'Установка OK',
'Install FAILED at step:': 'Установка не удалась на шаге:', 'Install FAILED at step:': 'Установка не удалась на шаге:',
'Install FAILED:': 'Установка не удалась:',
'quotas in the STK EF': 'квоты в EF STK-параметров',
'Hint:': 'Подсказка:', 'Hint:': 'Подсказка:',
'Set all 0.0': 'Все 0.0', 'Set all 0.0': 'Все 0.0',
'CAP compatibility test': 'Проверка совместимости CAP', 'CAP compatibility test': 'Проверка совместимости CAP',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v394'; const CACHE = 'simple-v395';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+55 -12
View File
@@ -23,8 +23,9 @@ function extractFunc(src, name) {
let code = ''; let code = '';
for (const fn of ['berLenStr', 'stkParamsBuild', 'buildRcToolkitParams', for (const fn of ['berLenStr', 'stkParamsBuild', 'buildRcToolkitParams',
'updateStkParamsHex', 'adfAidFromFci', 'ramParamsPrefix', 'ramQuotaValue', 'updateStkParamsHex', 'updateInstallParamsHex', 'adfAidFromFci', 'ramQuotaValue', 'ramQuotaTlvs',
'ramInstallParamsPrefix']) code += extractFunc(html, fn) + '\n'; 'composeRamInstallParams', 'ramInstallParamsCompose',
'parseTlvList', 'parseBerLen']) code += extractFunc(html, fn) + '\n';
eval(code); eval(code);
const base = { mode: 'ea', priority: '0', timers: '0', textLen: '0', menus: '0', 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'), ''); 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): // The vendor's working install (samples/uicc/applets/STK3):
// C9 22 <config> EF 08 C7 02 0000 C8 02 0000 EA ... // C9 22 <config> EF 08 C7 02 0000 C8 02 0000 EA ...
// C9 is mandatory (GP Table 11-49): an empty field is sent as C9 00 - // 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). // without it the card rejects the data field with 6A80 (live 2026-09-28).
assert.strictEqual(ramParamsPrefix('', '', ''), 'C900'); assert.strictEqual(composeRamInstallParams('', '', '', '', '').install_params, 'C900');
assert.strictEqual(ramParamsPrefix('082905112000012066', '', ''), assert.strictEqual(
composeRamInstallParams('082905112000012066', '', '', '', '').install_params,
'C909082905112000012066'); 'C909082905112000012066');
assert.strictEqual(ramParamsPrefix('', '0', '0'), 'C900EF08C7020000C8020000'); assert.strictEqual(composeRamInstallParams('', '0', '0', '', '').install_params,
assert.strictEqual(ramParamsPrefix('', '32768', ''), 'C900EF06C70400008000'); 'C900EF08C7020000C8020000');
assert.strictEqual(ramParamsPrefix('AA', '', '1234'), 'C901AAEF04C80204D2'); 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' }); 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'; 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', () => { 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', () => { test('the install body composes the C9/quota prefix', () => {
const fn = extractFunc(html, 'ramInstallCap'); 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', () => { test('the ADF AID From-card button is wired', () => {
+139 -17
View File
@@ -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 <len> 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', 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 """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. 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 Case 3: no trailing Le (a trailing byte becomes a phantom command on the
card's SCP80 layer).""" card's SCP80 layer)."""
instance = instance_aid or module_aid instance = instance_aid or module_aid
params = install_params if install_params else 'C900' params = _compose_install_params(install_params, stk_params,
if stk_params: nv_quota=nv_quota,
params += stk_params volatile_quota=volatile_quota)
p1 = 0x0C if make_selectable else 0x04 p1 = 0x0C if make_selectable else 0x04
data = (_lv(loadfile_aid) + _lv(module_aid) + _lv(instance) + data = (_lv(loadfile_aid) + _lv(module_aid) + _lv(instance) +
_lv(privileges or '00') + _lv(params) + '00') _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='', def _cap_apdu_sequence(loadfile_aid, module_aid, loadfile_data, sd_aid='',
privileges='00', install_params='', stk_params='', 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 """RAM (GP) APDU sequence for a parsed .cap: INSTALL [for load], LOAD
blocks (240-byte payloads, block counter in P2, last block P1=0x80), blocks (240-byte payloads, block counter in P2, last block P1=0x80),
INSTALL [for install]. Shared by the SCP80 delivery path and the SCP81 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( apdus.append(_cap_install_apdu(
loadfile_aid, module_aid, instance_aid=instance_aid, loadfile_aid, module_aid, instance_aid=instance_aid,
privileges=privileges, install_params=install_params, 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 return apdus
@@ -3677,7 +3775,11 @@ def _scp81_gen_install(body):
privileges=re.sub(r'\s', '', body.get('privileges') or '') or '00', privileges=re.sub(r'\s', '', body.get('privileges') or '') or '00',
install_params=re.sub(r'\s', '', body.get('install_params') or ''), install_params=re.sub(r'\s', '', body.get('install_params') or ''),
stk_params=re.sub(r'\s', '', body.get('stk_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: except Exception as e:
return {'ok': False, 'error': 'cap parse failed: %s' % e} return {'ok': False, 'error': 'cap parse failed: %s' % e}
_BIP.log('gen-install', apdus=len(seq), load_file_aid=loadfile_aid, _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, 'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': body.get('includeCpi', True)} 'include_cpi': body.get('includeCpi', True)}
# INSTALL [for load] -> LOAD blocks -> INSTALL [for install] # INSTALL [for load] -> LOAD blocks -> INSTALL [for install].
seq = _cap_apdu_sequence( # The install parameters are composed per the spec: an
loadfile_aid, module_aid, loadfile_data, sd_aid=sd_aid, # EF-form STK part shares one System Specific Parameters EF
privileges=privileges_hex, # with the quotas (TS 102 226 8.2.1.3.2.1; v3.20.0) - an
install_params=install_params_hex, stk_params=stk_params_hex, # invalid combination is refused before anything is sent.
make_selectable=make_selectable, block_size=block_size) 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 # Live progress for the PWA's operation modal (the request holds
# _CARD_LOCK until the chain is done; /api/status carries this) # _CARD_LOCK until the chain is done; /api/status carries this)
@@ -8226,10 +8340,18 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(err, 400) self._send_json(err, 400)
self._log_resp(err) self._log_resp(err)
return return
apdu = _cap_install_apdu( try:
loadfile_aid, module_aid, instance_aid=instance_aid, apdu = _cap_install_apdu(
privileges=privileges, install_params=install_params_hex, loadfile_aid, module_aid, instance_aid=instance_aid,
stk_params=stk_params_hex, make_selectable=make_selectable) 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]' step_name = 'INSTALL [for install]'
# SCP80 params: the SPI1 comes from the MSL of the packet's # SCP80 params: the SPI1 comes from the MSL of the packet's
# TAR (the operation uses the preset's table); a caller-supplied # TAR (the operation uses the preset's table); a caller-supplied
+25
View File
@@ -715,6 +715,31 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(resp['final_cntr'], '0000000002') self.assertEqual(resp['final_cntr'], '0000000002')
self.assertEqual(self.store.get(pid)['keysets'][0]['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): def test_create_update_delete_round_trip(self):
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp) self.assertEqual(status, 200, resp)
+57
View File
@@ -1514,6 +1514,63 @@ class CapApduSequenceTest(unittest.TestCase):
'F0414C46416101', 'F0414C4641610101', 'F0414C46416101', 'F0414C4641610101',
make_selectable=False).startswith('80E60400')) 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): def test_make_selectable_apdu(self):
# GP Card Spec 11.5.2.3.3, Table 11-44: '00' '00' lv(AID) # GP Card Spec 11.5.2.3.3, Table 11-44: '00' '00' lv(AID)
# lv(privileges) lv(params) lv(token); case 3. # lv(privileges) lv(params) lv(token); case 3.