diff --git a/frontend/index.html b/frontend/index.html index cd7049d..dedb103 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.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); diff --git a/frontend/sw.js b/frontend/sw.js index 5f5baf3..292e837 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v332'; +const CACHE = 'simple-v333'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index c81053e..7c60eae 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', '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', () => { diff --git a/pyproject.toml b/pyproject.toml index fa80550..5aed374 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 128d5f5..854fc8d 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.7.0' +VERSION = '3.7.1' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE