diff --git a/frontend/index.html b/frontend/index.html index e82f91f..6bf8591 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -820,6 +820,8 @@ ADF.USIM access (full) + + @@ -1677,7 +1679,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.11'; +const SIMPLE_VERSION = '3.6.12'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -2498,9 +2500,14 @@ function updateRcPriv() { document.querySelectorAll('.rc-priv-b1:checked').forEach(cb => b1 |= parseInt(cb.value, 16)); document.querySelectorAll('.rc-priv-b2:checked').forEach(cb => b2 |= parseInt(cb.value, 16)); document.querySelectorAll('.rc-priv-b3:checked').forEach(cb => b3 |= parseInt(cb.value, 16)); + // GP Card Spec Table 11-43: the privileges length is 1 or 3 bytes - a + // 2-byte form is invalid, so the third byte is padded when either of the + // upper bytes is set. let hex = b1.toString(16).padStart(2, '0').toUpperCase(); - hex += b2.toString(16).padStart(2, '0').toUpperCase(); - if (b3) hex += b3.toString(16).padStart(2, '0').toUpperCase(); + if (b2 || b3) { + hex += b2.toString(16).padStart(2, '0').toUpperCase(); + hex += b3.toString(16).padStart(2, '0').toUpperCase(); + } document.getElementById('rc-priv').value = hex; } @@ -2518,6 +2525,20 @@ function updateRcTkMode() { document.getElementById('rc-tk-adfaccess-row').style.display = isSim ? 'none' : ''; } +// The install form's checkbox grants (privileges, toolkit enable, file +// access) are per-session choices: clear anything the browser restored on +// reload and refresh the aggregates, so a stale grant can never silently +// apply (live 2026-09-28: an inherited Receipt Generation bit produced 6985). +function ramResetGrants() { + document.querySelectorAll('.rc-priv-b1, .rc-priv-b2, .rc-priv-b3') + .forEach(cb => { cb.checked = false; }); + document.getElementById('rc-toolkit-enable').checked = false; + document.getElementById('rc-tk-fsaccess').checked = false; + document.getElementById('rc-tk-adfaccess').checked = false; + updateRcToolkit(); // hides the toolkit block and refreshes the STK hex + updateRcPriv(); // privileges aggregate back to 00 +} + // 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 @@ -2525,8 +2546,8 @@ function updateRcTkMode() { // 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). In EA mode `v.fsAccess` // adds the UICC file-access parameters (tag '82') and `v.adfAccess` extends -// them with an ADF.USIM entry; the SIM path grants access via the CA Access -// Domain field instead. +// 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; const timers = parseInt(v.timers, 10) || 0; @@ -2577,11 +2598,19 @@ function stkParamsBuild(v) { let eaValue = '80' + berLenStr(tkPayload.length / 2) + tkPayload; if (v.fsAccess) { // UICC Access Application specific parameters (TS 102 226 - // 8.2.1.3.2.2.2): [file system AID length 00 = shared file system] - // [Access Domain length 01][ADP 00 = full access], optionally with an - // ADF entry [AID length 07][ADF.USIM][AD length 01][ADP 00]. - let acc = '000100'; - if (v.adfAccess) acc += '07A00000008710020100'; + // 8.2.1.3.2.2.2): every entry ends with the "Length of Access Domain + // DAP" byte (00 = no DAP): + // [file system AID length 00 = shared FS][AD length 01] + // [ADP 00 = full access][DAP length 00] + // [ADF AID length][ADF AID][AD length 01][ADP 00][DAP length 00] + // (without the DAP length byte the card rejected the ADF entry with + // 6A80; the ADF AID must be 5..16 bytes.) + let acc = '000100' + '00'; + if (v.adfAccess) { + const adf = (v.adfAid || 'A0000000871002').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + if (adf.length < 10 || adf.length > 32) return null; + acc += (adf.length / 2).toString(16).padStart(2, '0').toUpperCase() + adf + '0100' + '00'; + } eaValue += '82' + berLenStr(acc.length / 2) + acc; } return 'EA' + berLenStr(eaValue.length / 2) + eaValue; @@ -2601,6 +2630,7 @@ function buildRcToolkitParams() { tar: g('rc-tk-tar'), ad: g('rc-tk-ad'), services: g('rc-tk-services'), fsAccess: document.getElementById('rc-tk-fsaccess').checked, adfAccess: document.getElementById('rc-tk-adfaccess').checked, + adfAid: g('rc-tk-adfaid'), }); } @@ -3471,6 +3501,8 @@ function chainRamToolkitHtml(chainId, idx, f, uf) { html += '
'; + html += '
' + + '
'; } html += ''; return html; @@ -4074,10 +4106,11 @@ function chainRamBuildRowHex(idx, row) { var b1 = parseInt(ph.substring(0, 2), 16) || 0; var b2 = ph.length >= 4 ? parseInt(ph.substring(2, 4), 16) : 0; var b3 = ph.length >= 6 ? parseInt(ph.substring(4, 6), 16) : 0; + // Table 11-43: privileges are 1 or 3 bytes (never 2). if (b2 === 0 && b3 === 0) return b1.toString(16).padStart(2, '0').toUpperCase(); - var r = b1.toString(16).padStart(2, '0').toUpperCase() + b2.toString(16).padStart(2, '0').toUpperCase(); - if (b3) r += b3.toString(16).padStart(2, '0').toUpperCase(); - return r; + return b1.toString(16).padStart(2, '0').toUpperCase() + + b2.toString(16).padStart(2, '0').toUpperCase() + + b3.toString(16).padStart(2, '0').toUpperCase(); } // The chain row's toolkit fields -> the install parameters hex. function buildTkParams() { @@ -4089,7 +4122,7 @@ function chainRamBuildRowHex(idx, row) { lastPos: f.tkLastpos, lastId: f.tkLastid, channels: f.tkChannels, msl: f.tkMsl || '16', tar: f.tkTar, ad: f.tkAd, services: f.tkServices, - fsAccess: f.tkFsAccess, adfAccess: f.tkAdfAccess, + fsAccess: f.tkFsAccess, adfAccess: f.tkAdfAccess, adfAid: f.tkAdfAid, }); } // INSTALL/LOAD are case-3 commands (no trailing Le), matching the @@ -8959,6 +8992,10 @@ async function ramInstallCap(sp) { if (document.getElementById('rc-toolkit-enable').checked && !(stkEl.dataset && stkEl.dataset.manual)) { updateStkParamsHex(); } + // The privileges are an aggregate of the checkboxes: derive them now, so a + // value restored by the browser or left from an earlier experiment can + // never be sent silently. + updateRcPriv(); const body = { cap_hex: capHex, @@ -16428,6 +16465,7 @@ const LANG_RU = { 'Access domain (hex)': 'Домен доступа (hex)', 'File system access (full)': 'Доступ к файловой системе (полный)', 'ADF.USIM access (full)': 'Доступ к ADF.USIM (полный)', + 'ADF AID (hex)': 'AID ADF (hex)', 'Load data (hex)': 'Данные загрузки (hex)', 'Block number': 'Номер блока', 'Encryption': 'Шифрование', @@ -17397,6 +17435,7 @@ updateSpKid(); chainInit('chain-sim'); chainInit('chain-usim'); chainInit('chain-ram'); +ramResetGrants(); translatePage(); // PWA let deferredPrompt; diff --git a/frontend/sw.js b/frontend/sw.js index bd5f77b..2f6e73f 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v284'; +const CACHE = 'simple-v285'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram_grants.test.js b/frontend/tests/ram_grants.test.js new file mode 100644 index 0000000..258a7c9 --- /dev/null +++ b/frontend/tests/ram_grants.test.js @@ -0,0 +1,78 @@ +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 ['updateRcPriv']) code += extractFunc(html, fn) + '\n'; +eval(code); + +// The install form's grants are per-session choices: the privileges are an +// aggregate of the checkboxes, derived at send time (a browser-restored +// Receipt Generation bit produced 6985 on the live card, 2026-09-28). +function fakeEnv(checked) { + const els = { 'rc-priv': { value: '' } }; + const boxes = checked.map(([cls, val]) => ({ className: cls, value: val, checked: true })); + globalThis.document = { + getElementById: id => els[id] || null, + querySelectorAll: sel => boxes.filter(b => sel.indexOf('.' + b.className) >= 0), + }; + return els; +} + +test('updateRcPriv aggregates the checked privilege boxes', () => { + let els = fakeEnv([]); + updateRcPriv(); + assert.strictEqual(els['rc-priv'].value, '00'); + els = fakeEnv([['rc-priv-b3', '80']]); + updateRcPriv(); + assert.strictEqual(els['rc-priv'].value, '000080', 'Receipt Generation is the third byte'); + els = fakeEnv([['rc-priv-b1', '80'], ['rc-priv-b3', '80']]); + updateRcPriv(); + assert.strictEqual(els['rc-priv'].value, '800080'); + els = fakeEnv([['rc-priv-b1', '04'], ['rc-priv-b2', '80']]); + updateRcPriv(); + assert.strictEqual(els['rc-priv'].value, '048000'); + els = fakeEnv([['rc-priv-b1', '80'], ['rc-priv-b1', '40'], ['rc-priv-b2', '80']]); + updateRcPriv(); + assert.strictEqual(els['rc-priv'].value, 'C08000', 'bits within a byte are OR-ed'); +}); + +test('the install derives the privileges at send time', () => { + const fn = extractFunc(html, 'ramInstallCap'); + const iPriv = fn.indexOf('updateRcPriv();'); + const iBody = fn.indexOf('const body = {'); + assert.ok(iPriv >= 0, 'ramInstallCap must call updateRcPriv()'); + assert.ok(iBody >= 0, 'ramInstallCap body not found'); + assert.ok(iPriv < iBody, 'the privileges must be derived before the request body'); +}); + +test('the form resets the restored grants on load', () => { + assert.ok(/function ramResetGrants\(\)/.test(html), 'ramResetGrants is missing'); + assert.ok(/querySelectorAll\('\.rc-priv-b1, \.rc-priv-b2, \.rc-priv-b3'\)/.test(html), + 'the privilege checkboxes must be cleared'); + assert.ok(/chainInit\('chain-ram'\);\s*\n\s*ramResetGrants\(\);/.test(html), + 'ramResetGrants must run on load'); + assert.ok(/getElementById\('rc-toolkit-enable'\)\.checked = false;/.test(html), + 'the toolkit enable must be cleared'); + assert.ok(/getElementById\('rc-tk-fsaccess'\)\.checked = false;/.test(html), + 'the file-access grant must be cleared'); +}); diff --git a/frontend/tests/stk_params.test.js b/frontend/tests/stk_params.test.js index f9ac60d..61ef794 100644 --- a/frontend/tests/stk_params.test.js +++ b/frontend/tests/stk_params.test.js @@ -102,7 +102,7 @@ test('every toolkit field regenerates the STK parameters hex on edit', () => { assert.ok(/on(?:input|change)="[^"]*updateStkParamsHex/.test(m[0]), m[1] + ' does not refresh the hex: ' + m[0]); } - assert.strictEqual(seen.length, 16, 'expected 16 toolkit fields, got ' + seen.join(', ')); + assert.strictEqual(seen.length, 17, 'expected 17 toolkit fields, got ' + seen.join(', ')); }); test('the applet TAR field has no B00001 default or placeholder', () => { @@ -128,7 +128,7 @@ function fakeForm(values) { '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', 'rc-tk-fsaccess', - 'rc-tk-adfaccess', 'ram-stk-params']; + 'rc-tk-adfaccess', 'rc-tk-adfaid', 'ram-stk-params']; const checks = ['rc-toolkit-enable', 'rc-tk-fsaccess', 'rc-tk-adfaccess']; const els = {}; for (const id of ids) els[id] = { value: '', checked: false, dataset: {} }; @@ -150,31 +150,44 @@ test('the RAM form fields build the live install parameters end to end', () => { }); test('UICC file-access parameters (82) are appended in EA mode', () => { - // TS 102 226 8.2.1.3.2.2.2: [file system AID len 00 = shared FS] - // [Access Domain len 01][ADP 00 = full access]; the SIM path grants the - // same rights via the CA Access Domain field. The ADF entry is an - // extension of the file-system entry. + // TS 102 226 8.2.1.3.2.2.2: every entry ends with the "Length of Access + // Domain DAP" byte (00 = no DAP): + // [FS AID len 00 = shared FS][AD len 01][ADP 00 = full][DAP len 00] + // [ADF AID len][ADF AID][AD len 01][ADP 00][DAP len 00] + // The SIM path grants the same rights via the CA Access Domain field; the + // ADF entry is an extension of the file-system entry. A missing DAP + // length byte made the card reject the ADF entry with 6A80 (live + // 2026-09-28). const base = vals({ channels: '1', msl: '12', tar: 'AF4D01' }); assert.strictEqual(stkParamsBuild(Object.assign({}, base, { fsAccess: true })), - 'EA14800D000000000102011203AF4D01008203000100'); + 'EA15800D000000000102011203AF4D0100820400010000'); assert.strictEqual( stkParamsBuild(Object.assign({}, base, { fsAccess: true, adfAccess: true })), - 'EA1E800D000000000102011203AF4D0100820D00010007A00000008710020100'); + 'EA20800D000000000102011203AF4D0100820F0001000007A0000000871002010000'); + assert.strictEqual( + stkParamsBuild(Object.assign({}, base, { fsAccess: true, adfAccess: true, + adfAid: 'A0000000871002FF33FFFF89010101' })), + 'EA28800D000000000102011203AF4D01008217000100000FA0000000871002FF33FFFF89010101010000'); assert.strictEqual(stkParamsBuild(base), 'EA0F800D000000000102011203AF4D0100'); assert.strictEqual(stkParamsBuild(Object.assign({}, base, { adfAccess: true })), 'EA0F800D000000000102011203AF4D0100'); + // the ADF AID must be 5..16 bytes + assert.strictEqual(stkParamsBuild(Object.assign({}, base, + { fsAccess: true, adfAccess: true, adfAid: 'A00000' })), null); + assert.strictEqual(stkParamsBuild(Object.assign({}, base, + { fsAccess: true, adfAccess: true, adfAid: 'A0'.repeat(17) })), null); }); 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(), - 'EA14800D000000000102011203AF4D01008203000100'); + 'EA15800D000000000102011203AF4D0100820400010000'); 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(), - 'EA1E800D000000000102011203AF4D0100820D00010007A00000008710020100'); + 'EA20800D000000000102011203AF4D0100820F0001000007A0000000871002010000'); }); test('updateStkParamsHex refreshes the field and clears the manual flag', () => { diff --git a/pyproject.toml b/pyproject.toml index 1f9d0a3..817c7a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.11" +version = "3.6.12" 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 ff26824..356f66e 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.11' +VERSION = '3.6.12' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE