From 032f70f509423814b407ee5bf38322ad4ab59813 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 11:17:12 +0300 Subject: [PATCH] feat: ELF versions via P1=20 retry and per-AID app queries (v3.7.0) No live card accepts a tag list for the ELF queries (tags/noea/lean all answer 6A88 on P1=10 - the log of 2026-09-30 11:11), so the version-carrying CE tag was unreachable there; the ELF listing itself (notags + chained GET RESPONSE) works and carries AID + life cycle + modules only. Two fallbacks: - when the ELF rows have no version and the details pass never got a tag-list form, the ladder is walked once more with P1=20 (a card's P1=10 implementation can be stricter) and merged by AID; - any version still missing is fetched per applet AID: the AID search qualifier (4F , GP 11.4.2.3 Table 11-35) plus the apps tag list selects a single applet entry, whose CE is its package's version (the applet <-> package mapping is the AID prefix; at most 6 queries). Also: - the app and ISD rows show their parsed CE version (the expanded apps listing carries it); - an unfulfilled 6310 (the next-occurrence page refused, e.g. 6A86) is reported as '(first page only)' instead of looking complete; - the page loop only continues when the chained GET RESPONSE was accepted - a refused retry no longer costs an extra P2=03 packet. sw cache simple-v332; tests: 557 Python / 716 Node. --- frontend/index.html | 91 ++++++++++++++++++++++++++++++----- frontend/sw.js | 2 +- frontend/tests/ram.test.js | 30 ++++++++++++ pyproject.toml | 2 +- pysim_simple_server/server.py | 2 +- 5 files changed, 113 insertions(+), 14 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index f9283b4..cd7049d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1750,7 +1750,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.58'; +const SIMPLE_VERSION = '3.7.0'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -9184,6 +9184,7 @@ function ramExpandedGroups(report) { } g.compact = g.compact || !!r.compact; g.transport = g.transport || !!r.transport; + g.more = g.more || !!r.more; if (r.form) { const score = ((r.entries || 0) > 0 ? 100 : 0) + (RAM_EXPANDED_FORMS.length - RAM_EXPANDED_FORMS.indexOf(r.form)); @@ -9211,7 +9212,8 @@ function ramExpandedReport(report) { : (f === 'noea' ? t('tag list') : (f === 'lean' ? t('lean tag list') : t('no tag list'))); const parts = []; RAM_EXPANDED_FORMS.forEach(f => { - const labels = groups.filter(g => g.form === f).map(g => g.label); + const labels = groups.filter(g => g.form === f) + .map(g => g.label + (g.more ? ' (' + t('first page only') + ')' : '')); if (labels.length) parts.push(formName(f) + ' (' + labels.join(', ') + ')'); }); groups.forEach(g => { @@ -9298,6 +9300,7 @@ function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) { html += '
'; html += '
' + esc(t('AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '
'; html += '
' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', 'isd') + '
'; + if (o.version) html += '
' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) + '
'; if (o.privileges) html += '
' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')
'; if (o.elfAid) html += '
' + esc(t('Load File AID / Package AID:')) + ' ' + jcAidHtml(o.elfAid) + '
'; if (o.sdAid) html += '
' + esc(t('SD AID:')) + ' ' + jcAidHtml(o.sdAid) + '
'; @@ -9315,6 +9318,7 @@ function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) { html += '
'; html += '
' + esc(t('Application / Instance AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '
'; html += '
' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', ramEntryKind(o)) + '
'; + if (o.version) html += '
' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) + '
'; if (o.privileges) html += '
' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')
'; if (o.implicitSel) html += '
' + esc(t('Implicit sel:')) + ' ' + o.implicitSel + '
'; if (o.elfAid) html += '
' + esc(t('Load File AID / Package AID:')) + ' ' + jcAidHtml(o.elfAid) + '
'; @@ -9463,6 +9467,7 @@ async function ramExplore(sp) { return res.por.decoded ? (res.por.decoded.last_response_data || '') : ''; }; let page = 0; + let more = false; while (accepted && page++ < 8) { const apdu = ramExpandedQueryApdu(p1, form, page > 1); const what = page > 1 ? 'P2=03 (' + form + ')' : 'P2=02 (' + form + ')'; @@ -9481,13 +9486,18 @@ async function ramExplore(sp) { // 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; + // continue only when the answer really says 'more occurrences' + // or the chained retry was accepted (a '61xx' page masks the + // 6310 signal with the GET RESPONSE's own SW); a refused retry + // must not cost another packet (v3.7.0) + if (sw !== '6310' && !(pending && ramRemoteSwOk(sw))) break; + more = true; } const parsed = data ? parser(data) : []; if (parsed.length) { expandedForm = form; return { form: form, parsed: parsed, sw: sw, entries: parsed.length, - transport: transportError }; + transport: transportError, more: more }; } lastSw = sw; lastTransport = transportError; @@ -9495,10 +9505,11 @@ async function ramExplore(sp) { if (ramRemoteSwOk(sw)) { // accepted but empty: nothing to gain from another form return { form: form, parsed: [], sw: sw, entries: 0, - transport: transportError }; + transport: transportError, more: more }; } } - return { form: null, parsed: [], sw: lastSw, entries: 0, transport: lastTransport }; + return { form: null, parsed: [], sw: lastSw, entries: 0, + transport: lastTransport, more: false }; } async function paginate(p1, collector, parser, label, quiet) { @@ -9574,7 +9585,7 @@ async function ramExplore(sp) { const out = await expandedPass(p1, label, parser, spi2); expandedReport.push({ p1: p1, label: label, compact: collector.length > before, form: out.form, sw: out.sw, entries: out.entries, - transport: out.transport }); + transport: out.transport, more: out.more }); if (collector.length === before) { // Nothing from the compact pass: the expanded result is the listing collector.push(...out.parsed); @@ -9627,10 +9638,61 @@ async function ramExplore(sp) { await paginate('20', elfs, ramParseElfStatus, t('ELFs'), true); if (!elfs.length) errors.push(...elfFailure); } + // The ELF tag-list forms may be refused for P1=10 while P1=20 accepts + // them (a card's P1=10 implementation can be stricter): when the ELF rows + // have no version and the details pass never got a tag-list form, walk the + // ladder once more with P1=20 and merge by AID (v3.7.0). + async function elfTagListRetry() { + if (cntrLow || !elfs.length || elfs.some(o => o.version)) return; + const g = ramExpandedGroups(expandedReport).find(x => x.label === t('ELFs')); + if (g && ['tags', 'noea', 'lean'].indexOf(g.form) >= 0) return; + const saved = expandedForm; + expandedForm = null; // start the ladder at the richest form + try { + const out = await expandedPass('20', t('ELFs'), ramParseElfStatus, ramListingSpi2('20')); + expandedReport.push({ p1: '20', label: t('ELFs'), compact: true, + form: out.form, sw: out.sw, entries: out.entries, + transport: out.transport, more: out.more }); + if (out.parsed.length) ramMergeExpanded(elfs, out.parsed); + } finally { + if (expandedForm === null) expandedForm = saved; + } + } + await elfTagListRetry(); + // 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); + + // Still missing versions: query the apps listing once per applet AID (the + // AID search qualifier selects one entity). The apps tag list works even + // where the ELF one is refused, and an applet's CE is its package's + // version - the applet <-> package mapping is the AID prefix (v3.7.0). + async function elfPerAidVersions() { + const missing = elfs.filter(e => !e.version && e.aid); + for (const e of missing.slice(0, 6)) { + if (cntrLow) break; + const app = apps.find(a => a.aid && !a.version && + String(a.aid).toUpperCase().indexOf(String(e.aid).toUpperCase()) === 0); + if (!app) continue; + ramShowProgress(t('Version') + ' ' + e.aid + '...'); + const res = await sendOta(ramExpandedQueryApdu('40', 'tags', false, app.aid), + Object.assign({}, sp, { cntr, spi2: ramListingSpi2('40') })); + if (res.ram_format) sp.ram_format = res.ram_format; + if (res.final_cntr) cntr = res.final_cntr; + else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr); + if (!res.success || !res.por || !spPorAccepted(res.por)) continue; + const data = res.por.decoded ? (res.por.decoded.last_response_data || '') : ''; + const parsed = data ? ramParseAppStatus(data) : []; + const hit = parsed.find(x => x.version); + if (hit) { + e.version = hit.version; + e.versionFrom = 'app'; + } + } + } + await elfPerAidVersions(); // 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); @@ -11173,13 +11235,19 @@ function ramExpandedTags(p1, form) { return base + (elf ? '84' : ''); } -// `next` = the next-occurrence page (P2.b1=1, GP 11.4.3.2) after a '6310'. -function ramExpandedQueryApdu(p1, form, next) { +// `next` = the next-occurrence page (P2.b1=1, GP 11.4.3.2) after a '6310'; +// `aidHex` = an AID search qualifier (`4F `, GP 11.4.2.3 +// Table 11-35) that selects a single registry entity - used to fetch one +// applet's entry when the listing cannot be paged (v3.7.0). +function ramExpandedQueryApdu(p1, form, next, aidHex) { const tags = ramExpandedTags(p1, form || 'tags'); const p2 = next ? '03' : '02'; - if (!tags) return '80F2' + p1 + p2 + '02' + '4F00' + '00'; + const aid = (aidHex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); const hex = n => n.toString(16).toUpperCase().padStart(2, '0'); - return '80F2' + p1 + p2 + hex(tags.length / 2 + 4) + '4F005C' + hex(tags.length / 2) + tags + '00'; + const filter = aid ? '4F' + hex(aid.length / 2) + aid : '4F00'; + const lc = filter.length / 2 + (tags ? tags.length / 2 + 2 : 0); + return '80F2' + p1 + p2 + hex(lc) + filter + + (tags ? '5C' + hex(tags.length / 2) + tags : '') + '00'; } // A '61xx' remote answer means the card holds the response data. The live @@ -18919,6 +18987,7 @@ const LANG_RU = { 'card counter': 'счётчик карты', 'the counter must be above it': 'счётчик должен быть больше', 'Go to preset': 'К пресету', + 'first page only': 'только первая страница', 'the operation was stopped': 'операция остановлена', 'STK menu entry:': 'Пункт меню STK:', 'position': 'позиция', diff --git a/frontend/sw.js b/frontend/sw.js index 2628401..5f5baf3 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v331'; +const CACHE = 'simple-v332'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index ad88ba6..c81053e 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -580,6 +580,12 @@ test('ramExpandedQueryApdu builds the expanded query forms', () => { assert.strictEqual(ramExpandedQueryApdu('10', 'lean', true), '80F21003094F005C054F9F70CE8400'); assert.strictEqual(ramExpandedQueryApdu('10', 'notags', true), '80F21003024F0000'); assert.deepStrictEqual(RAM_EXPANDED_FORMS, ['tags', 'noea', 'lean', 'notags']); + // an AID search qualifier (4F ) selects one entity: used to + // fetch a single applet's entry when the listing cannot be paged + assert.strictEqual(ramExpandedQueryApdu('40', 'tags', false, 'A1130001180001FFFFFFFF89A1003908'), + '80F240021C4F10A1130001180001FFFFFFFF89A10039085C084F9F70C5C4CCCEEA00'); + assert.strictEqual(ramExpandedQueryApdu('40', 'notags', false, 'A1130001180001'), + '80F24002094F07A113000118000100'); }); test('ramChainGetResponse chains the GET RESPONSE into the query string', () => { @@ -667,6 +673,10 @@ test('ramExpandedReport summarizes the query forms and the fallbacks', () => { { p1: '10', label: 'ELFs', compact: true, form: 'lean', sw: '9000', entries: 3 }, { p1: '20', label: 'ELFs', compact: true, form: 'notags', sw: '9000', entries: 5 }, ]), 'Expanded registry: lean tag list (ELFs)'); + // an unfulfilled 6310 (the next-occurrence page was refused) is noted + assert.strictEqual(ramExpandedReport([ + { p1: '40', label: 'Apps', compact: true, form: 'tags', sw: '6A86', entries: 2, more: true }, + ]), 'Expanded registry: tag list + EA (Apps (first page only))'); // a request/transport failure is not a remote status word assert.strictEqual(ramExpandedReport([ { p1: '40', label: 'Apps', compact: true, form: 'tags', sw: '9000', entries: 9 }, @@ -734,6 +744,26 @@ test('ramCntrLowHtml states the facts and offers the preset jump', () => { assert.strictEqual(ramCntrLowHtml(null, 0), ''); }); +test('the explorer shows versions on app and ISD rows too', () => { + globalThis.t = s => s; + const out = ramRenderExploreHtml(null, + [{ aid: 'A000000151000000', lifecycle: '0F', privileges: '82FC80', version: '0001' }], + [{ aid: 'A1130001180001FFFFFFFF89A1003908', lifecycle: '07', privileges: '000000', version: '0903' }], + []); + delete globalThis.t; + assert.ok(out.includes('Version: 0.1'), out); + assert.ok(out.includes('Version: 9.3'), out); +}); + +test('the Explore retries the ELF tag lists with P1=20 and per applet AID', () => { + const src = extractFunc(html, 'ramExplore'); + assert.ok(src.includes('elfTagListRetry'), 'the P1=20 tag-list retry must exist'); + assert.ok(src.includes("expandedPass('20'"), 'the retry must query P1=20'); + assert.ok(src.includes('elfPerAidVersions'), 'the per-AID version queries must exist'); + assert.ok(src.includes("ramExpandedQueryApdu('40', 'tags', false, app.aid)"), + 'the per-AID query must use the AID filter'); +}); + test('every SCP80/RAM flow checks the low counter and stops', () => { // the Explore: the guarded sender, the loop guards and the final branch const explore = extractFunc(html, 'ramExplore'); diff --git a/pyproject.toml b/pyproject.toml index c721212..fa80550 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.58" +version = "3.7.0" 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 d1a344a..128d5f5 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.58' +VERSION = '3.7.0' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE