Privileges
@@ -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 += '
' + esc(t('Application / Instance AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?');
if (o.aid) {
html += ' ' + esc(t('Delete')) + ' ';
+ if ((o.lifecycle || '').toUpperCase() === '03') {
+ html += ' ' + esc(t('Make selectable')) + ' ';
+ }
}
html += '
';
html += '
' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '') + '
';
@@ -8869,7 +8894,15 @@ function ramRenderExploreHtml(mem, isd, apps, elfs) {
if (o.version) html += '
' + esc(t('Version:')) + ' ' + o.version + '
';
if (o.moduleAids && o.moduleAids.length) {
html += '
' + esc(t('Executable Module AIDs / Applet Class AIDs:')) + '
';
- o.moduleAids.forEach(m => { html += '
- ' + jcAidHtml(m) + '
'; });
+ o.moduleAids.forEach(m => {
+ html += '
- ' + jcAidHtml(m);
+ // A loaded applet class with no instance yet: one click to
+ // the single-APDU INSTALL [for install].
+ if (!ramHasInstance(apps, m)) {
+ html += ' ' + esc(t('Install')) + ' ';
+ }
+ html += '
';
+ });
}
if (o.sdAid) html += '
' + esc(t('SD AID:')) + ' ' + jcAidHtml(o.sdAid) + '
';
html += '
';
@@ -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)',
diff --git a/frontend/sw.js b/frontend/sw.js
index c4252da..6145420 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v290';
+const CACHE = 'simple-v291';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js
index 864487a..7778c1e 100644
--- a/frontend/tests/ram.test.js
+++ b/frontend/tests/ram.test.js
@@ -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++; };
diff --git a/pyproject.toml b/pyproject.toml
index 187d276..8c60a51 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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.
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index 4aa219f..35f21e5 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.6.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
` (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.
diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py
index 4b700f1..b1e8d0c 100644
--- a/tests/test_ota_helpers.py
+++ b/tests/test_ota_helpers.py
@@ -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.