From 61bb1ab62124736ed9333a45995ab4ba274fb50a 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: Wed, 30 Sep 2026 10:08:24 +0300 Subject: [PATCH] feat: chained GET RESPONSE + ELF versions from app instances (v3.6.56) The live log (2026-09-30) showed our standalone GET RESPONSE was wrong: the card answered 6700 (wrong length) to 00C00000 for the 61xx answers of the expanded ISD query and the ELF notags query. The compact path proves how this card wants it: GET STATUS and GET RESPONSE chained in one compact command string (80F2..C0000000). - a 61xx answer now retries the same query with ramChainGetResponse (+ C0000000, compact format only; the expanded AA/22 form has no command string, so no retry there); - a 61xx page still continues to the next occurrence (the GET RESPONSE's own SW replaces the 6310 signal); the ISD stays a single occurrence; - the standalone 00C00000 attempt is gone (it only ever answered 6700). This card also refuses every tag list for the ELF queries (tags/noea/lean all 6A88), so CE cannot come from the ELF listing. Its apps listing does carry the load file version (CE) together with the package AID (C4), so ramDeriveElfVersions fills a missing ELF version from its applet instance and the row annotates it: 'Version: 9.3 (from the app instance)'. The version hint now shows whenever any ELF row lacks a version. sw cache simple-v329; tests: 550 Python / 711 Node. --- frontend/index.html | 70 ++++++++++++++++++++++++++--------- frontend/sw.js | 2 +- frontend/tests/ram.test.js | 53 +++++++++++++++++++++----- pyproject.toml | 2 +- pysim_simple_server/server.py | 2 +- 5 files changed, 99 insertions(+), 30 deletions(-) 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