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 <len> <AID>, 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.
This commit is contained in:
2026-09-30 11:17:12 +03:00
parent 162930eb96
commit 032f70f509
5 changed files with 113 additions and 14 deletions
+80 -11
View File
@@ -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 += '<div class="mb-1 pl-2 pr-2 py-1 rounded bg-gray-50 dark:bg-slate-800/60 hover:bg-gray-100 dark:hover:bg-slate-700/50 border-l-2 border-blue-400">';
html += '<div>' + esc(t('AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '</div>';
html += '<div>' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', 'isd') + '</div>';
if (o.version) html += '<div>' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) + '</div>';
if (o.privileges) html += '<div>' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
if (o.elfAid) html += '<div>' + esc(t('Load File AID / Package AID:')) + ' ' + jcAidHtml(o.elfAid) + '</div>';
if (o.sdAid) html += '<div>' + esc(t('SD AID:')) + ' ' + jcAidHtml(o.sdAid) + '</div>';
@@ -9315,6 +9318,7 @@ function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) {
html += '<div class="flex-1 min-w-0">';
html += '<div>' + esc(t('Application / Instance AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '</div>';
html += '<div>' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', ramEntryKind(o)) + '</div>';
if (o.version) html += '<div>' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) + '</div>';
if (o.privileges) html += '<div>' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
if (o.implicitSel) html += '<div>' + esc(t('Implicit sel:')) + ' ' + o.implicitSel + '</div>';
if (o.elfAid) html += '<div>' + esc(t('Load File AID / Package AID:')) + ' ' + jcAidHtml(o.elfAid) + '</div>';
@@ -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 <len> <AID>`, 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': 'позиция',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v331';
const CACHE = 'simple-v332';
const URLS = [
'index.html',
'help.html',
+30
View File
@@ -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 <len> <AID>) 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');
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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