@@ -1665,7 +1665,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.8';
+const SIMPLE_VERSION = '3.6.9';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -2503,40 +2503,39 @@ function updateRcTkMode() {
document.getElementById('rc-tk-ad-row').style.display = isSim ? '' : 'none';
document.getElementById('rc-tk-services-row').style.display = isSim ? 'none' : '';
}
-document.getElementById('rc-tk-mode').addEventListener('change', () => { updateRcTkMode(); updateStkParamsHex(); });
-function buildRcToolkitParams() {
- if (!document.getElementById('rc-toolkit-enable').checked) return '';
- const tkMode = document.getElementById('rc-tk-mode').value;
- const priority = parseInt(document.getElementById('rc-tk-priority').value) || 0;
- const timers = parseInt(document.getElementById('rc-tk-timers').value) || 0;
- const textLen = parseInt(document.getElementById('rc-tk-textlen').value) || 0;
- const menus = parseInt(document.getElementById('rc-tk-menus').value) || 0;
- const firstPos = parseInt(document.getElementById('rc-tk-firstpos').value) || 0;
- const firstId = (document.getElementById('rc-tk-firstid').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
- const lastPos = parseInt(document.getElementById('rc-tk-lastpos').value) || 0;
- const lastId = (document.getElementById('rc-tk-lastid').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
- const channels = parseInt(document.getElementById('rc-tk-channels').value) || 0;
- const msl = document.getElementById('rc-tk-msl').value;
- const tar = (document.getElementById('rc-tk-tar').value || '').replace(/[^0-9a-fA-F]/g, '');
- const adRaw = (document.getElementById('rc-tk-ad').value || '').replace(/[^0-9a-fA-F]/g, '');
+// Pure SIM/UICC toolkit install-parameter builder shared by the RAM install
+// form (buildRcToolkitParams) and the RAM/GP chain rows (buildTkParams).
+// `v` carries the field values; returns the CA/EA TLV hex, or null when a
+// value cannot be coded: each TAR is 3 bytes (6 hex digits), and menu item
+// identifiers 128..255 are reserved for the toolkit framework, so only
+// 01..7F may be requested (TS 102 226 8.2.1.3.2.3).
+function stkParamsBuild(v) {
+ const priority = parseInt(v.priority, 10) || 0;
+ const timers = parseInt(v.timers, 10) || 0;
+ const textLen = parseInt(v.textLen, 10) || 0;
+ const menus = parseInt(v.menus, 10) || 0;
+ const firstPos = parseInt(v.firstPos, 10) || 0;
+ const firstId = (v.firstId || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
+ const lastPos = parseInt(v.lastPos, 10) || 0;
+ const lastId = (v.lastId || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
+ const channels = parseInt(v.channels, 10) || 0;
+ const msl = v.msl || '00';
+ const tar = (v.tar || '').replace(/[^0-9a-fA-F]/g, '');
+ const adRaw = (v.ad || '').replace(/[^0-9a-fA-F]/g, '');
const ad = adRaw.padStart(2, '0').slice(0, 2);
- const services = parseInt(document.getElementById('rc-tk-services').value) || 0;
-
+ const services = parseInt(v.services, 10) || 0;
if (tar && tar.length % 6 !== 0) return null;
if (parseInt(firstId, 16) > 0x7F || parseInt(lastId, 16) > 0x7F) return null;
-
let menuPairs = '';
for (let i = 1; i <= menus; i++) {
if (i === 1) menuPairs += firstPos.toString(16).padStart(2, '0') + firstId;
else if (i === menus) menuPairs += lastPos.toString(16).padStart(2, '0') + lastId;
else menuPairs += '0000';
}
-
const mslField = msl === '00' ? '00' : '0201' + msl;
-
let tkPayload = '';
- if (tkMode === 'ca') {
+ if (v.mode === 'ca') {
tkPayload = (adRaw ? '01' + ad : '00') +
priority.toString(16).padStart(2, '0') +
timers.toString(16).padStart(2, '0') +
@@ -2548,24 +2547,42 @@ function buildRcToolkitParams() {
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00');
const caTlv = 'CA' + berLenStr(tkPayload.length / 2) + tkPayload;
return 'EF' + berLenStr(caTlv.length / 2) + caTlv;
- } else {
- tkPayload = priority.toString(16).padStart(2, '0') +
- timers.toString(16).padStart(2, '0') +
- textLen.toString(16).padStart(2, '0') +
- menus.toString(16).padStart(2, '0') +
- menuPairs +
- channels.toString(16).padStart(2, '0') +
- mslField +
- (tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00') +
- services.toString(16).padStart(2, '0');
- const innerTlv = '80' + berLenStr(tkPayload.length / 2) + tkPayload;
- return 'EA' + berLenStr(innerTlv.length / 2) + innerTlv;
}
+ tkPayload = priority.toString(16).padStart(2, '0') +
+ timers.toString(16).padStart(2, '0') +
+ textLen.toString(16).padStart(2, '0') +
+ menus.toString(16).padStart(2, '0') +
+ menuPairs +
+ channels.toString(16).padStart(2, '0') +
+ mslField +
+ (tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00') +
+ services.toString(16).padStart(2, '0');
+ const innerTlv = '80' + berLenStr(tkPayload.length / 2) + tkPayload;
+ return 'EA' + berLenStr(innerTlv.length / 2) + innerTlv;
+}
+
+// The RAM install form's toolkit fields -> the install parameters hex.
+function buildRcToolkitParams() {
+ if (!document.getElementById('rc-toolkit-enable').checked) return '';
+ const g = id => document.getElementById(id).value;
+ return stkParamsBuild({
+ mode: g('rc-tk-mode'),
+ priority: g('rc-tk-priority'), timers: g('rc-tk-timers'),
+ textLen: g('rc-tk-textlen'), menus: g('rc-tk-menus'),
+ firstPos: g('rc-tk-firstpos'), firstId: g('rc-tk-firstid'),
+ lastPos: g('rc-tk-lastpos'), lastId: g('rc-tk-lastid'),
+ channels: g('rc-tk-channels'), msl: g('rc-tk-msl'),
+ tar: g('rc-tk-tar'), ad: g('rc-tk-ad'), services: g('rc-tk-services'),
+ });
}
function updateStkParamsHex() {
const tk = buildRcToolkitParams();
- document.getElementById('ram-stk-params').value = tk || '';
+ const el = document.getElementById('ram-stk-params');
+ el.value = tk || '';
+ // A hand-edited hex is an explicit override: keep it until a toolkit
+ // field is edited again (this clears the flag).
+ if (el.dataset) delete el.dataset.manual;
}
function updateInstallParamsHex() {
@@ -3377,7 +3394,7 @@ function chainRamToolkitHtml(chainId, idx, f, uf) {
var lastId = f.tkLastid || '00';
var channels = f.tkChannels || '0';
var msl = f.tkMsl || '16';
- var tar = f.tkTar || 'B00001';
+ var tar = f.tkTar || '';
var ad = f.tkAd || '';
var services = f.tkServices || '0';
html += '
' +
@@ -4028,57 +4045,22 @@ function chainRamBuildRowHex(idx, row) {
if (b3) r += b3.toString(16).padStart(2, '0').toUpperCase();
return r;
}
+ // The chain row's toolkit fields -> the install parameters hex.
function buildTkParams() {
if (!f.tkEnabled) return '';
- var tkMode = f.tkMode || 'ea';
- var priority = parseInt(f.tkPriority) || 0;
- var timers = parseInt(f.tkTimers) || 0;
- var textLen = parseInt(f.tkTextlen) || 0;
- var menus = parseInt(f.tkMenus) || 0;
- var firstPos = parseInt(f.tkFirstpos) || 0;
- var firstId = (f.tkFirstid || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
- var lastPos = parseInt(f.tkLastpos) || 0;
- var lastId = (f.tkLastid || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
- var channels = parseInt(f.tkChannels) || 0;
- var msl = f.tkMsl || '00';
- var tar = (f.tkTar || '').replace(/[^0-9a-fA-F]/g, '');
- var ad = (f.tkAd || '').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2);
- var services = parseInt(f.tkServices) || 0;
- if (tar && tar.length % 6 !== 0) return null;
- var menuPairs = '';
- for (var i = 1; i <= menus; i++) {
- if (i === 1) menuPairs += firstPos.toString(16).padStart(2, '0') + firstId;
- else if (i === menus) menuPairs += lastPos.toString(16).padStart(2, '0') + lastId;
- else menuPairs += '0000';
- }
- var mslField = msl === '00' ? '00' : '0201' + msl;
- var tkPayload = '';
- if (tkMode === 'ca') {
- tkPayload = (f.tkAd ? '01' + ad : '00') +
- priority.toString(16).padStart(2, '0') +
- timers.toString(16).padStart(2, '0') +
- textLen.toString(16).padStart(2, '0') +
- menus.toString(16).padStart(2, '0') +
- menuPairs +
- channels.toString(16).padStart(2, '0') +
- mslField +
- (tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00');
- var caTlv = 'CA' + berLenStr(tkPayload.length / 2) + tkPayload;
- return 'EF' + berLenStr(caTlv.length / 2) + caTlv;
- } else {
- tkPayload = priority.toString(16).padStart(2, '0') +
- timers.toString(16).padStart(2, '0') +
- textLen.toString(16).padStart(2, '0') +
- menus.toString(16).padStart(2, '0') +
- menuPairs +
- channels.toString(16).padStart(2, '0') +
- mslField +
- (tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00') +
- services.toString(16).padStart(2, '0');
- var innerTlv = '80' + berLenStr(tkPayload.length / 2) + tkPayload;
- return 'EA' + berLenStr(innerTlv.length / 2) + innerTlv;
- }
+ return stkParamsBuild({
+ mode: f.tkMode || 'ea',
+ priority: f.tkPriority, timers: f.tkTimers, textLen: f.tkTextlen,
+ menus: f.tkMenus, firstPos: f.tkFirstpos, firstId: f.tkFirstid,
+ lastPos: f.tkLastpos, lastId: f.tkLastid, channels: f.tkChannels,
+ msl: f.tkMsl || '16', tar: f.tkTar, ad: f.tkAd,
+ services: f.tkServices,
+ });
}
+ // INSTALL/LOAD are case-3 commands (no trailing Le), matching the
+ // reference terminal form and the server's _cap_apdu_sequence: a trailing
+ // Le made the card execute a phantom second command whose status word
+ // masked the real result (v3.6.8). DELETE/GET STATUS/GET DATA keep Le.
var INSTALL_P1 = {
'install-load': 0x02, 'install-install': 0x0C,
'install-make-sel': 0x08, 'install-reg-update': 0x40,
@@ -4092,7 +4074,7 @@ function chainRamBuildRowHex(idx, row) {
if (!loadFileAid) return '';
var dataField = lv(loadFileAid) + lv(sdAid) + '00' + (loadData ? lv(loadData) : '00') + '00';
var dataLen = berLenStr(dataField.length / 2);
- return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField) + '00';
+ return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField);
}
if (cmd === 'install-install') {
var elfAid = (f.elfAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
@@ -4105,35 +4087,35 @@ function chainRamBuildRowHex(idx, row) {
if (tkParams) installParams += tkParams;
var df2 = lv(elfAid) + lv(modAid) + lv(aid) + lv(privBytes) + lv(installParams) + '00';
var dl2 = berLenStr(df2.length / 2);
- return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl2, 16), df2) + '00';
+ return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl2, 16), df2);
}
if (cmd === 'install-make-sel') {
if (!aid) return '';
var privBytes2 = computePriv();
var df3 = '00' + '00' + lv(aid) + lv(privBytes2) + '00' + '00';
var dl3 = berLenStr(df3.length / 2);
- return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl3, 16), df3) + '00';
+ return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl3, 16), df3);
}
if (cmd === 'install-reg-update') {
if (!aid) return '';
var privBytes3 = computePriv();
var df4 = lv(sdAid) + '00' + lv(aid) + lv(privBytes3) + '00' + '00';
var dl4 = berLenStr(df4.length / 2);
- return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl4, 16), df4) + '00';
+ return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl4, 16), df4);
}
if (cmd === 'install-extradition') {
if (!sdAid || sdAid.length < 10 || sdAid.length > 20) return '';
if (!aid) return '';
var df5 = lv(sdAid) + '00' + lv(aid) + '00' + '00' + '00';
var dl5 = berLenStr(df5.length / 2);
- return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl5, 16), df5) + '00';
+ return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl5, 16), df5);
}
if (cmd === 'load') {
var ramData = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var blockNum = parseInt(f.block) || 0;
if (!ramData) return '';
var dl6 = berLenStr(ramData.length / 2);
- return buildApdu(0x80, 0xE8, 0x80, blockNum, parseInt(dl6, 16), ramData) + '00';
+ return buildApdu(0x80, 0xE8, 0x80, blockNum, parseInt(dl6, 16), ramData);
}
if (cmd === 'delete') {
if (!aid) return '';
@@ -8931,6 +8913,14 @@ async function ramInstallCap(sp) {
const capHex = await ramReadFileHex(file);
ramShowProgress(t('Sending to server for install...'));
+ // The STK hex is regenerated on every toolkit field edit; recompute it
+ // once more at send time unless the user pasted a custom hex directly
+ // (dataset.manual), so the sent parameters always match the fields.
+ const stkEl = document.getElementById('ram-stk-params');
+ if (document.getElementById('rc-toolkit-enable').checked && !(stkEl.dataset && stkEl.dataset.manual)) {
+ updateStkParamsHex();
+ }
+
const body = {
cap_hex: capHex,
sd_aid: (document.getElementById('ram-sd-aid').value || '').replace(/[^0-9a-fA-F]/g, ''),
diff --git a/frontend/sw.js b/frontend/sw.js
index b6e31f3..c524c83 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v281';
+const CACHE = 'simple-v282';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js
index a2da085..73f1d96 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',
- 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer',
+ 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer',
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute',
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
@@ -79,7 +79,7 @@ function genRamApdu() {
tkLastid: '02',
tkChannels: '0',
tkMsl: '16',
- tkTar: 'B00001',
+ tkTar: 'AF4D01',
tkAd: '',
tkServices: '0',
});
@@ -87,7 +87,7 @@ function genRamApdu() {
test('UICC toolkit nested inside EA (m=2, services 0)', () => {
const apdu = genRamApdu();
- assert.ok(apdu.includes('EA13801100000002010102020002011603B0000100'), apdu);
+ assert.ok(apdu.includes('EA13801100000002010102020002011603AF4D0100'), apdu);
});
test('UICC m=1 emits single pair', () => {
@@ -95,9 +95,9 @@ test('UICC m=1 emits single pair', () => {
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
tkEnabled: true, tkMode: 'ea', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
tkMenus: '1', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '0', tkLastid: '00',
- tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '', tkServices: '0',
+ tkChannels: '0', tkMsl: '16', tkTar: 'AF4D01', tkAd: '', tkServices: '0',
});
- assert.ok(apdu.includes('EA11800F0000000101010002011603B0000100'), apdu);
+ assert.ok(apdu.includes('EA11800F0000000101010002011603AF4D0100'), apdu);
});
test('UICC m=3 fills middle pair with 0000', () => {
@@ -105,9 +105,9 @@ test('UICC m=3 fills middle pair with 0000', () => {
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
tkEnabled: true, tkMode: 'ea', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
tkMenus: '3', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '3', tkLastid: '03',
- tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '', tkServices: '0',
+ tkChannels: '0', tkMsl: '16', tkTar: 'AF4D01', tkAd: '', tkServices: '0',
});
- assert.ok(apdu.includes('EA158013000000030101000003030002011603B0000100'), apdu);
+ assert.ok(apdu.includes('EA158013000000030101000003030002011603AF4D0100'), apdu);
});
test('UICC services 7 appended as final byte', () => {
@@ -115,9 +115,9 @@ test('UICC services 7 appended as final byte', () => {
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
tkEnabled: true, tkMode: 'ea', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
tkMenus: '2', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '2', tkLastid: '02',
- tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '', tkServices: '7',
+ tkChannels: '0', tkMsl: '16', tkTar: 'AF4D01', tkAd: '', tkServices: '7',
});
- assert.ok(apdu.includes('EA13801100000002010102020002011603B0000107'), apdu);
+ assert.ok(apdu.includes('EA13801100000002010102020002011603AF4D0107'), apdu);
});
test('SIM (CA) access domain FIRST, no services byte', () => {
@@ -125,9 +125,9 @@ test('SIM (CA) access domain FIRST, no services byte', () => {
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
tkEnabled: true, tkMode: 'ca', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
tkMenus: '2', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '2', tkLastid: '02',
- tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '5A', tkServices: '0',
+ tkChannels: '0', tkMsl: '16', tkTar: 'AF4D01', tkAd: '5A', tkServices: '0',
});
- assert.ok(apdu.includes('EF14CA12015A00000002010102020002011603B00001'), apdu);
+ assert.ok(apdu.includes('EF14CA12015A00000002010102020002011603AF4D01'), apdu);
});
test('SIM (CA) blank access domain emits length byte 00', () => {
@@ -135,9 +135,9 @@ test('SIM (CA) blank access domain emits length byte 00', () => {
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
tkEnabled: true, tkMode: 'ca', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
tkMenus: '2', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '2', tkLastid: '02',
- tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '', tkServices: '0',
+ tkChannels: '0', tkMsl: '16', tkTar: 'AF4D01', tkAd: '', tkServices: '0',
});
- assert.ok(apdu.includes('EF13CA110000000002010102020002011603B00001'), apdu);
+ assert.ok(apdu.includes('EF13CA110000000002010102020002011603AF4D01'), apdu);
});
test('SIM (CA) m=3, no TAR, blank access domain', () => {
@@ -155,7 +155,7 @@ test('UICC m=60 uses long-form BER lengths (EA 81 87 / inner 81 84)', () => {
cmd: 'install-install', aid: 'A000000151000000', priv: '00',
tkEnabled: true, tkMode: 'ea', tkPriority: '0', tkTimers: '0', tkTextlen: '0',
tkMenus: '60', tkFirstpos: '1', tkFirstid: '01', tkLastpos: '60', tkLastid: '3C',
- tkChannels: '0', tkMsl: '16', tkTar: 'B00001', tkAd: '', tkServices: '0',
+ tkChannels: '0', tkMsl: '16', tkTar: 'AF4D01', tkAd: '', tkServices: '0',
});
assert.ok(apdu.includes('EA8188808185'), apdu);
});
diff --git a/frontend/tests/ram_counter_flow.test.js b/frontend/tests/ram_counter_flow.test.js
new file mode 100644
index 0000000..c4e806f
--- /dev/null
+++ b/frontend/tests/ram_counter_flow.test.js
@@ -0,0 +1,106 @@
+const { test } = require('node:test');
+const assert = require('node:assert');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
+
+function extractFunc(src, name) {
+ const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
+ const m = re.exec(src);
+ if (!m) throw new Error('function ' + name + ' not found');
+ let i = m.index + m[0].length - 1;
+ let depth = 0;
+ for (; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}') {
+ depth--;
+ if (depth === 0) break;
+ }
+ }
+ return src.slice(m.index, i + 1);
+}
+
+// The real functions behind the Explore Delete flow: the counter the card
+// consumed must be persisted into the preset, or the next operation starts
+// one behind and is rejected with cntr_low.
+let code = '';
+for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr',
+ 'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
+ 'ramHideProgress', 'ramDeleteFromExplorer']) {
+ code += extractFunc(html, fn) + '\n';
+}
+eval(code);
+
+function fakeEnv(por, success) {
+ const els = {};
+ const mk = () => ({ value: '', textContent: '',
+ classList: { add() {}, remove() {}, toggle() {} } });
+ for (const id of ['sp-spi1', 'sp-spi2', 'sp-kic-hex', 'sp-kid-hex', 'sp-tar',
+ 'sp-cntr', 'sp-kic-key', 'sp-kid-key', 'ram-result', 'ram-steps',
+ 'ram-progress', 'ram-progress-text']) els[id] = mk();
+ els['ram-card-sel'] = { value: '0' };
+ // the preset keys the real spRefreshFromPreset/cardsApply would copy into
+ // the form (the delete flow aborts without them)
+ els['sp-kic-key'].value = 'AA';
+ els['sp-kid-key'].value = 'BB';
+ globalThis.document = { getElementById: id => els[id] || null };
+ globalThis.cards = [{ name: 'C', cntr: '0000000005', kicKey: 'AA', kidKey: 'BB' }];
+ const calls = { saved: 0, explored: null, sent: null };
+ globalThis.cardsSave = () => { calls.saved++; };
+ globalThis.cardsRender = () => {};
+ globalThis.ramRender = () => {};
+ globalThis.t = s => s;
+ globalThis.alert = () => {};
+ globalThis.confirm = () => true;
+ // the preset re-read (cardsApply -> the sp-* form fields); the real
+ // spRefreshFromPreset is covered by cards_counter.test.js
+ globalThis.spRefreshFromPreset = () => {
+ els['sp-cntr'].value = cards[0].cntr;
+ return cards[0].cntr;
+ };
+ globalThis.ramSendOta = async (apdu, sp) => {
+ calls.sent = { apdu: apdu, cntr: sp.cntr };
+ return { success: success !== false, por: por };
+ };
+ globalThis.ramExplore = async sp => { calls.explored = sp.cntr; };
+ return { els, calls };
+}
+
+test('accepted delete persists the consumed counter and re-explores from it', async () => {
+ const { els, calls } = fakeEnv({ response_status: 'por_ok',
+ decoded: { last_status_word: '9000' } });
+ await ramDeleteFromExplorer('F0414C46416101', false);
+ assert.strictEqual(calls.sent.cntr, '0000000005');
+ assert.strictEqual(calls.sent.apdu, '80E40000094F07F0414C4641610100');
+ assert.strictEqual(cards[0].cntr, '0000000006',
+ 'the preset must carry the counter the card consumed');
+ assert.strictEqual(els['sp-cntr'].value, '0000000006');
+ assert.ok(calls.saved > 0, 'cardsSave() must persist it');
+ assert.strictEqual(calls.explored, '0000000006',
+ 'the re-explore must start at N+1, never replay N');
+ assert.strictEqual(els['ram-result'].textContent, 'OK');
+});
+
+test('cntr_low leaves the preset untouched (the card did not consume the packet)', async () => {
+ const { calls } = fakeEnv({ response_status: 'cntr_low' });
+ await ramDeleteFromExplorer('F0414C46416101', false);
+ assert.strictEqual(cards[0].cntr, '0000000005');
+ assert.strictEqual(calls.saved, 0);
+ assert.strictEqual(calls.explored, null);
+});
+
+test('a refused DELETE still advances the counter but does not re-explore', async () => {
+ const { calls } = fakeEnv({ response_status: 'por_ok',
+ decoded: { last_status_word: '6A88' } });
+ await ramDeleteFromExplorer('F0414C46416101', true);
+ assert.strictEqual(cards[0].cntr, '0000000006');
+ assert.strictEqual(calls.explored, null);
+});
+
+test('a send failure leaves the preset untouched', async () => {
+ const { calls } = fakeEnv({ response_status: 'por_ok' }, false);
+ await ramDeleteFromExplorer('F0414C46416101', false);
+ assert.strictEqual(cards[0].cntr, '0000000005');
+ assert.strictEqual(calls.explored, null);
+});
diff --git a/frontend/tests/stk_params.test.js b/frontend/tests/stk_params.test.js
new file mode 100644
index 0000000..03d334c
--- /dev/null
+++ b/frontend/tests/stk_params.test.js
@@ -0,0 +1,158 @@
+const { test } = require('node:test');
+const assert = require('node:assert');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
+
+function extractFunc(src, name) {
+ const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
+ const m = re.exec(src);
+ if (!m) throw new Error('function ' + name + ' not found');
+ let i = m.index + m[0].length - 1;
+ let depth = 0;
+ for (; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}') {
+ depth--;
+ if (depth === 0) break;
+ }
+ }
+ return src.slice(m.index, i + 1);
+}
+
+let code = '';
+for (const fn of ['berLenStr', 'stkParamsBuild', 'buildRcToolkitParams',
+ 'updateStkParamsHex']) code += extractFunc(html, fn) + '\n';
+eval(code);
+
+const base = { mode: 'ea', priority: '0', timers: '0', textLen: '0', menus: '0',
+ firstPos: '0', firstId: '00', lastPos: '0', lastId: '00',
+ channels: '0', msl: '00', tar: '', ad: '', services: '0' };
+function vals(over) { return Object.assign({}, base, over); }
+
+// ===== install-parameter coding (TS 102 226 8.2.1.3.2.2.1) =====
+
+test('UICC (EA) parameters match the decrypted live packet', () => {
+ // The install parameters of the live CAP install (decrypted):
+ // EA 0F | 80 0D
+ //
+ assert.strictEqual(
+ stkParamsBuild(vals({ channels: '1', msl: '12', tar: 'AF4D01' })),
+ 'EA0F800D000000000102011203AF4D0100');
+});
+
+test('empty TAR emits a zero-length TAR field, never a B00001 default', () => {
+ // B0 00 01 is the allocated ADF RFM TAR (TS 101 220 Annex D), not an
+ // applet TAR: the field stays empty and the TAR Value(s) length is 00
+ // (TS 102 226 8.2.1.3.2.7 - the card may then use a TAR from the AID,
+ // but only when the AID carries one).
+ const out = stkParamsBuild(vals({}));
+ assert.strictEqual(out, 'EA0A80080000000000000000');
+ assert.ok(!out.includes('B00001'), out);
+});
+
+test('SIM (CA) parameters carry the access domain first and no services byte', () => {
+ assert.strictEqual(stkParamsBuild(vals({ mode: 'ca' })),
+ 'EF0ACA080000000000000000');
+ assert.strictEqual(stkParamsBuild(vals({ mode: 'ca', ad: '00' })),
+ 'EF0BCA09010000000000000000');
+});
+
+test('menu item pairs run first .. last with 0000 fillers between', () => {
+ assert.strictEqual(stkParamsBuild(vals({ menus: '1', firstId: '7F' })),
+ 'EA0C800A00000001007F00000000');
+ const out = stkParamsBuild(vals({ menus: '3', firstPos: '1', firstId: '01',
+ lastPos: '3', lastId: '03' }));
+ assert.ok(out.includes('010100000303'), out);
+});
+
+test('long menu lists use BER long-form lengths', () => {
+ const out = stkParamsBuild(vals({ menus: '60', firstPos: '1', firstId: '01',
+ lastPos: '60', lastId: '3C', msl: '16', tar: 'AF4D01' }));
+ assert.ok(out.startsWith('EA8188808185'), out);
+});
+
+test('rejects a TAR that is not a whole number of 3-byte values', () => {
+ assert.strictEqual(stkParamsBuild(vals({ tar: 'AF4D' })), null);
+ assert.strictEqual(stkParamsBuild(vals({ tar: 'AF4D0' })), null);
+ assert.strictEqual(stkParamsBuild(vals({ tar: 'AF4D0102' })), null);
+ // several TAR values are allowed (3 bytes each)
+ assert.ok(stkParamsBuild(vals({ tar: 'AF4D01AFFA01' })).includes('06AF4D01AFFA01'));
+});
+
+test('rejects menu item identifiers above 7F (reserved for the toolkit framework)', () => {
+ assert.strictEqual(stkParamsBuild(vals({ menus: '1', firstId: '80' })), null);
+ assert.strictEqual(stkParamsBuild(vals({ menus: '1', lastId: 'FF' })), null);
+});
+
+// ===== form wiring: the STK settings must reach the sent hex =====
+
+test('every toolkit field regenerates the STK parameters hex on edit', () => {
+ // v3.6.9: the install used to send the hex computed once when the
+ // checkbox was ticked - edits to the fields afterwards were dropped
+ // (the live install carried the defaults, not the entered TAR).
+ const block = html.slice(html.indexOf('id="rc-toolkit-body"'),
+ html.indexOf('id="ram-install-params-hex"'));
+ const re = /<(?:input|select)\b[^>]*id="(rc-tk-[^"]+)"[^>]*>/g;
+ const seen = [];
+ let m;
+ while ((m = re.exec(block))) {
+ seen.push(m[1]);
+ assert.ok(/on(?:input|change)="[^"]*updateStkParamsHex/.test(m[0]),
+ m[1] + ' does not refresh the hex: ' + m[0]);
+ }
+ assert.strictEqual(seen.length, 14, 'expected 14 toolkit fields, got ' + seen.join(', '));
+});
+
+test('the applet TAR field has no B00001 default or placeholder', () => {
+ const m = /]*>/.exec(html);
+ assert.ok(m, 'rc-tk-tar not found');
+ assert.ok(!/value="B00001"/.test(m[0]), m[0]);
+ assert.ok(!/placeholder="B00001"/.test(m[0]), m[0]);
+ assert.ok(/oninput="updateStkParamsHex\(\)"/.test(m[0]), m[0]);
+ assert.ok(!html.includes("f.tkTar || 'B00001'"), 'the chain toolkit default must stay empty');
+});
+
+test('the RAM install recomputes the STK hex at send time unless hand-edited', () => {
+ assert.ok(/oninput="this\.dataset\.manual='1'"/.test(html),
+ 'the hex field must flag manual edits');
+ assert.ok(/rc-toolkit-enable'\)\.checked && !\(stkEl\.dataset && stkEl\.dataset\.manual\)/.test(html),
+ 'the send-time recompute guard is missing');
+});
+
+// ===== form -> hex (the live regression) =====
+
+function fakeForm(values) {
+ const ids = ['rc-toolkit-enable', 'rc-tk-mode', 'rc-tk-priority', 'rc-tk-timers',
+ 'rc-tk-textlen', 'rc-tk-menus', 'rc-tk-firstpos', 'rc-tk-firstid',
+ 'rc-tk-lastpos', 'rc-tk-lastid', 'rc-tk-channels', 'rc-tk-msl',
+ 'rc-tk-tar', 'rc-tk-ad', 'rc-tk-services', 'ram-stk-params'];
+ const els = {};
+ for (const id of ids) els[id] = { value: '', checked: false, dataset: {} };
+ for (const [id, v] of Object.entries(values || {})) {
+ if (id === 'rc-toolkit-enable') els[id].checked = v;
+ else els[id].value = v;
+ }
+ globalThis.document = { getElementById: id => els[id] || null };
+ return els;
+}
+
+test('the RAM form fields build the live install parameters end to end', () => {
+ // The reported regression: the values were entered after the toolkit
+ // checkbox was ticked and never reached the sent hex - the install
+ // carried TAR B00001 / MSL 16 / channels 0 and the card answered 6A80.
+ fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12',
+ 'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1' });
+ assert.strictEqual(buildRcToolkitParams(), 'EA0F800D000000000102011203AF4D0100');
+});
+
+test('updateStkParamsHex refreshes the field and clears the manual flag', () => {
+ const els = fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea',
+ 'rc-tk-tar': 'AF4D01' });
+ els['ram-stk-params'].dataset.manual = '1';
+ updateStkParamsHex();
+ assert.ok(els['ram-stk-params'].value.startsWith('EA'), els['ram-stk-params'].value);
+ assert.ok(!els['ram-stk-params'].dataset.manual,
+ 'a regeneration supersedes a manual edit');
+});
diff --git a/frontend/tests/ts102226.test.js b/frontend/tests/ts102226.test.js
index 5121022..0b7f589 100644
--- a/frontend/tests/ts102226.test.js
+++ b/frontend/tests/ts102226.test.js
@@ -30,7 +30,7 @@ function extractFunc(src, name) {
// GlobalPlatform Card Spec 11.8 (PUT KEY)
const FNS = ['berLenStr', 'buildApdu', 'buildSelect', 'escHtml', 'esc', 'chainInit',
'chainKind', 'chainIsEmbedded', 'chainCommands', 'chainBuildRowHex',
- 'chainSimBuildRowHex', 'chainRamBuildRowHex', 'chainPushData', '_hotaAsciiHex',
+ 'chainSimBuildRowHex', 'chainRamBuildRowHex', 'stkParamsBuild', 'chainPushData', '_hotaAsciiHex',
'chainApduList', 'chainBuildFcp', 'pushSectionApdus', 'pushOpenChannelTlvs'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
diff --git a/pyproject.toml b/pyproject.toml
index 0aadf8b..a63a439 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.6.8"
+version = "3.6.9"
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 2a365f8..ac0a7ec 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.8'
+VERSION = '3.6.9'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE