diff --git a/frontend/index.html b/frontend/index.html index 4a49416..437e208 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -754,8 +754,8 @@
- - + +
@@ -824,7 +824,10 @@ ADF.USIM access (full) - +
+ + +
@@ -1682,7 +1685,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.15'; +const SIMPLE_VERSION = '3.6.16'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -2552,17 +2555,20 @@ function ramResetGrants() { // them with an ADF entry (AID from `v.adfAid`, default ADF.USIM); the SIM path // grants access via the CA Access Domain field instead. 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 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 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 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 msl = v.msl || '00'; - const tar = (v.tar || '').replace(/[^0-9a-fA-F]/g, ''); + const msl = (v.msl || '00').toUpperCase(); + const tar = (v.tar || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); const adRaw = (v.ad || '').replace(/[^0-9a-fA-F]/g, ''); const ad = adRaw.padStart(2, '0').slice(0, 2); 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; 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; + if (i === 1) menuPairs += firstPos.toString(16).padStart(2, '0').toUpperCase() + firstId; + else if (i === menus) menuPairs += lastPos.toString(16).padStart(2, '0').toUpperCase() + lastId; else menuPairs += '0000'; } const mslField = msl === '00' ? '00' : '0201' + msl; let tkPayload = ''; if (v.mode === 'ca') { tkPayload = (adRaw ? '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') + + priority.toString(16).padStart(2, '0').toUpperCase() + + timers.toString(16).padStart(2, '0').toUpperCase() + + textLen.toString(16).padStart(2, '0').toUpperCase() + + menus.toString(16).padStart(2, '0').toUpperCase() + menuPairs + - channels.toString(16).padStart(2, '0') + + channels.toString(16).padStart(2, '0').toUpperCase() + 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; return 'EF' + berLenStr(caTlv.length / 2) + caTlv; } - 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') + + tkPayload = priority.toString(16).padStart(2, '0').toUpperCase() + + timers.toString(16).padStart(2, '0').toUpperCase() + + textLen.toString(16).padStart(2, '0').toUpperCase() + + menus.toString(16).padStart(2, '0').toUpperCase() + menuPairs + - channels.toString(16).padStart(2, '0') + + channels.toString(16).padStart(2, '0').toUpperCase() + 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'); let eaValue = '80' + berLenStr(tkPayload.length / 2) + tkPayload; if (v.fsAccess) { @@ -2647,6 +2653,54 @@ function updateStkParamsHex() { 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() { document.getElementById('ram-install-params-hex').value = 'C900'; } @@ -3446,7 +3500,7 @@ function chainRamToolkitHtml(chainId, idx, f, uf) { 'SIM/UICC Toolkit parameters'; if (!enabled) return html; var tkMode = f.tkMode || 'ea'; - var priority = f.tkPriority || '0'; + var priority = f.tkPriority || '255'; var timers = f.tkTimers || '0'; var textLen = f.tkTextlen || '0'; var menus = f.tkMenus || '0'; @@ -3465,7 +3519,7 @@ function chainRamToolkitHtml(chainId, idx, f, uf) { '' + '' + '
' + - '
' + + '
' + '
' + '
' + '
' + @@ -17237,6 +17291,7 @@ const LANG_RU = { 'pending': 'ожидает', 'Name is required': 'Укажите имя', 'From card': 'С карты', + 'Priority (0 = RFU)': 'Приоритет (0 = RFU)', 'SCP80 (GSM 03.48, ETSI TS 102 225)': 'SCP80 (GSM 03.48, ETSI TS 102 225)', 'SCP81 (HTTP OTA)': 'SCP81 (HTTP OTA)', 'Start': 'Запустить', diff --git a/frontend/sw.js b/frontend/sw.js index f21c44f..767b7f4 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v288'; +const CACHE = 'simple-v289'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/stk_params.test.js b/frontend/tests/stk_params.test.js index b67034c..746aece 100644 --- a/frontend/tests/stk_params.test.js +++ b/frontend/tests/stk_params.test.js @@ -23,7 +23,7 @@ function extractFunc(src, name) { let code = ''; for (const fn of ['berLenStr', 'stkParamsBuild', 'buildRcToolkitParams', - 'updateStkParamsHex']) code += extractFunc(html, fn) + '\n'; + 'updateStkParamsHex', 'adfAidFromFci']) code += extractFunc(html, fn) + '\n'; eval(code); 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'); }); +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', () => { assert.ok(/oninput="this\.dataset\.manual='1'"/.test(html), '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. 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'); + assert.strictEqual(buildRcToolkitParams(), 'EA0F800DFF0000000102011203AF4D0100'); }); 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', 'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1', 'rc-tk-fsaccess': true }); assert.strictEqual(buildRcToolkitParams(), - 'EA15800D000000000102011203AF4D0100810400010000'); + 'EA15800DFF0000000102011203AF4D0100810400010000'); 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-adfaccess': true }); assert.strictEqual(buildRcToolkitParams(), - 'EA20800D000000000102011203AF4D0100810F0001000007A0000000871002010000'); + 'EA20800DFF0000000102011203AF4D0100810F0001000007A0000000871002010000'); }); test('updateStkParamsHex refreshes the field and clears the manual flag', () => { diff --git a/pyproject.toml b/pyproject.toml index a7a943a..7df7785 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.15" +version = "3.6.16" 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 e04f467..405239a 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.15' +VERSION = '3.6.16' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE