feat: card-specific ADF AID helper + priority default (v3.6.16)

- the ADF AID field gains a "From card" button: it reads the ADF.USIM FCI
  via /api/select and extracts the DF name (tag 84) with the new
  adfAidFromFci() - the AID is card-specific (the live card's is
  A0000000871002FF49FF0589, 12 bytes; the base A0000000871002 is only a
  prefix, and a shorter AID makes the card reject the access entry with
  6A80).
- the priority field defaults to 255 (0xFF) and is labelled "(0 = RFU)":
  TS 102 226 8.2.1.3.2.6 makes '00' RFU, which can break the toolkit
  registration; the builder defaults an unset priority to 0xFF and the
  chain rows follow.
- stkParamsBuild's formatted payload fields are uppercase now (the rest of
  the tool's hex is; toString(16) emits lowercase).
- tests: adfAidFromFci pinned to the live FCI, the From-card wiring, the
  updated form expectations.

643 frontend / 496 Python green; version 3.6.16; sw simple-v289.
This commit is contained in:
2026-09-28 03:32:13 +03:00
parent bab787fac1
commit 9a3174a324
5 changed files with 108 additions and 32 deletions
+80 -25
View File
@@ -754,8 +754,8 @@
</div> </div>
<div class="grid grid-cols-2 gap-x-3 gap-y-2 text-sm"> <div class="grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
<div> <div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Priority">Priority</label> <label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Priority (0 = RFU)">Priority (0 = RFU)</label>
<input id="rc-tk-priority" oninput="updateStkParamsHex()" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800"> <input id="rc-tk-priority" oninput="updateStkParamsHex()" type="number" min="0" max="255" value="255" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
</div> </div>
<div> <div>
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Timers (max)">Timers (max)</label> <label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Timers (max)">Timers (max)</label>
@@ -824,7 +824,10 @@
<span data-l10n="ADF.USIM access (full)">ADF.USIM access (full)</span> <span data-l10n="ADF.USIM access (full)">ADF.USIM access (full)</span>
</label> </label>
<label class="block mb-1 mt-1 text-xs text-gray-500 dark:text-slate-400" data-l10n="ADF AID (hex)">ADF AID (hex)</label> <label class="block mb-1 mt-1 text-xs text-gray-500 dark:text-slate-400" data-l10n="ADF AID (hex)">ADF AID (hex)</label>
<input id="rc-tk-adfaid" oninput="updateStkParamsHex()" value="A0000000871002" maxlength="32" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800"> <div class="flex items-center gap-2">
<input id="rc-tk-adfaid" oninput="updateStkParamsHex()" value="A0000000871002" maxlength="32" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">
<button type="button" id="rc-tk-adfaid-from-card" data-needs="card" onclick="ramAdfAidFromCard()" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="From card">From card</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1682,7 +1685,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.6.15'; const SIMPLE_VERSION = '3.6.16';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -2552,17 +2555,20 @@ function ramResetGrants() {
// them with an ADF entry (AID from `v.adfAid`, default ADF.USIM); the SIM path // them with an ADF entry (AID from `v.adfAid`, default ADF.USIM); the SIM path
// grants access via the CA Access Domain field instead. // grants access via the CA Access Domain field instead.
function stkParamsBuild(v) { function stkParamsBuild(v) {
const priority = parseInt(v.priority, 10) || 0; // Priority '00' is RFU (TS 102 226 8.2.1.3.2.6): an unset field defaults
// to 255 (0xFF, lowest priority) instead of the invalid zero.
const priority = (v.priority === '' || v.priority === undefined)
? 0xFF : (parseInt(v.priority, 10) || 0);
const timers = parseInt(v.timers, 10) || 0; const timers = parseInt(v.timers, 10) || 0;
const textLen = parseInt(v.textLen, 10) || 0; const textLen = parseInt(v.textLen, 10) || 0;
const menus = parseInt(v.menus, 10) || 0; const menus = parseInt(v.menus, 10) || 0;
const firstPos = parseInt(v.firstPos, 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 firstId = (v.firstId || '00').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padStart(2, '0').slice(0, 2) || '00';
const lastPos = parseInt(v.lastPos, 10) || 0; 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 lastId = (v.lastId || '00').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padStart(2, '0').slice(0, 2) || '00';
const channels = parseInt(v.channels, 10) || 0; const channels = parseInt(v.channels, 10) || 0;
const msl = v.msl || '00'; const msl = (v.msl || '00').toUpperCase();
const tar = (v.tar || '').replace(/[^0-9a-fA-F]/g, ''); const tar = (v.tar || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
const adRaw = (v.ad || '').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 ad = adRaw.padStart(2, '0').slice(0, 2);
const services = parseInt(v.services, 10) || 0; const services = parseInt(v.services, 10) || 0;
@@ -2570,33 +2576,33 @@ function stkParamsBuild(v) {
if (parseInt(firstId, 16) > 0x7F || parseInt(lastId, 16) > 0x7F) return null; if (parseInt(firstId, 16) > 0x7F || parseInt(lastId, 16) > 0x7F) return null;
let menuPairs = ''; let menuPairs = '';
for (let i = 1; i <= menus; i++) { for (let i = 1; i <= menus; i++) {
if (i === 1) menuPairs += firstPos.toString(16).padStart(2, '0') + firstId; if (i === 1) menuPairs += firstPos.toString(16).padStart(2, '0').toUpperCase() + firstId;
else if (i === menus) menuPairs += lastPos.toString(16).padStart(2, '0') + lastId; else if (i === menus) menuPairs += lastPos.toString(16).padStart(2, '0').toUpperCase() + lastId;
else menuPairs += '0000'; else menuPairs += '0000';
} }
const mslField = msl === '00' ? '00' : '0201' + msl; const mslField = msl === '00' ? '00' : '0201' + msl;
let tkPayload = ''; let tkPayload = '';
if (v.mode === 'ca') { if (v.mode === 'ca') {
tkPayload = (adRaw ? '01' + ad : '00') + tkPayload = (adRaw ? '01' + ad : '00') +
priority.toString(16).padStart(2, '0') + priority.toString(16).padStart(2, '0').toUpperCase() +
timers.toString(16).padStart(2, '0') + timers.toString(16).padStart(2, '0').toUpperCase() +
textLen.toString(16).padStart(2, '0') + textLen.toString(16).padStart(2, '0').toUpperCase() +
menus.toString(16).padStart(2, '0') + menus.toString(16).padStart(2, '0').toUpperCase() +
menuPairs + menuPairs +
channels.toString(16).padStart(2, '0') + channels.toString(16).padStart(2, '0').toUpperCase() +
mslField + mslField +
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00'); (tar ? (tar.length / 2).toString(16).padStart(2, '0').toUpperCase() + tar : '00');
const caTlv = 'CA' + berLenStr(tkPayload.length / 2) + tkPayload; const caTlv = 'CA' + berLenStr(tkPayload.length / 2) + tkPayload;
return 'EF' + berLenStr(caTlv.length / 2) + caTlv; return 'EF' + berLenStr(caTlv.length / 2) + caTlv;
} }
tkPayload = priority.toString(16).padStart(2, '0') + tkPayload = priority.toString(16).padStart(2, '0').toUpperCase() +
timers.toString(16).padStart(2, '0') + timers.toString(16).padStart(2, '0').toUpperCase() +
textLen.toString(16).padStart(2, '0') + textLen.toString(16).padStart(2, '0').toUpperCase() +
menus.toString(16).padStart(2, '0') + menus.toString(16).padStart(2, '0').toUpperCase() +
menuPairs + menuPairs +
channels.toString(16).padStart(2, '0') + channels.toString(16).padStart(2, '0').toUpperCase() +
mslField + mslField +
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00') + (tar ? (tar.length / 2).toString(16).padStart(2, '0').toUpperCase() + tar : '00') +
services.toString(16).padStart(2, '0'); services.toString(16).padStart(2, '0');
let eaValue = '80' + berLenStr(tkPayload.length / 2) + tkPayload; let eaValue = '80' + berLenStr(tkPayload.length / 2) + tkPayload;
if (v.fsAccess) { if (v.fsAccess) {
@@ -2647,6 +2653,54 @@ function updateStkParamsHex() {
if (el.dataset) delete el.dataset.manual; if (el.dataset) delete el.dataset.manual;
} }
// The ADF.USIM AID is card-specific: the FCI's DF name (tag 84) is the full
// 5..16-byte AID the access parameters need - the base A0000000871002 is only
// a prefix, and a shorter AID makes the card reject the entry with 6A80
// (live 2026-09-28: the card's DF name is A0000000871002FF49FF0589).
function adfAidFromFci(fciHex) {
const s = (fciHex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
let i = 0;
if (s.length >= 4 && (s.startsWith('62') || s.startsWith('6F'))) {
const n = parseInt(s.substr(2, 2), 16);
i = 4 + (n > 0x80 ? (n & 0x7F) * 2 : 0); // skip the FCP/FCI template head
}
while (i + 4 <= s.length) {
const tag = s.substr(i, 2);
let n = parseInt(s.substr(i + 2, 2), 16);
let j = i + 4;
if (n > 0x80) {
const nb = n & 0x7F;
n = parseInt(s.substr(i + 4, nb * 2), 16);
j = i + 4 + nb * 2;
}
if (tag === '84') return s.substr(j, n * 2).toUpperCase();
i = j + n * 2;
}
return '';
}
// Fill the ADF AID from the card's ADF.USIM FCI (one click; the AID cannot be
// known in advance - it carries a card-specific PIX).
async function ramAdfAidFromCard() {
const el = document.getElementById('rc-tk-adfaid');
const btn = document.getElementById('rc-tk-adfaid-from-card');
if (!el || !btn) return;
const label = btn.textContent;
btn.disabled = true;
try {
const data = await pysimFetch('/api/select', { name: 'ADF.USIM' });
const aid = adfAidFromFci(data && data.fci_hex);
if (!aid) throw new Error('no DF name');
el.value = aid;
updateStkParamsHex();
btn.textContent = t('OK');
} catch (e) {
btn.textContent = t('Failed');
} finally {
setTimeout(() => { btn.textContent = label; btn.disabled = false; }, 1500);
}
}
function updateInstallParamsHex() { function updateInstallParamsHex() {
document.getElementById('ram-install-params-hex').value = 'C900'; document.getElementById('ram-install-params-hex').value = 'C900';
} }
@@ -3446,7 +3500,7 @@ function chainRamToolkitHtml(chainId, idx, f, uf) {
'SIM/UICC Toolkit parameters</label></div>'; 'SIM/UICC Toolkit parameters</label></div>';
if (!enabled) return html; if (!enabled) return html;
var tkMode = f.tkMode || 'ea'; var tkMode = f.tkMode || 'ea';
var priority = f.tkPriority || '0'; var priority = f.tkPriority || '255';
var timers = f.tkTimers || '0'; var timers = f.tkTimers || '0';
var textLen = f.tkTextlen || '0'; var textLen = f.tkTextlen || '0';
var menus = f.tkMenus || '0'; var menus = f.tkMenus || '0';
@@ -3465,7 +3519,7 @@ function chainRamToolkitHtml(chainId, idx, f, uf) {
'<option value="ca"' + (tkMode === 'ca' ? ' selected' : '') + '>SIM Toolkit (Tag CA)</option>' + '<option value="ca"' + (tkMode === 'ca' ? ' selected' : '') + '>SIM Toolkit (Tag CA)</option>' +
'<option value="ea"' + (tkMode === 'ea' ? ' selected' : '') + '>UICC Toolkit (Tag EA)</option></select></div>' + '<option value="ea"' + (tkMode === 'ea' ? ' selected' : '') + '>UICC Toolkit (Tag EA)</option></select></div>' +
'<div class="grid grid-cols-4 gap-x-2 gap-y-1.5 text-xs">' + '<div class="grid grid-cols-4 gap-x-2 gap-y-1.5 text-xs">' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Priority</label>' + '<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Priority (0 = RFU)</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(priority) + '" oninput="' + uf('tkPriority') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' + '<input type="number" min="0" max="255" value="' + escHtml(priority) + '" oninput="' + uf('tkPriority') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Timers</label>' + '<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Timers</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(timers) + '" oninput="' + uf('tkTimers') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' + '<input type="number" min="0" max="255" value="' + escHtml(timers) + '" oninput="' + uf('tkTimers') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
@@ -17237,6 +17291,7 @@ const LANG_RU = {
'pending': 'ожидает', 'pending': 'ожидает',
'Name is required': 'Укажите имя', 'Name is required': 'Укажите имя',
'From card': 'С карты', 'From card': 'С карты',
'Priority (0 = RFU)': 'Приоритет (0 = RFU)',
'SCP80 (GSM 03.48, ETSI TS 102 225)': 'SCP80 (GSM 03.48, ETSI TS 102 225)', 'SCP80 (GSM 03.48, ETSI TS 102 225)': 'SCP80 (GSM 03.48, ETSI TS 102 225)',
'SCP81 (HTTP OTA)': 'SCP81 (HTTP OTA)', 'SCP81 (HTTP OTA)': 'SCP81 (HTTP OTA)',
'Start': 'Запустить', 'Start': 'Запустить',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v288'; const CACHE = 'simple-v289';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+25 -4
View File
@@ -23,7 +23,7 @@ function extractFunc(src, name) {
let code = ''; let code = '';
for (const fn of ['berLenStr', 'stkParamsBuild', 'buildRcToolkitParams', for (const fn of ['berLenStr', 'stkParamsBuild', 'buildRcToolkitParams',
'updateStkParamsHex']) code += extractFunc(html, fn) + '\n'; 'updateStkParamsHex', 'adfAidFromFci']) code += extractFunc(html, fn) + '\n';
eval(code); eval(code);
const base = { mode: 'ea', priority: '0', timers: '0', textLen: '0', menus: '0', const base = { mode: 'ea', priority: '0', timers: '0', textLen: '0', menus: '0',
@@ -114,6 +114,27 @@ test('the applet TAR field has no B00001 default or placeholder', () => {
assert.ok(!html.includes("f.tkTar || 'B00001'"), 'the chain toolkit default must stay empty'); assert.ok(!html.includes("f.tkTar || 'B00001'"), 'the chain toolkit default must stay empty');
}); });
test('adfAidFromFci extracts the DF name (tag 84) from an FCI', () => {
// the live ADF.USIM FCI (read from the card, 2026-09-28): the DF name is
// 12 bytes - the base A0000000871002 is only a prefix, and a shorter AID
// makes the card reject the access entry with 6A80.
assert.strictEqual(
adfAidFromFci('622882027821840CA0000000871002FF49FF05898A01058B032F0617C60C90016083010183010A830181'),
'A0000000871002FF49FF0589');
// an FCI without a DF name, and garbage
assert.strictEqual(adfAidFromFci('6207820278218A0105'), '');
assert.strictEqual(adfAidFromFci(''), '');
assert.strictEqual(adfAidFromFci('ZZZZ'), '');
});
test('the ADF AID From-card button is wired', () => {
assert.ok(/id="rc-tk-adfaid-from-card"[^>]*onclick="ramAdfAidFromCard\(\)"/.test(html),
'the ADF AID From-card button is missing');
assert.ok(/async function ramAdfAidFromCard\(\)/.test(html), 'ramAdfAidFromCard is missing');
assert.ok(/pysimFetch\('\/api\/select', \{ name: 'ADF.USIM' \}\)/.test(html),
'the button must read the ADF.USIM FCI');
});
test('the RAM install recomputes the STK hex at send time unless hand-edited', () => { test('the RAM install recomputes the STK hex at send time unless hand-edited', () => {
assert.ok(/oninput="this\.dataset\.manual='1'"/.test(html), assert.ok(/oninput="this\.dataset\.manual='1'"/.test(html),
'the hex field must flag manual edits'); 'the hex field must flag manual edits');
@@ -146,7 +167,7 @@ test('the RAM form fields build the live install parameters end to end', () => {
// carried TAR B00001 / MSL 16 / channels 0 and the card answered 6A80. // 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', fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12',
'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1' }); 'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1' });
assert.strictEqual(buildRcToolkitParams(), 'EA0F800D000000000102011203AF4D0100'); assert.strictEqual(buildRcToolkitParams(), 'EA0F800DFF0000000102011203AF4D0100');
}); });
test('UICC file-access parameters (82) are appended in EA mode', () => { test('UICC file-access parameters (82) are appended in EA mode', () => {
@@ -182,12 +203,12 @@ test('the RAM form emits full file access when the checkbox is ticked', () => {
fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12', fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12',
'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1', 'rc-tk-fsaccess': true }); 'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1', 'rc-tk-fsaccess': true });
assert.strictEqual(buildRcToolkitParams(), assert.strictEqual(buildRcToolkitParams(),
'EA15800D000000000102011203AF4D0100810400010000'); 'EA15800DFF0000000102011203AF4D0100810400010000');
fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12', fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12',
'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1', 'rc-tk-fsaccess': true, 'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1', 'rc-tk-fsaccess': true,
'rc-tk-adfaccess': true }); 'rc-tk-adfaccess': true });
assert.strictEqual(buildRcToolkitParams(), assert.strictEqual(buildRcToolkitParams(),
'EA20800D000000000102011203AF4D0100810F0001000007A0000000871002010000'); 'EA20800DFF0000000102011203AF4D0100810F0001000007A0000000871002010000');
}); });
test('updateStkParamsHex refreshes the field and clears the manual flag', () => { test('updateStkParamsHex refreshes the field and clears the manual flag', () => {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.6.15" version = "3.6.16"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+1 -1
View File
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.6.15' VERSION = '3.6.16'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE