From 90fe409decc330a351fb30f706c6201a75372413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 28 Sep 2026 23:29:01 +0300 Subject: [PATCH] fix: correct the GP privilege decode and remove the duplicate (v3.6.28) The RAM explorer's ramFmtPrivileges used a private table with wrong bits and invented names ("Cum Deletion Ctr", "GSM Card Binding", "Global PIN", "RFM"/"CFM") and no case for 0x80: a live ISD 9E printed four wrong names and dropped Security Domain. Per GP Card Spec v2.3.1 11.1.2 Tables 11-7/8/9, 9E = Security Domain, Card Lock, Card Terminate, Card Reset, CVM Management. - ramFmtPrivileges now delegates to decodePrivileges (one spec table). - decodePrivileges decodes byte 1's DAP pair per Table 11-7: b8+b7 = DAP Verification, b8+b7+b1 = Mandated DAP Verification; the flag bits alone are no privilege. - The file had two decodePrivileges copies (the runtime used the later one, the C-APDU parser test the earlier); the duplicate and its private table are gone - the single implementation plus PRIVILEGE_NAMES live in the parser section, which the test evaluates (converted to var like PARSE_INS). - Tests: 9E/9A live values, the DAP pair, byte 2/3 spot checks; the PRIVILEGE_NAMES spec pin moved to apdu_parse. 659 frontend / 504 Python green; version 3.6.28; sw simple-v301. --- frontend/index.html | 91 ++++++++++------------------- frontend/sw.js | 2 +- frontend/tests/apdu_parse.test.js | 20 ++++++- frontend/tests/ram.test.js | 21 ++++++- frontend/tests/response_map.test.js | 8 --- pyproject.toml | 2 +- pysim_simple_server/server.py | 2 +- 7 files changed, 74 insertions(+), 72 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 55e72e8..c58db3a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1713,7 +1713,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.27'; +const SIMPLE_VERSION = '3.6.28'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -5568,19 +5568,32 @@ function baseCompTag(tag) { return (b >= 0x80 && b <= 0x9F) ? (b & 0x7F).toString(16).padStart(2, '0').toUpperCase() : tag; } +// GP Card Spec v2.3.1 11.1.2 privilege coding (Tables 11-7/8/9). Table 11-8 +// prints "Token Management" while the rest of the spec (and the tooling) +// says "Token Verification" - the latter is used throughout. The table lives +// in the C-APDU parser section because its test evaluates that slice alone. +const PRIVILEGE_NAMES = [ + ['Security Domain', 'DAP Verification', 'Delegated Management', 'Card Lock', 'Card Terminate', 'Card Reset', 'CVM Management', 'Mandated DAP Verification'], + ['Trusted Path', 'Authorized Management', 'Token Verification', 'Global Delete', 'Global Lock', 'Global Registry', 'Final Application', 'Global Service'], + ['Receipt Generation', 'Ciphered Load File Data Block', 'Contactless Activation', 'Contactless Self-Activation'], +]; + +// Byte 1's b7 flags "DAP Verification" and b1 turns it into "Mandated DAP +// Verification" - the pair is decoded together so the flag bit cannot stand +// alone (v3.6.28). function decodePrivileges(hex) { - if (hex.length < 2) return ''; - const names = [ - ['Security Domain', 'DAP Verification', 'Delegated Management', 'Card Lock', 'Card Terminate', 'Card Reset', 'CVM Management', 'Mandated DAP Verification'], - ['Trusted Path', 'Authorized Management', 'Token Verification', 'Global Delete', 'Global Lock', 'Global Registry', 'Final Application', 'Global Service'], - ['Receipt Generation', 'Ciphered Load File Data Block', 'Contactless Activation', 'Contactless Self-Activation'], - ]; + const raw = (hex || '').replace(/[^0-9a-fA-F]/g, ''); + const bytes = []; + for (let i = 0; i + 1 < raw.length; i += 2) bytes.push(parseInt(raw.substr(i, 2), 16)); const parts = []; - for (let i = 0; i < Math.min(names.length, hex.length / 2); i++) { - const b = parseInt(hex.substr(i * 2, 2), 16); - for (let bit = 7; bit >= 0; bit--) { - const idx = 7 - bit; - if (b & (1 << bit) && names[i][idx]) parts.push(names[i][idx]); + for (let b = 0; b < Math.min(PRIVILEGE_NAMES.length, bytes.length); b++) { + for (let bit = 0; bit < PRIVILEGE_NAMES[b].length; bit++) { + if (b === 0 && (bit === 1 || bit === 7)) continue; + if (bytes[b] & (0x80 >> bit) && PRIVILEGE_NAMES[b][bit]) parts.push(PRIVILEGE_NAMES[b][bit]); + } + if (b === 0 && (bytes[0] & 0xC0) === 0xC0) { + parts.splice(1, 0, (bytes[0] & 0x01) + ? 'Mandated DAP Verification' : 'DAP Verification'); } } return parts.join(', ') || 'None'; @@ -6401,12 +6414,6 @@ const LIFECYCLE_MAP = { '8F': 'LOCKED (SD)', }; -const PRIVILEGE_NAMES = [ - ['Security Domain', 'DAP Verification', 'Delegated Management', 'Card Lock', 'Card Terminate', 'Card Reset', 'CVM Management', 'Mandated DAP Verification'], - ['Trusted Path', 'Authorized Management', 'Token Verification', 'Global Delete', 'Global Lock', 'Global Registry', 'Final Application', 'Global Service'], - ['Receipt Generation', 'Ciphered Load File Data Block', 'Contactless Activation', 'Contactless Self-Activation'], -]; - function getSwCategory(cmd) { if (!cmd) return 'generic'; return cmd.startsWith('ram-') ? 'gp' : 'uicc'; @@ -6559,21 +6566,6 @@ function decodeTlvValue(t) { return ''; } -function decodePrivileges(hex) { - const bytes = []; - for (let i = 0; i < hex.length; i += 2) bytes.push(parseInt(hex.substr(i,2),16)); - const names = []; - for (let b = 0; b < Math.min(bytes.length, PRIVILEGE_NAMES.length); b++) { - const byteNames = PRIVILEGE_NAMES[b] || []; - for (let bit = 0; bit < byteNames.length; bit++) { - if (bytes[b] & (0x80 >> bit)) { - if (byteNames[bit]) names.push(byteNames[bit]); - } - } - } - return names.length ? names.join(', ') : 'None'; -} - function decodeResponse() { const cmd = document.getElementById('resp-cmd').value; const swHex = (document.getElementById('resp-sw').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padStart(4, '0').slice(0, 4); @@ -8808,34 +8800,15 @@ function ramFmtLifecycle(hex) { return RAM_LIFECYCLE[hex] || ('0x' + hex); } -// Decode GP Card Spec privileges TLV (tag C5) into human-readable strings +// Decode the GP privileges value (tag C5 / compact listing) into +// human-readable strings. Delegates to the shared GP decoder so the RAM view +// and the C-APDU parser can never drift apart: the previous private table had +// wrong bits and invented names like "Cum Deletion Ctr" (v3.6.28). function ramFmtPrivileges(hex) { - const p = hex || ''; + const p = (hex || '').replace(/[^0-9a-fA-F]/g, ''); if (!p) return t('(none)'); - const bytes = p.match(/.{2}/g) || []; - const privs = []; - const b1 = parseInt(bytes[0] || '00', 16); - if (b1 & 0x01) privs.push('Card Lock'); - if (b1 & 0x02) privs.push('Card Terminate'); - if (b1 & 0x04) privs.push('Card Reset'); - if (b1 & 0x08) privs.push('Cum Deletion Ctr'); - if (b1 & 0x10) privs.push('GSM Card Binding'); - if (b1 & 0x20) privs.push('Default Selected'); - if (b1 & 0x40) privs.push('Global PIN'); - const b2 = parseInt(bytes[1] || '00', 16); - if (b2 & 0x01) privs.push('Mandated DAP'); - if (b2 & 0x02) privs.push('Security Domain'); - if (b2 & 0x04) privs.push('DAP Verification'); - if (b2 & 0x08) privs.push('Delegated Mgmt'); - if (b2 & 0x10) privs.push('RFM'); - if (b2 & 0x20) privs.push('CFM'); - const b3 = parseInt(bytes[2] || '00', 16); - if (b3 & 0x01) privs.push('Receipt Gen'); - if (b3 & 0x02) privs.push('Ciphered Load'); - if (b3 & 0x04) privs.push('Delegated Perso'); - if (b3 & 0x08) privs.push('Trusted Path'); - if (b3 & 0x10) privs.push('Authorized Mgmt'); - return privs.length ? privs.join(', ') : t('(none)'); + const decoded = decodePrivileges(p); + return decoded === 'None' ? t('(none)') : decoded; } // Render the full explorer result as structured HTML diff --git a/frontend/sw.js b/frontend/sw.js index bfee2fa..7bf971f 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v300'; +const CACHE = 'simple-v301'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/apdu_parse.test.js b/frontend/tests/apdu_parse.test.js index c4cc60b..b74212c 100644 --- a/frontend/tests/apdu_parse.test.js +++ b/frontend/tests/apdu_parse.test.js @@ -50,7 +50,9 @@ for (const c of consts) { } const start = html.indexOf('// ===== C-APDU Parser ====='); const end = html.indexOf('// ===== Response Parser ====='); -eval(prefix + html.slice(start, end).replace(/^const PARSE_INS = /m, 'var PARSE_INS = ')); +eval(prefix + html.slice(start, end) + .replace(/^const PARSE_INS = /m, 'var PARSE_INS = ') + .replace(/^const PRIVILEGE_NAMES = /m, 'var PRIVILEGE_NAMES = ')); test('Compact RAM INSTALL [for install] UICC', () => { const tree = parseHexTree('80E60C00214F08A000000151000000C70100EA128010000000020101020200011603B0000100'); @@ -227,10 +229,26 @@ test('decodeTextData explicit DCS 08 with malformed payload returns ?', () => { assert.strictEqual(decodeTextData('0841'), '?'); }); +test('PRIVILEGE_NAMES matches GPC v2.3 Tables 11-7/11-8/11-9', () => { + assert.strictEqual(PRIVILEGE_NAMES[0][7], 'Mandated DAP Verification'); + assert.strictEqual(PRIVILEGE_NAMES[1][2], 'Token Verification'); + assert.strictEqual(PRIVILEGE_NAMES[1][7], 'Global Service'); + assert.strictEqual(PRIVILEGE_NAMES[2][0], 'Receipt Generation'); + assert.strictEqual(PRIVILEGE_NAMES[2][3], 'Contactless Self-Activation'); +}); + test('decodePrivileges byte 2 b6 is Token Verification per GPC v2.3 Table 11-8', () => { assert.ok(decodePrivileges('0020').includes('Token Verification')); }); +test('decodePrivileges byte 1: DAP Verification vs Mandated DAP (GP Table 11-7)', () => { + assert.strictEqual(decodePrivileges('80'), 'Security Domain'); + assert.strictEqual(decodePrivileges('C0'), 'Security Domain, DAP Verification'); + assert.strictEqual(decodePrivileges('C1'), 'Security Domain, Mandated DAP Verification'); + assert.strictEqual(decodePrivileges('40'), 'None'); // b7 alone is no privilege + assert.strictEqual(decodePrivileges('01'), 'None'); // b1 alone is only the flag +}); + test('decodePrivileges', () => { assert.ok(decodePrivileges('00').includes('None')); assert.ok(decodePrivileges('80').includes('Security Domain')); diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 85a4ad0..94e48ef 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -25,7 +25,7 @@ function extractFunc(src, name) { const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramGetStatusApdu', 'ramDeleteApdu', 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry', - 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', + 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml']; let code = ''; for (const f of FNS) { @@ -35,6 +35,8 @@ const m = html.match(/const _chains = \{\};/); if (m) code += m[0].replace(/^const /, 'var ') + '\n'; const lc = html.match(/const RAM_LIFECYCLE = \{[\s\S]*?\n\};/); if (lc) code += lc[0].replace(/^const /, 'var ') + '\n'; +const pn = html.match(/const PRIVILEGE_NAMES = \[[\s\S]*?\n\];/); +if (pn) code += pn[0].replace(/^const /, 'var ') + '\n'; const an = html.match(/const JC_AID_NAMES = \{[\s\S]*?\n\};/); if (an) code += an[0].replace(/^const /, 'var ') + '\n'; const ar = html.match(/const JC_AID_RIDS = \{[\s\S]*?\n\};/); @@ -231,6 +233,23 @@ test('ramRenderExploreHtml annotates standard package AIDs, vendor AIDs stay bar assert.ok(out.includes('(GlobalPlatform RID)'), out); }); +test('ramFmtPrivileges decodes the GP privilege bytes (live ISD values)', () => { + global.t = s => s; + // GP Table 11-7: 0x9E = Security Domain + Card Lock + Card Terminate + + // Card Reset + CVM Management (the live ISD privilege byte) + assert.strictEqual(ramFmtPrivileges('9E'), + 'Security Domain, Card Lock, Card Terminate, Card Reset, CVM Management'); + assert.strictEqual(ramFmtPrivileges('9A'), + 'Security Domain, Card Lock, Card Terminate, CVM Management'); + // DAP Verification (b8+b7) vs Mandated DAP Verification (b8+b7+b1) + assert.strictEqual(ramFmtPrivileges('C0'), 'Security Domain, DAP Verification'); + assert.strictEqual(ramFmtPrivileges('C1'), 'Security Domain, Mandated DAP Verification'); + // byte 2 / byte 3 spot checks + assert.strictEqual(ramFmtPrivileges('0001'), 'Global Service'); + assert.strictEqual(ramFmtPrivileges('000080'), 'Receipt Generation'); + delete global.t; +}); + test('ramFmtPrivileges uses the translated (none) placeholder', () => { global.t = s => 'XX' + s; assert.strictEqual(ramFmtPrivileges(''), 'XX(none)'); diff --git a/frontend/tests/response_map.test.js b/frontend/tests/response_map.test.js index fbef7b5..cc5f16c 100644 --- a/frontend/tests/response_map.test.js +++ b/frontend/tests/response_map.test.js @@ -48,14 +48,6 @@ test('LIFECYCLE_MAP per GPC v2.3 life cycles', () => { assert.strictEqual(LIFECYCLE_MAP['83'], 'LOCKED (SD)'); }); -test('PRIVILEGE_NAMES matches GPC v2.3 Tables 11-7/11-8/11-9', () => { - assert.strictEqual(PRIVILEGE_NAMES[0][7], 'Mandated DAP Verification'); - assert.strictEqual(PRIVILEGE_NAMES[1][2], 'Token Verification'); - assert.strictEqual(PRIVILEGE_NAMES[1][7], 'Global Service'); - assert.strictEqual(PRIVILEGE_NAMES[2][0], 'Receipt Generation'); - assert.strictEqual(PRIVILEGE_NAMES[2][3], 'Contactless Self-Activation'); -}); - test('TS 51.011 SIM families resolve (94xx/98xx/92xx/9Exx/9Fxx)', () => { assert.ok(lookupSw('94', '04', 'uicc').includes('not found')); assert.ok(lookupSw('94', '00', 'uicc').includes('No EF selected')); diff --git a/pyproject.toml b/pyproject.toml index 54cacd3..89d1210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.27" +version = "3.6.28" 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 b051791..4d20840 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.27' +VERSION = '3.6.28' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE