diff --git a/frontend/index.html b/frontend/index.html index c3eea12..3d5a379 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1749,7 +1749,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.55'; +const SIMPLE_VERSION = '3.6.56'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -9238,6 +9238,24 @@ function ramElfVersionHint(elfForm) { return t('Package versions come from the expanded registry query \u2014 this card did not return them.'); } +// Fill a missing ELF version from an application entry: cards commonly return +// the load file version (CE) on the app entry together with its package AID +// (C4), so the package's version can be read from its applet instance (live +// 2026-09-30: the ELF listing refused every tag list while the apps listing +// carried CE). The row is annotated (`versionFrom`) so the inference stays +// visible. Packages without an applet instance get no version this way. +function ramDeriveElfVersions(elfs, apps) { + (elfs || []).forEach(e => { + if (e.version || !e.aid) return; + const app = (apps || []).find(a => a.version && a.elfAid && + String(a.elfAid).toUpperCase() === String(e.aid).toUpperCase()); + if (app) { + e.version = app.version; + e.versionFrom = 'app'; + } + }); +} + // Render the full explorer result as structured HTML function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) { let html = ''; @@ -9301,7 +9319,7 @@ function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) { if (elfs && elfs.length) { html += '
'; html += '
' + esc(t('Executable Load Files (ELFs) / Packages')) + ' (' + elfs.length + ')
'; - if (!elfs.some(o => o.version)) { + if (elfs.some(o => !o.version)) { html += '
' + esc(ramElfVersionHint(elfForm)) + '
'; } elfs.forEach(o => { @@ -9309,7 +9327,11 @@ function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) { html += '
'; html += '
' + esc(t('Load File AID / Package AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '
'; html += '
' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', 'elf') + '
'; - if (o.version) html += '
' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) + '
'; + if (o.version) { + html += '
' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) + + (o.versionFrom === 'app' ? ' (' + esc(t('from the app instance')) + ')' : '') + + '
'; + } if (o.privileges && o.privileges !== '00') html += '
' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')
'; if (o.moduleAids && o.moduleAids.length) { html += '
' + esc(t('Executable Module AIDs / Applet Class AIDs:')) + '
'; @@ -9407,15 +9429,24 @@ async function ramExplore(sp) { }; let page = 0; while (accepted && page++ < 8) { - data += await sendOne(ramExpandedQueryApdu(p1, form, page > 1), - page > 1 ? 'P2=03 (' + form + ')' : 'P2=02 (' + form + ')'); + const apdu = ramExpandedQueryApdu(p1, form, page > 1); + const what = page > 1 ? 'P2=03 (' + form + ')' : 'P2=02 (' + form + ')'; + data += await sendOne(apdu, what); if (!accepted) break; - let rounds = 0; - while (/^61[0-9A-F]{2}$/.test(sw) && rounds++ < 4) { - data += await sendOne(ramGetResponseApdu(sw), 'GET RESPONSE'); + // '61xx': the card holds the response data - the GET RESPONSE + // must ride in the same compact command string (a standalone + // one answers 6700 on the live card); the expanded AA/22 + // format has no command string, so there is no retry there. + const pending = /^61[0-9A-F]{2}$/.test(sw); + if (pending && sp.ram_format !== 'expanded') { + data += await sendOne(ramChainGetResponse(apdu), what + ' + GET RESPONSE'); if (!accepted) break; } - if (!accepted || sw !== '6310') break; + // the ISD is a single occurrence (GP 11.4.2.2); '6310' means + // more occurrences, and a '61xx' page may mask that signal + // (the GET RESPONSE's own SW replaces it) - try one more page + if (p1 === '80') break; + if (sw !== '6310' && !pending) break; } const parsed = data ? parser(data) : []; if (parsed.length) { @@ -9541,8 +9572,7 @@ async function ramExplore(sp) { // P1 per GP Card Spec v2.3.1 table 11-33: // 80=ISD, 40=Applications, 20=Executable Load Files (ELFs), 10=ELF+modules await paginate('80', isd, ramParseAppStatus, t('ISD')); - await paginate('40', apps, ramParseAppStatus, t('Apps')); - // GP 11.4.2.1: '10' returns the ELFs *with* their modules - the superset of + await paginate('40', apps, ramParseAppStatus, t('Apps')); // GP 11.4.2.1: '10' returns the ELFs *with* their modules - the superset of // '20' (ELFs only). All four P1 values shall be supported, but cards // deviate: the live card answered 6A86 (incorrect P1-P2) to '20' while // '10' works (2026-09-28). Query '10' first; fall back to '20' (no @@ -9555,6 +9585,10 @@ async function ramExplore(sp) { await paginate('20', elfs, ramParseElfStatus, t('ELFs'), true); if (!elfs.length) errors.push(...elfFailure); } + // A package whose own listing carries no version may still be known from + // its applet instance (the app entry carries the load file version and the + // package AID); the rows are annotated when this inference is used. + ramDeriveElfVersions(elfs, apps); // Only write the counter when a packet was actually accepted: a run whose // start value was stale must never lower the preset counter. if (cntr !== sp.cntr) ramSaveCntr(cntr); @@ -11001,12 +11035,13 @@ function ramExpandedQueryApdu(p1, form, next) { return '80F2' + p1 + p2 + hex(tags.length / 2 + 4) + '4F005C' + hex(tags.length / 2) + tags + '00'; } -// GET RESPONSE for a '61xx' remote status word: the card holds xx bytes of -// response data (the compact path chains C0000000 for the same situation). -function ramGetResponseApdu(sw) { - const s = String(sw || '').toUpperCase(); - const len = /^61[0-9A-F]{2}$/.test(s) ? s.substr(2, 2) : '00'; - return '00C00000' + len; +// A '61xx' remote answer means the card holds the response data. The live +// card (2026-09-30) rejects a standalone GET RESPONSE with 6700 - the GET +// RESPONSE must ride in the same compact command string as the query, exactly +// like the compact path does (`80F2..C0000000`). Only the compact command +// string can chain commands; the expanded AA/22 format has no retry. +function ramChainGetResponse(apdu) { + return apdu + 'C0000000'; } // The ISD (80) and memory responses fit the ENVELOPE; the Apps (40) and ELF @@ -18730,6 +18765,7 @@ const LANG_RU = { 'compact listing only (package versions unavailable)': 'только компактный список (версии пакетов недоступны)', 'Package versions come from the expanded registry query \u2014 this card did not return them.': 'Версии пакетов приходят из расширенного запроса реестра \u2014 эта карта их не вернула.', 'Package versions are only in the tag-list forms of the expanded registry query \u2014 this card accepted only the no-tag-list form.': 'Версии пакетов есть только в формах расширенного запроса со списком тегов \u2014 эта карта приняла только форму без списка тегов.', + 'from the app instance': 'от экземпляра апплета', 'STK menu entry:': 'Пункт меню STK:', 'position': 'позиция', 'Other registry data:': 'Прочие данные реестра:', diff --git a/frontend/sw.js b/frontend/sw.js index f26f4f5..21e9ed9 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v328'; +const CACHE = 'simple-v329'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 801fd72..3c036c6 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', 'ramFmtVersion', 'ramMenuState', 'ramEntryKind', 'ramMenuHtml', 'ramRawTlvsHtml', 'ramNvLine', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramMergeExpanded', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramCompatProbeNote', 'ramCompatImportRows', 'ramCompatRowsHtml', 'ramCompatFailureHtml', 'ramCompatDetailHtml', 'lookupSw', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu', 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries', - 'ramExpandedReport', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramGetResponseApdu', + 'ramExpandedReport', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText', 'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml']; let code = ''; @@ -581,13 +581,45 @@ test('ramExpandedQueryApdu builds the expanded query forms', () => { assert.deepStrictEqual(RAM_EXPANDED_FORMS, ['tags', 'noea', 'lean', 'notags']); }); -test('ramGetResponseApdu fetches a 61xx response (the announced length)', () => { - assert.strictEqual(ramGetResponseApdu('6131'), '00C0000031'); - assert.strictEqual(ramGetResponseApdu('617F'), '00C000007F'); - assert.strictEqual(ramGetResponseApdu('613f'), '00C000003F'); - // not a 61xx: ask for all available data - assert.strictEqual(ramGetResponseApdu('9000'), '00C0000000'); - assert.strictEqual(ramGetResponseApdu(''), '00C0000000'); +test('ramChainGetResponse chains the GET RESPONSE into the query string', () => { + // the live card rejects a standalone GET RESPONSE with 6700; the compact + // command string (query + C0000000) is how the compact path fetches too + assert.strictEqual(ramChainGetResponse('80F280020C4F005C084F9F70C5C4CCCEEA00'), + '80F280020C4F005C084F9F70C5C4CCCEEA00C0000000'); + assert.strictEqual(ramChainGetResponse('80F21002094F005C054F9F70CE8400'), + '80F21002094F005C054F9F70CE8400C0000000'); +}); + +test('ramDeriveElfVersions reads a package version from its applet instance', () => { + // the app entry carries CE (the load file version) and C4 (the package + // AID), so an ELF whose own listing refused every tag list still gets one + const elfs = [ + { aid: 'A1130001180001FFFFFFFF89A1003900', lifecycle: '01', privileges: '00' }, + { aid: 'F0414C46416001', lifecycle: '01', privileges: '00', version: '0100' }, + { aid: 'DEADBEEF', lifecycle: '01', privileges: '00' }, + ]; + const apps = [ + { aid: 'A1130001180001FFFFFFFF89A1003908', version: '0903', elfAid: 'A1130001180001FFFFFFFF89A1003900' }, + { aid: 'A0000005591010FFFFFFFF8900001200', version: '0001', elfAid: 'A0000005591010FFFFFFFF8900000E00' }, + ]; + ramDeriveElfVersions(elfs, apps); + assert.strictEqual(elfs[0].version, '0903'); + assert.strictEqual(elfs[0].versionFrom, 'app'); + // an own version is never overwritten, and an unmatched package stays bare + assert.strictEqual(elfs[1].version, '0100'); + assert.strictEqual(elfs[1].versionFrom, undefined); + assert.strictEqual(elfs[2].version, undefined); +}); + +test('the explorer annotates a derived package version', () => { + globalThis.t = s => s; + const out = ramRenderExploreHtml(null, [], [], + [{ aid: 'E1', lifecycle: '01', privileges: '00', version: '0903', versionFrom: 'app' }]); + const plain = ramRenderExploreHtml(null, [], [], + [{ aid: 'E1', lifecycle: '01', privileges: '00', version: '0903' }]); + delete globalThis.t; + assert.ok(out.includes('Version: 9.3 (from the app instance)'), out); + assert.ok(!plain.includes('from the app instance'), plain); }); test('ramExpandedReport summarizes the query forms and the fallbacks', () => { @@ -671,8 +703,9 @@ test('the Explore detects the expanded query form and reports it', () => { // the ISD gets a single attempt: its 6A88 must not poison the detection assert.ok(src.includes("if (p1 === '80') forms = [expandedForm || 'tags'];"), 'the ISD must not walk the ladder'); - // a 61xx answer holds the data (GET RESPONSE) and a 6310 pages - assert.ok(src.includes('ramGetResponseApdu(sw)'), '61xx must be answered with GET RESPONSE'); + // a 61xx answer holds the data (chained GET RESPONSE) and a 6310 pages + assert.ok(src.includes('ramChainGetResponse(apdu)'), '61xx must be answered with a chained GET RESPONSE'); + assert.ok(!src.includes('00C00000'), 'no standalone GET RESPONSE (it answers 6700)'); assert.ok(src.includes('ramExpandedQueryApdu(p1, form, page > 1)'), '6310 must page'); }); diff --git a/pyproject.toml b/pyproject.toml index 1c4ce56..43a22e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.55" +version = "3.6.56" 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 43ed253..2a3b464 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.55' +VERSION = '3.6.56' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE