feat: single-APDU INSTALL [for install] / [for make selectable] ops (v3.6.18)

Iterating the final install step no longer needs a delete + re-load of the
.cap:

- `POST /api/ram-install-app` runs INSTALL [for install] (`mode: "install"`,
  P1 0x0C/0x04 by make_selectable) or INSTALL [for make selectable]
  (`mode: "make_selectable"`, GP 11.5.2.3.3 Table 11-44) for an already
  loaded package.  The APDU builders are shared with `_cap_apdu_sequence`
  (`_cap_install_apdu`, `_cap_make_selectable_apdu` - one builder, no drift)
  and the per-step send/PoR/counter logic is the extracted shared
  `_ram_send_gp_apdu` (used by /api/ram-install too).
- PWA: the RAM op selector gains "INSTALL [for install] (loaded package)"
  and "INSTALL [for make selectable]" with Load File / Module / Instance AID
  inputs (instance defaults to the module AID); the result renders through
  the shared `ramShowInstallResult` and `final_cntr` keeps the preset in
  sync.
- Explore: an **Install** button on a package's module AIDs that have no
  instance yet, and a **Make selectable** button on INSTALLED (0x03)
  instances - both switch the op and pre-fill the AIDs.
- tests: the install APDU pinned to the live no-STK packet and to the
  sequence tail, the make-selectable APDU, ramHasInstance, the Explore
  buttons and the op wiring; docs/api.md updated.

650 frontend / 498 Python green; version 3.6.18; sw simple-v291.
This commit is contained in:
2026-09-28 08:10:40 +03:00
parent 6403fb9f29
commit face2268ca
7 changed files with 414 additions and 109 deletions
+36
View File
@@ -38,6 +38,7 @@ a 3.x PWA).
| `/api/help` | POST | pySim help for a given command |
| `/api/send-ota` | POST | SCP80 OTA secured packet delivery |
| `/api/ram-install` | POST | Install a Java Card `.cap` file via SCP80 (INSTALL[for load] → LOAD ×N → INSTALL[for install]) |
| `/api/ram-install-app` | POST | Single INSTALL [for install] / [for make selectable] for an already loaded package (no re-load) |
| `/api/cap-info` | POST | Validate a `.cap` archive and estimate its code/NVRAM/RAM requirements (read-only) |
| `/api/test/run` | POST | Start a test script (actions + proactive expectations) |
| `/api/test/status` | GET | Test script run state and per-step results |
@@ -561,6 +562,41 @@ sets `success: false`, `failed_step` and a detailed `error` such as
The `steps` array contains one entry per GP command. `final_cntr` is the counter value after all successful steps (use it to update the card preset). The response is not streamed — all steps run server-side before the JSON is returned.
### `POST /api/ram-install-app`
Run the single **INSTALL [for install]** (or **INSTALL [for make selectable]**) APDU for an *already loaded* package — one APDU, so the install parameters can be iterated without deleting and re-loading the `.cap`. The APDU builders are shared with `/api/ram-install` (`_cap_install_apdu` / `_cap_make_selectable_apdu`; both case 3 — no trailing `Le`, which the card's SCP80 layer counts as a phantom command) and the step verdict/counter logic is the same (`final_cntr` is returned on success **and** on failure).
**Request body:**
```json
{
"mode": "install",
"loadfile_aid": "F0414C46416101",
"module_aid": "F0414C4641610101",
"instance_aid": "",
"privileges": "00",
"install_params": "C900",
"stk_params": "",
"make_selectable": true,
"spi1": "0E", "spi2": "01",
"kic": "15", "kid": "15",
"tar": "000000",
"cntr": "0000000001",
"kicKey": "D6FCC023...",
"kidKey": "1B07E7E0..."
}
```
| Field | Req | Description |
|---|---|---|
| `mode` | no | `install` (default) or `make_selectable` (GP Table 11-44; needs `instance_aid`) |
| `loadfile_aid` | cond | Package 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 |
| `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) |
**Response:** `{"success": bool, "steps": [...], "final_cntr": "...", "error": "...", "failed_step": N}` — the same step records as `/api/ram-install`.
### `POST /api/sp-verify`
Cross-check a secured packet against pySim's `OtaDialectSms.encode_cmd`
+133 -19
View File
@@ -696,20 +696,32 @@
<select id="ram-op" onchange="ramOpChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="explore" data-l10n="Explore Card (all GP data)">Explore Card (all GP data)</option>
<option value="install-cap" data-l10n="Install Package (.cap file)">Install Package (.cap file)</option>
<option value="install-app" data-l10n="INSTALL [for install] (loaded package)">INSTALL [for install] (loaded package)</option>
<option value="make-selectable" data-l10n="INSTALL [for make selectable]">INSTALL [for make selectable]</option>
</select>
</div>
<button data-needs="card" data-cap-gate="ram" onclick="ramExecute()" class="px-5 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Execute">Execute</button>
</div>
<div id="ram-install-params" class="hidden mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="CAP file (max 48 kB)">CAP file (max 48 kB)</label>
<input type="file" id="ram-cap-file" accept=".cap,.zip" onchange="ramCapFileChanged()" class="w-full text-sm text-gray-600 dark:text-slate-300">
<fieldset id="ram-cap-info" class="hidden mt-2 border border-gray-200 dark:border-slate-700 rounded p-3 text-xs text-gray-500 dark:text-slate-400">
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="CAP details">CAP details</legend>
<div id="ram-cap-body"></div>
</fieldset>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300 mt-2" data-l10n="SD AID (empty = ISD)">SD AID (empty = ISD)</label>
<input id="ram-sd-aid" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000003000000" maxlength="32">
<div id="ram-cap-rows">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="CAP file (max 48 kB)">CAP file (max 48 kB)</label>
<input type="file" id="ram-cap-file" accept=".cap,.zip" onchange="ramCapFileChanged()" class="w-full text-sm text-gray-600 dark:text-slate-300">
<fieldset id="ram-cap-info" class="hidden mt-2 border border-gray-200 dark:border-slate-700 rounded p-3 text-xs text-gray-500 dark:text-slate-400">
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="CAP details">CAP details</legend>
<div id="ram-cap-body"></div>
</fieldset>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300 mt-2" data-l10n="SD AID (empty = ISD)">SD AID (empty = ISD)</label>
<input id="ram-sd-aid" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000003000000" maxlength="32">
</div>
<div id="ram-app-rows" class="hidden">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Load File AID / Package AID (hex)">Load File AID / Package AID (hex)</label>
<input id="ram-app-loadfile" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="F0414C46416101" maxlength="32">
<label class="block mb-1 mt-2 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Executable Module AID / Applet Class AID (hex)">Executable Module AID / Applet Class AID (hex)</label>
<input id="ram-app-module" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="F0414C4641610101" maxlength="32">
<label class="block mb-1 mt-2 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Instance AID (hex, empty = module AID)">Instance AID (hex, empty = module AID)</label>
<input id="ram-app-instance" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="(module AID)" maxlength="32">
</div>
<div id="rc-priv-row" class="mb-3 mt-2">
<div class="flex items-center gap-3 mb-2">
<label class="text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Privileges">Privileges</label>
@@ -1699,7 +1711,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.17';
const SIMPLE_VERSION = '3.6.18';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -8178,7 +8190,17 @@ function ramOpChanged() {
const op = document.getElementById('ram-op').value;
if (_ramOpLast !== null && op !== _ramOpLast) ramClearResults();
_ramOpLast = op;
document.getElementById('ram-install-params').classList.toggle('hidden', op !== 'install-cap');
const isCap = op === 'install-cap';
const isApp = op === 'install-app' || op === 'make-selectable';
const setHidden = (id, hidden) => {
const el = document.getElementById(id);
if (el) el.classList.toggle('hidden', hidden);
};
setHidden('ram-install-params', !(isCap || isApp));
setHidden('ram-cap-rows', !isCap);
setHidden('ram-app-rows', !isApp);
// The Execute button's .cap gate only applies to the install-cap op.
if (typeof pysimApplyAvailability === 'function') pysimApplyAvailability();
}
function ramShowProgress(text) {
@@ -8843,6 +8865,9 @@ function ramRenderExploreHtml(mem, isd, apps, elfs) {
html += '<div>' + esc(t('Application / Instance AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?');
if (o.aid) {
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" data-needs="card" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed">' + esc(t('Delete')) + '</button>';
if ((o.lifecycle || '').toUpperCase() === '03') {
html += ' <button onclick="ramMakeSelectableFromExplorer(\'' + o.aid + '\')" data-needs="card" class="ml-1 px-2 py-0.5 text-xs bg-emerald-100 text-emerald-700 rounded hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed">' + esc(t('Make selectable')) + '</button>';
}
}
html += '</div>';
html += '<div>' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
@@ -8869,7 +8894,15 @@ function ramRenderExploreHtml(mem, isd, apps, elfs) {
if (o.version) html += '<div>' + esc(t('Version:')) + ' ' + o.version + '</div>';
if (o.moduleAids && o.moduleAids.length) {
html += '<div>' + esc(t('Executable Module AIDs / Applet Class AIDs:')) + '</div>';
o.moduleAids.forEach(m => { html += '<div class="pl-4">- ' + jcAidHtml(m) + '</div>'; });
o.moduleAids.forEach(m => {
html += '<div class="pl-4">- ' + jcAidHtml(m);
// A loaded applet class with no instance yet: one click to
// the single-APDU INSTALL [for install].
if (!ramHasInstance(apps, m)) {
html += ' <button onclick="ramInstallFromExplorer(\'' + o.aid + '\',\'' + m + '\')" data-needs="card" class="ml-2 px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 disabled:opacity-40 disabled:cursor-not-allowed">' + esc(t('Install')) + '</button>';
}
html += '</div>';
});
}
if (o.sdAid) html += '<div>' + esc(t('SD AID:')) + ' ' + jcAidHtml(o.sdAid) + '</div>';
html += '</div>';
@@ -9023,6 +9056,31 @@ function ramDeleteApdu(aid, withCascade) {
return '80E400' + (withCascade ? '80' : '00') + berLenStr(data.length / 2) + data;
}
// A loaded applet class with no instance yet (or an INSTALLED, non-selectable
// instance): jump to the matching single-APDU operation with the AIDs filled.
function ramHasInstance(apps, aid) {
const a = (aid || '').toUpperCase();
return (apps || []).some(x => (x.aid || '').toUpperCase().startsWith(a));
}
function ramInstallFromExplorer(loadfileAid, moduleAid) {
document.getElementById('ram-op').value = 'install-app';
ramOpChanged();
document.getElementById('ram-app-loadfile').value = loadfileAid || '';
document.getElementById('ram-app-module').value = moduleAid || '';
document.getElementById('ram-app-instance').value = '';
const el = document.getElementById('ram-op');
if (el.scrollIntoView) el.scrollIntoView({block: 'center'});
}
function ramMakeSelectableFromExplorer(aid) {
document.getElementById('ram-op').value = 'make-selectable';
ramOpChanged();
document.getElementById('ram-app-instance').value = aid || '';
const el = document.getElementById('ram-op');
if (el.scrollIntoView) el.scrollIntoView({block: 'center'});
}
// A successful delete drops the object from the Explore result locally (the
// re-explore is not re-run). The registry has no package-to-applet link, so
// a cascade package delete also drops the applets whose AID starts with the
@@ -9165,25 +9223,71 @@ async function ramInstallCap(sp) {
const data = await pysimFetch('/api/ram-install', body);
ramHideProgress();
let sizeInfo = '';
if (data.load_block_size) {
sizeInfo = ' — ' + t('LOAD blocks') + ': ' + data.load_block_size + ' B';
if (data.load_block_size_auto) sizeInfo += ' (' + t('default') + ')';
}
ramShowInstallResult(data, t('Install OK') + ' — load_file_aid=' + data.load_file_aid
+ ' module_aid=' + data.module_aid + sizeInfo);
}
// INSTALL [for install] / [for make selectable] for an already loaded
// package: one APDU, so the parameters can be iterated without deleting and
// re-loading the .cap every time.
async function ramInstallApp(sp, op) {
const clean = id => (document.getElementById(id).value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
const mode = op === 'make-selectable' ? 'make_selectable' : 'install';
const loadfile = clean('ram-app-loadfile');
const module = clean('ram-app-module');
const instance = clean('ram-app-instance');
if (mode === 'make_selectable') {
if (!instance) { alert(t('Instance AID required')); return; }
} else if (!loadfile || !module) {
alert(t('Load File AID and Module AID required'));
return;
}
// The privileges and the STK hex are derived from the form, never sent
// stale (see ramInstallCap).
updateRcPriv();
const stkEl = document.getElementById('ram-stk-params');
if (document.getElementById('rc-toolkit-enable').checked && !(stkEl.dataset && stkEl.dataset.manual)) {
updateStkParamsHex();
}
const body = {
mode: mode,
loadfile_aid: loadfile,
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, ''),
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,
};
ramShowProgress(t('Sending to card...'));
const data = await pysimFetch('/api/ram-install-app', body);
ramHideProgress();
ramShowInstallResult(data, t('OK'));
}
// Shared result rendering for /api/ram-install and /api/ram-install-app: the
// per-step lines, the counter the card consumed, and the verdict.
function ramShowInstallResult(data, okText) {
const resultEl = document.getElementById('ram-result');
const stepsEl = document.getElementById('ram-steps');
stepsEl.classList.remove('hidden');
let txt = '';
(data.steps || []).forEach((s, idx) => { txt += ramStepLine(s, idx) + '\n'; });
stepsEl.textContent = txt;
// Persist the counter the card actually consumed (accepted packets only):
// even a failed install leaves the accepted steps behind, and replaying
// their counter would make the card reject the next attempt.
if (data.final_cntr) ramSaveCntr(data.final_cntr);
if (data.success) {
let sizeInfo = '';
if (data.load_block_size) {
sizeInfo = ' — ' + t('LOAD blocks') + ': ' + data.load_block_size + ' B';
if (data.load_block_size_auto) sizeInfo += ' (' + t('default') + ')';
}
resultEl.textContent = t('Install OK') + ' — load_file_aid=' + data.load_file_aid + ' module_aid=' + data.module_aid + sizeInfo;
resultEl.textContent = okText || t('OK');
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
} else {
resultEl.textContent = t('Install FAILED at step:') + ' ' + data.failed_step + (data.error ? ' (' + data.error + ')' : '');
@@ -9211,6 +9315,7 @@ async function ramExecute() {
try {
if (op === 'explore') await ramExplore(sp);
else if (op === 'install-cap') await ramInstallCap(sp);
else if (op === 'install-app' || op === 'make-selectable') await ramInstallApp(sp, op);
} catch (e) {
const resultEl = document.getElementById('ram-result');
resultEl.textContent = t('Error') + ': ' + e.message;
@@ -17352,6 +17457,15 @@ const LANG_RU = {
'From card': 'С карты',
'Priority (0 = RFU)': 'Приоритет (0 = RFU)',
'Applet-specific parameters (C9, hex, optional)': 'Параметры апплета (C9, hex, опционально)',
'INSTALL [for install] (loaded package)': 'INSTALL [for install] (загруженный пакет)',
'INSTALL [for make selectable]': 'INSTALL [for make selectable]',
'Load File AID / Package AID (hex)': 'Load File AID / AID пакета (hex)',
'Executable Module AID / Applet Class AID (hex)': 'AID модуля / класса апплета (hex)',
'Instance AID (hex, empty = module AID)': 'AID экземпляра (hex, пусто = AID модуля)',
'Install': 'Установить',
'Instance AID required': 'Требуется AID экземпляра',
'Load File AID and Module AID required': 'Требуются Load File AID и Module AID',
'Sending to card...': 'Отправка на карту...',
'C7 / C8 memory quotas (bytes, optional)': 'Квоты памяти C7 / C8 (байты, опционально)',
'STK parameters not generated: check the TAR (multiple of 3 bytes), menu IDs (<= 7F) and the ADF AID (5-16 bytes)': 'STK-параметры не сформированы: проверьте TAR (кратность 3 байтам), ID меню (<= 7F) и ADF AID (5-16 байт)',
'SCP80 (GSM 03.48, ETSI TS 102 225)': 'SCP80 (GSM 03.48, ETSI TS 102 225)',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v290';
const CACHE = 'simple-v291';
const URLS = [
'index.html',
'help.html',
+32 -1
View File
@@ -23,7 +23,7 @@ function extractFunc(src, name) {
// Extract chain builder functions and dependencies
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramGetStatusApdu', 'ramDeleteApdu',
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer',
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance',
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute',
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
@@ -441,6 +441,37 @@ test('ramRemoteSwOk mirrors the server success set', () => {
}
});
test('ramHasInstance matches instances by AID prefix', () => {
const apps = [{ aid: 'F0414C4641610101' },
{ aid: 'A1130001180001FFFFFFFF89A1003908' }];
assert.strictEqual(ramHasInstance(apps, 'f0414c46416101'), true);
assert.strictEqual(ramHasInstance(apps, 'F0414C4641610101'), true);
assert.strictEqual(ramHasInstance(apps, 'F0414C46416001'), false);
assert.strictEqual(ramHasInstance([], 'F0414C46416101'), false);
});
test('the Explore result offers Install / Make selectable actions', () => {
global.t = s => s;
const out = ramRenderExploreHtml(
null,
[],
[{ aid: 'A000000151000000', lifecycle: '03', privileges: '' }],
[{ aid: 'F0414C46416101', lifecycle: '01', version: '0.0',
moduleAids: ['F0414C4641610101'] }]
);
// the module has no instance yet -> Install; the INSTALLED instance -> Make selectable
assert.ok(out.includes("ramInstallFromExplorer('F0414C46416101','F0414C4641610101')"), out);
assert.ok(out.includes("ramMakeSelectableFromExplorer('A000000151000000')"), out);
delete global.t;
});
test('the single-APDU ops are wired', () => {
assert.ok(/value="install-app"/.test(html), 'the INSTALL [for install] op is missing');
assert.ok(/value="make-selectable"/.test(html), 'the make selectable op is missing');
assert.ok(/async function ramInstallApp\(sp, op\)/.test(html), 'ramInstallApp is missing');
assert.ok(/pysimFetch\('\/api\/ram-install-app'/.test(html), 'the endpoint call is missing');
});
test('ramRemoveFromExplorer drops the object locally (cascade drops its applets)', () => {
let renders = 0;
globalThis.ramRenderExplorer = () => { renders++; };
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.6.17"
version = "3.6.18"
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.
+176 -87
View File
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.6.17'
VERSION = '3.6.18'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -721,6 +721,30 @@ def _encode_scts(dt=None):
])
def _cap_install_apdu(loadfile_aid, module_aid, instance_aid='', privileges='00',
install_params='', stk_params='', make_selectable=True):
"""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
p1 = 0x0C if make_selectable else 0x04
data = (_lv(loadfile_aid) + _lv(module_aid) + _lv(instance) +
_lv(privileges or '00') + _lv(params) + '00')
return '80E6%02X00%02X%s' % (p1, len(data) // 2, data)
def _cap_make_selectable_apdu(instance_aid, privileges='00'):
"""INSTALL [for make selectable] APDU (GP Card Spec 11.5.2.3.3, Table
11-44): '00' '00' lv(AID) lv(privileges) lv(params) lv(token); the
parameters and token are empty here. Case 3 (no Le)."""
data = '00' + '00' + _lv(instance_aid) + _lv(privileges or '00') + '00' + '00'
return '80E60800%02X%s' % (len(data) // 2, data)
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):
@@ -746,14 +770,10 @@ def _cap_apdu_sequence(loadfile_aid, module_aid, loadfile_data, sd_aid='',
for i, block in enumerate(blocks):
p1 = 0x80 if i == len(blocks) - 1 else 0x00
apdus.append('80E8%02X%02X%02X%s' % (p1, i % 256, len(block) // 2, block))
instance = instance_aid or module_aid
params = install_params if install_params else 'C900'
if stk_params:
params += stk_params
p1_install = 0x0C if make_selectable else 0x04
ifi_data = (_lv(loadfile_aid) + _lv(module_aid) + _lv(instance) +
_lv(privileges or '00') + _lv(params) + '00')
apdus.append('80E6%02X00%02X%s' % (p1_install, len(ifi_data) // 2, ifi_data))
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))
return apdus
@@ -1176,6 +1196,77 @@ def _ram_step_result(step_name, last_sw, por, por_hex, bytes_, segments):
return step, error
def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex):
"""Send one GP APDU as an SCP80 secured packet and append its step result
to `state['steps']`. `sp` carries the SCP80 parameters (spi1, spi2, kic,
kid, tar, kic_key, kid_key, include_cpi); `state` carries the mutable run
state ({'steps', 'encode_error', 'failure', 'cntr'}) and the counter is
advanced only for a packet the card accepted. Returns True on success
(the step record is the last element of state['steps'])."""
spi1, spi2 = sp['spi1'], sp['spi2']
try:
sp_hex, _ = _build_secured_packet(spi1, spi2, sp['kic'], sp['kid'], sp['tar'],
state['cntr'], apdu_hex,
sp['kic_key'], sp['kid_key'])
except ValueError as e:
state['encode_error'] = str(e)
state['steps'].append({'name': step_name, 'por_status': 'encode_error',
'sw': str(e)})
sys.stderr.write('RAM-INSTALL: %s encode failed: %s\n' % (step_name, e))
return False
submit_handler = None
old_proactive = None
if bool(int(spi2, 16) & 0x20) and hasattr(scc, '_tp'):
submit_handler = PoRSubmitHandler()
old_proactive = scc._tp.proactive_handler
scc._tp.proactive_handler = submit_handler
try:
result = _send_secured_packet(
scc, sp_hex, oa_number=server.sms_oa, sm_sc=server.sms_sc,
include_cpi=sp['include_cpi'], submit_handler=submit_handler)
if not result['success']:
state['steps'].append({'name': step_name, 'por_status': 'envelope_error',
'sw': result.get('sw') or result.get('error'),
'bytes': result.get('bytes'),
'segments': result.get('segments')})
sys.stderr.write('RAM-INSTALL: %s send failed: %s\n' % (
step_name, result.get('error')))
return False
por_hex = result['response_data']
last_sw = result['sw']
por_src = 'envelope'
submit_hex = _sms_submit_por(submit_handler)
if submit_hex:
por_hex = submit_hex
por_src = 'sms-submit'
elif submit_handler and submit_handler.submit_tpdu_hex:
# no assembled UD: fall back to a raw RPI packet
tpdu_b = bytes.fromhex(submit_handler.submit_tpdu_hex)
idx = tpdu_b.find(b'\x02\x71\x00')
if idx >= 0:
por_hex = tpdu_b[idx:].hex()
por_src = 'sms-submit'
por = _decode_por(spi1, spi2, sp['kic'], sp['kid'], state['cntr'],
sp['kic_key'], sp['kid_key'], por_hex)
step, step_error = _ram_step_result(step_name, last_sw, por, por_hex,
result['bytes'], result['segments'])
state['steps'].append(step)
sys.stderr.write('RAM-INSTALL: %s PoR[%s] status=%s remote_sw=%s%s (%d B, %d SM)\n' % (
step_name, por_src, step.get('por_status', '?'), step.get('por_sw', '-'),
(' error=%s' % step_error) if step_error else '',
result['bytes'], result['segments']))
# Advance the counter only for an accepted packet
state['cntr'] = _ram_next_cntr(
state['cntr'], step.get('por_status') in ('por_ok', 'no_por'))
if step_error:
state['failure']['error'] = '%s: %s' % (step_name, step_error)
return False
return True
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
def _parse_response_scripting(data):
"""Parse a Response Scripting template (TS 102 226 5.2.2, tables
5.10/5.10a): `AB <len>` (definite) or `AF 80 ... 00 00` (indefinite),
@@ -4371,7 +4462,7 @@ _TEST_KIND_LABELS = {
_TEST_BLOCKED_PATHS = frozenset([
'/api/command', '/api/cardinfo', '/api/tree', '/api/select', '/api/read',
'/api/write', '/api/apdu', '/api/verify-adm', '/api/send-ota',
'/api/ram-install', '/api/sp-verify', '/api/menu-select',
'/api/ram-install', '/api/ram-install-app', '/api/sp-verify', '/api/menu-select',
'/api/menu-respond', '/api/event-send', '/api/net-sim',
'/api/net-state-refresh', '/api/status-poll', '/api/rescue',
'/api/terminal-profile', '/api/poll-toggle', '/api/esim/chip',
@@ -6125,78 +6216,11 @@ class PysimHandler(BaseHTTPRequestHandler):
sys.stderr.write('RAM-INSTALL: LOAD block size %d bytes\n' % block_size)
steps = []
encode_error = None
include_cpi = body.get('includeCpi', True)
spi2_val = int(spi2, 16)
por_in_submit = bool(spi2_val & 0x20)
failure = {}
def _send_gp_apdu(apdu_hex, step_name):
nonlocal cntr, encode_error
try:
sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, cntr, apdu_hex, kic_key, kid_key)
except ValueError as e:
encode_error = str(e)
steps.append({'name': step_name, 'por_status': 'encode_error',
'sw': encode_error})
sys.stderr.write('RAM-INSTALL: %s encode failed: %s\n' % (step_name, e))
return False
submit_handler = None
old_proactive = None
if por_in_submit and hasattr(scc, '_tp'):
submit_handler = PoRSubmitHandler()
old_proactive = scc._tp.proactive_handler
scc._tp.proactive_handler = submit_handler
try:
result = _send_secured_packet(
scc, sp_hex, oa_number=self.server.sms_oa,
sm_sc=self.server.sms_sc, include_cpi=include_cpi,
submit_handler=submit_handler)
if not result['success']:
steps.append({'name': step_name, 'por_status': 'envelope_error',
'sw': result.get('sw') or result.get('error'),
'bytes': result.get('bytes'),
'segments': result.get('segments')})
sys.stderr.write('RAM-INSTALL: %s send failed: %s\n' % (
step_name, result.get('error')))
return False
last_data = result['response_data']
last_sw = result['sw']
# Decode PoR
por_src = 'envelope'
por_hex = last_data
submit_hex = _sms_submit_por(submit_handler)
if submit_hex:
por_hex = submit_hex
por_src = 'sms-submit'
elif submit_handler and submit_handler.submit_tpdu_hex:
# no assembled UD: fall back to a raw RPI packet
tpdu_b = bytes.fromhex(submit_handler.submit_tpdu_hex)
idx = tpdu_b.find(b'\x02\x71\x00')
if idx >= 0:
por_hex = tpdu_b[idx:].hex()
por_src = 'sms-submit'
por = _decode_por(spi1, spi2, kic, kid, cntr, kic_key, kid_key, por_hex)
step, step_error = _ram_step_result(
step_name, last_sw, por, por_hex,
result['bytes'], result['segments'])
steps.append(step)
sys.stderr.write('RAM-INSTALL: %s PoR[%s] status=%s remote_sw=%s%s (%d B, %d SM)\n' % (
step_name, por_src, step.get('por_status', '?'),
step.get('por_sw', '-'),
(' error=%s' % step_error) if step_error else '',
result['bytes'], result['segments']))
# Advance the counter only for an accepted packet
cntr = _ram_next_cntr(
cntr, step.get('por_status') in ('por_ok', 'no_por'))
if step_error:
failure['error'] = '%s: %s' % (step_name, step_error)
return False
return True
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
state = {'steps': steps, 'encode_error': None, 'failure': {},
'cntr': cntr}
sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'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(
@@ -6213,11 +6237,12 @@ class PysimHandler(BaseHTTPRequestHandler):
step_name = 'INSTALL [for install]'
else:
step_name = 'LOAD (%d/%d)' % (apdu_idx, len(seq) - 2)
if not _send_gp_apdu(gp_apdu, step_name):
if not _ram_send_gp_apdu(self.server, scc, sp_state, state,
step_name, gp_apdu):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': (encode_error or failure.get('error')
'error': (state['encode_error'] or state['failure'].get('error')
or ('%s failed' % step_name)),
'final_cntr': cntr,
'final_cntr': state['cntr'],
'load_file_aid': loadfile_aid, 'module_aid': module_aid,
'load_block_size': block_size,
'load_block_size_requested': block_size_req,
@@ -6227,12 +6252,12 @@ class PysimHandler(BaseHTTPRequestHandler):
return
resp = {'success': True, 'steps': steps, 'load_file_aid': loadfile_aid,
'module_aid': module_aid, 'final_cntr': cntr,
'module_aid': module_aid, 'final_cntr': state['cntr'],
'load_block_size': block_size,
'load_block_size_requested': block_size_req,
'load_block_size_auto': not block_size_req}
sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
loadfile_aid, module_aid, cntr))
loadfile_aid, module_aid, state['cntr']))
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
@@ -6240,6 +6265,70 @@ class PysimHandler(BaseHTTPRequestHandler):
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/ram-install-app':
# INSTALL [for install] / [for make selectable] for an already
# loaded package (iterate the final step without re-loading).
body = self._read_body()
self._log_req(body)
scc = self.server.scc
if not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
try:
mode = (body.get('mode') or 'install').strip()
loadfile_aid = (body.get('loadfile_aid') or '').replace(' ', '').upper()
module_aid = (body.get('module_aid') or '').replace(' ', '').upper()
instance_aid = ((body.get('instance_aid') or '').replace(' ', '').upper()
or module_aid)
privileges = (body.get('privileges') or '').replace(' ', '') or '00'
install_params_hex = (body.get('install_params') or '').replace(' ', '')
stk_params_hex = (body.get('stk_params') or '').replace(' ', '')
make_selectable = bool(body.get('make_selectable', True))
if mode == 'make_selectable':
if not instance_aid:
err = {'success': False, 'error': 'instance_aid required'}
self._send_json(err, 400)
self._log_resp(err)
return
apdu = _cap_make_selectable_apdu(instance_aid, privileges)
step_name = 'INSTALL [for make selectable]'
else:
if not loadfile_aid or not module_aid:
err = {'success': False,
'error': 'loadfile_aid and module_aid required'}
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)
step_name = 'INSTALL [for install]'
sp_state = {
'spi1': body.get('spi1', '16'), 'spi2': body.get('spi2', '01'),
'kic': body.get('kic', '25'), 'kid': body.get('kid', '25'),
'tar': body.get('tar', '000000'), 'cntr': body.get('cntr', '00000000'),
'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''),
'include_cpi': body.get('includeCpi', True)}
state = {'steps': [], 'encode_error': None, 'failure': {},
'cntr': sp_state['cntr']}
sys.stderr.write('RAM-INSTALL-APP: %s (%s) cntr=%s\n' % (
apdu, step_name, sp_state['cntr']))
ok = _ram_send_gp_apdu(self.server, scc, sp_state, state, step_name, apdu)
resp = {'success': bool(ok), 'steps': state['steps'],
'final_cntr': state['cntr']}
if not ok:
resp['error'] = (state['encode_error'] or state['failure'].get('error')
or ('%s failed' % step_name))
resp['failed_step'] = len(state['steps'])
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
sys.stderr.write('RAM-INSTALL-APP error: %s\n' % e)
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/scp81/bip':
body = self._read_body()
# Never log the pre-shared keys.
+35
View File
@@ -1207,6 +1207,41 @@ class CapApduSequenceTest(unittest.TestCase):
self.assertEqual(seq[0][:10], '80E6020012')
self.assertIn('06A00000010001' + '07A0000000040000' + '000000', seq[0])
def test_install_apdu_matches_the_live_apdu_and_the_sequence_tail(self):
# /api/ram-install-app reuses the exact final APDU of the full
# sequence (one builder, no drift). Pinned to the live no-STK
# INSTALL [for install] (decrypted 2026-09-28).
from pysim_simple_server.server import _cap_apdu_sequence, _cap_install_apdu
self.assertEqual(
_cap_install_apdu('F0414C46416101', 'F0414C4641610101'),
'80E60C0020' + '07F0414C46416101' + '08F0414C4641610101'
+ '08F0414C4641610101' + '0100' + '02C900' + '00')
stk = 'EA0F800D000000000102011203AF4D0100'
seq = _cap_apdu_sequence('F0414C46416101', 'F0414C4641610101', 'AABBCCDD',
stk_params=stk)
self.assertEqual(seq[-1], _cap_install_apdu(
'F0414C46416101', 'F0414C4641610101', stk_params=stk))
# case 3: the length is header + Lc data, no trailing Le
lc = int(seq[-1][8:10], 16)
self.assertEqual(len(seq[-1]), 10 + 2 * lc)
# make selectable switches P1 to 0x0C
self.assertTrue(_cap_install_apdu(
'F0414C46416101', 'F0414C4641610101').startswith('80E60C00'))
self.assertTrue(_cap_install_apdu(
'F0414C46416101', 'F0414C4641610101',
make_selectable=False).startswith('80E60400'))
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.
from pysim_simple_server.server import _cap_make_selectable_apdu
self.assertEqual(
_cap_make_selectable_apdu('F0414C4641610101'),
'80E608000F' + '0000' + '08F0414C4641610101' + '0100' + '0000')
self.assertEqual(
_cap_make_selectable_apdu('F0414C4641610101', '04'),
'80E608000F' + '0000' + '08F0414C4641610101' + '0104' + '0000')
def test_gen_install_returns_the_apdu_list(self):
# /api/scp81/gen-install: build the INSTALL/LOAD/INSTALL list for a
# .cap without touching any listener or script state.