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<xx> 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<xx> 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.
This commit is contained in:
2026-09-30 10:08:24 +03:00
parent b7798a7fd9
commit 61bb1ab621
5 changed files with 99 additions and 30 deletions
+53 -17
View File
@@ -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 += '<div class="mb-4">';
html += '<div class="font-semibold text-sm mb-1">' + esc(t('Executable Load Files (ELFs) / Packages')) + ' (' + elfs.length + ')</div>';
if (!elfs.some(o => o.version)) {
if (elfs.some(o => !o.version)) {
html += '<div class="mb-1 text-xs text-gray-500 dark:text-slate-400">' + esc(ramElfVersionHint(elfForm)) + '</div>';
}
elfs.forEach(o => {
@@ -9309,7 +9327,11 @@ function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) {
html += '<div class="flex-1 min-w-0">';
html += '<div>' + esc(t('Load File AID / Package AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '</div>';
html += '<div>' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', 'elf') + '</div>';
if (o.version) html += '<div>' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) + '</div>';
if (o.version) {
html += '<div>' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) +
(o.versionFrom === 'app' ? ' <span class="text-gray-500 dark:text-slate-400">(' + esc(t('from the app instance')) + ')</span>' : '') +
'</div>';
}
if (o.privileges && o.privileges !== '00') html += '<div>' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
if (o.moduleAids && o.moduleAids.length) {
html += '<div>' + esc(t('Executable Module AIDs / Applet Class AIDs:')) + '</div>';
@@ -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:': 'Прочие данные реестра:',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v328';
const CACHE = 'simple-v329';
const URLS = [
'index.html',
'help.html',
+43 -10
View File
@@ -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 <span class="text-gray-500 dark:text-slate-400">(from the app instance)</span>'), 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');
});
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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