fix: per-AID version queries - module->applet mapping + chained GET RESPONSE (v3.7.1)

Two bugs in the v3.7.0 per-AID fallback, both visible in the live log
(2026-09-30 11:21):

- the candidate applet was searched by the AID prefix, but the applet
  instance AIDs are the package's module AIDs (package A113...0200 has
  modules ...0208 / ...494D4508, both present as applet instances - they
  differ from the package AID in the last byte), so that package produced no
  query at all.  ramElfAppletCandidate (pure) prefers the module AIDs and
  keeps the shared-prefix rule as a fallback;
- the single-AID expanded query answered 6134 (the card holds the data) and
  the empty response was read, so no version was derived.  The query is now
  retried with the GET RESPONSE chained into the compact command string
  (ramChainGetResponse) exactly like the ladder - a standalone GET RESPONSE
  answers 6700 on this card.

Tests: the module->applet mapping with the live AIDs (incl. the versioned
applet being skipped and the prefix fallback), plus the source guards for the
chained retry.

sw cache simple-v333; tests: 557 Python / 717 Node.
This commit is contained in:
2026-09-30 11:30:40 +03:00
parent 032f70f509
commit 666fc2cf6c
5 changed files with 72 additions and 13 deletions
+39 -9
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.7.0';
const SIMPLE_VERSION = '3.7.1';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -9257,6 +9257,22 @@ function ramElfVersionHint(elfForm) {
return t('Package versions come from the expanded registry query \u2014 this card did not return them.');
}
// The applet instance that belongs to a package (pure). The package's module
// AIDs are the applet instance AIDs - they can differ from the package AID in
// the last byte (live 2026-09-30: package A113...0200 with modules ...0208 /
// ...494D4508, all present as applet instances); a shared AID prefix is the
// fallback rule. Applets that already carry a version are skipped.
function ramElfAppletCandidate(elf, apps) {
if (!elf || !elf.aid) return null;
const cands = (elf.moduleAids || []).concat([elf.aid])
.map(c => String(c).toUpperCase());
const list = (apps || []).filter(a => a && a.aid && !a.version);
const exact = list.find(a => cands.indexOf(String(a.aid).toUpperCase()) >= 0);
if (exact) return exact;
const pkg = String(elf.aid).toUpperCase();
return list.find(a => String(a.aid).toUpperCase().indexOf(pkg) === 0) || null;
}
// 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
@@ -9668,21 +9684,35 @@ async function ramExplore(sp) {
// 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).
// version - the applet instance AIDs are the package's module AIDs
// (ramElfAppletCandidate). The single-AID form answers 61xx (the card
// holds the data), so it is retried with the GET RESPONSE chained into the
// compact command string, like the ladder (v3.7.1).
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);
const app = ramElfAppletCandidate(e, apps);
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);
const apdu = ramExpandedQueryApdu('40', 'tags', false, app.aid);
const send = async a => {
const res = await sendOta(a, 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);
return res;
};
let res = await send(apdu);
if (!res.success || !res.por || !spPorAccepted(res.por)) continue;
let sw = ((res.por.decoded && res.por.decoded.last_status_word) || '').toUpperCase();
if (/^61[0-9A-F]{2}$/.test(sw) && sp.ram_format !== 'expanded') {
// 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)
res = await send(ramChainGetResponse(apdu));
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);
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v332';
const CACHE = 'simple-v333';
const URLS = [
'index.html',
'help.html',
+30 -1
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', 'ramChainGetResponse', 'ramDeriveElfVersions',
'ramExpandedReport', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramElfAppletCandidate',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText',
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
@@ -618,6 +618,31 @@ test('ramDeriveElfVersions reads a package version from its applet instance', ()
assert.strictEqual(elfs[2].version, undefined);
});
test('ramElfAppletCandidate maps a package to its applet instance', () => {
// live 2026-09-30: the applet instance AIDs are the package's module AIDs
// (they differ from the package AID in the last byte)
const apps = [
{ aid: 'A1130001180002FFF7100E8904000208' },
{ aid: 'A1130001180002FFF7100E89494D4508' },
{ aid: 'F0414C4641600101' },
{ aid: 'A1130001180001FFFFFFFF89A1003908', version: '0903' },
];
assert.strictEqual(ramElfAppletCandidate({ aid: 'A1130001180002FFF7100E8904000200',
moduleAids: ['A1130001180002FFF7100E8904000208', 'A1130001180002FFF7100E89494D4508'] }, apps).aid,
'A1130001180002FFF7100E8904000208');
assert.strictEqual(ramElfAppletCandidate({ aid: 'F0414C46416001',
moduleAids: ['F0414C4641600101'] }, apps).aid, 'F0414C4641600101');
// an applet that already carries a version is skipped, and a package whose
// module is that applet has no other candidate
assert.strictEqual(ramElfAppletCandidate({ aid: 'A1130001180001FFFFFFFF89A1003900',
moduleAids: ['A1130001180001FFFFFFFF89A1003908'] }, apps), null);
// a package with no applet at all
assert.strictEqual(ramElfAppletCandidate({ aid: 'A00000006203010101', moduleAids: [] }, apps), null);
assert.strictEqual(ramElfAppletCandidate(null, apps), null);
// the shared-prefix rule is the fallback
assert.strictEqual(ramElfAppletCandidate({ aid: 'ABCD' }, [{ aid: 'ABCDEF' }]).aid, 'ABCDEF');
});
test('the explorer annotates a derived package version', () => {
globalThis.t = s => s;
const out = ramRenderExploreHtml(null, [], [],
@@ -762,6 +787,10 @@ test('the Explore retries the ELF tag lists with P1=20 and per applet AID', () =
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');
assert.ok(src.includes('ramElfAppletCandidate(e, apps)'),
'the module->applet mapping must be used');
assert.ok(src.includes('ramChainGetResponse(apdu)'),
'the single-AID 61xx answer must be fetched with a chained GET RESPONSE');
});
test('every SCP80/RAM flow checks the low counter and stops', () => {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.7.0"
version = "3.7.1"
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.7.0'
VERSION = '3.7.1'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE