';
if (o.aid) {
@@ -9210,16 +9347,7 @@ async function ramExplore(sp) {
collector.push(...parsed);
} else if (parsed.length) {
// Keep the compact entries, add the expanded-only fields
- const byAid = {};
- collector.forEach(e => { if (e.aid) byAid[e.aid.toUpperCase()] = e; });
- parsed.forEach(p => {
- const cur = byAid[(p.aid || '').toUpperCase()];
- if (!cur) return;
- ['privileges', 'lifecycle', 'version', 'elfAid', 'sdAid', 'implicitSel'].forEach(k => {
- if (p[k] && !cur[k]) cur[k] = p[k];
- });
- if (p.moduleAids && p.moduleAids.length) cur.moduleAids = p.moduleAids;
- });
+ ramMergeExpanded(collector, parsed);
}
}
// The compact error only surfaces when the expanded pass did not rescue it
@@ -9726,6 +9854,17 @@ async function ramInstallApp(sp, op) {
ramShowInstallResult(data, t('OK'));
}
+// Measured NV footprint of an install operation: the server reads GET DATA
+// FF21 (free non-volatile memory) before and after the chain; the delta is
+// what the operation consumed (negative = memory was freed). Cards without
+// FF21 have no values and the line disappears.
+function ramNvLine(data) {
+ if (!data || data.nv_before == null || data.nv_after == null) return '';
+ const used = data.nv_before - data.nv_after;
+ const label = used < 0 ? t('NV freed:') : t('NV used:');
+ return label + ' ~' + Math.abs(used) + ' B (' + t('free') + ' ' + data.nv_before + ' \u2192 ' + data.nv_after + ')';
+}
+
// Shared result rendering for /api/ram-install and /api/ram-install-app: the
// per-step lines, the counter the card consumed, and the verdict.
function ramShowInstallResult(data, okText, capMem) {
@@ -9735,6 +9874,8 @@ function ramShowInstallResult(data, okText, capMem) {
const comps = capMem && capMem.components;
let txt = '';
(data.steps || []).forEach((s, idx) => { txt += ramStepLine(s, idx, comps, data.load_block_size) + '\n'; });
+ const nv = ramNvLine(data);
+ if (nv) txt += nv + '\n';
stepsEl.textContent = txt;
// Persist the counter the card actually consumed (accepted packets only):
// even a failed install leaves the accepted steps behind, and replaying
@@ -10489,8 +10630,14 @@ function ramGetStatusApdu(p1, p2) {
// (GP Card Spec 11.4.2.3/11.4.3.1). Used as the fallback when a card rejects
// the deprecated compact format and for the optional details pass; the bytes
// are the reference trace form (`80F28002 0C 4F00 5C08 4F9F70C5C4CCCEEA 00`).
+// The ELF queries add '84' (Executable Module AIDs, GP Table 11-37): with a
+// tag list present the card returns only the listed tags (11.4.3.1), so
+// without it the expanded fallback would list packages without their modules
+// (no module rows / Install buttons on cards that reject the compact form).
function ramExpandedQueryApdu(p1) {
- return '80F2' + p1 + '02' + '0C' + '4F005C084F9F70C5C4CCCEEA' + '00';
+ const elf = p1 === '10' || p1 === '20';
+ const tags = '4F9F70C5C4CCCEEA' + (elf ? '84' : '');
+ return '80F2' + p1 + '02' + (elf ? '0D' : '0C') + '4F005C' + (elf ? '09' : '08') + tags + '00';
}
// The ISD (80) and memory responses fit the ENVELOPE; the Apps (40) and ELF
@@ -18189,7 +18336,13 @@ const LANG_RU = {
'KIc key and KID key are required': 'Укажите ключ KIc и ключ KID',
'RAM command format:': 'Формат команд RAM:',
'Fetch expanded registry details (load-file / SD associations)': 'Запрашивать расширенные данные реестра (связи load-файла и SD)',
- 'Runs one extra expanded query per listing (P2=02, tag list 4F 9F70 C5 C4 CC CE EA). The compact listing carries AID/lifecycle/privileges/version only; the expanded pass adds the Load File AID, Security Domain AID and module lists. It costs extra secured packets and some cards reject it - the Explore already uses it automatically when the compact listing returns nothing.': 'Запускает дополнительный расширенный запрос для каждого списка (P2=02, список тегов 4F 9F70 C5 C4 CC CE EA). Компактный список содержит только AID/состояние/привилегии/версию; расширенный проход добавляет AID load-файла, AID Security Domain и списки модулей. Он расходует дополнительные защищённые пакеты, и не все карты его поддерживают — Explore и так применяет его автоматически, когда компактный список ничего не вернул.',
+ 'Runs one extra expanded query per listing (P2=02, tag list 4F 9F70 C5 C4 CC CE EA, plus 84 for the ELF queries). The compact listing carries AID, life cycle and privileges only - it has no version; the expanded pass adds the Executable Load File version, the Load File AID, the Security Domain AID, the module lists and the applet STK menu entries (SCP Registry Data). It costs extra secured packets and some cards reject it - the Explore already uses it automatically when the compact listing returns nothing.': 'Запускает дополнительный расширенный запрос для каждого списка (P2=02, список тегов 4F 9F70 C5 C4 CC CE EA, плюс 84 для запросов ELF). Компактный список содержит только AID, состояние и привилегии — версии в нём нет; расширенный проход добавляет версию исполняемого load-файла, AID load-файла, AID Security Domain, списки модулей и пункты меню STK апплета (SCP Registry Data). Он расходует дополнительные защищённые пакеты, и не все карты его поддерживают — Explore и так применяет его автоматически, когда компактный список ничего не вернул.',
+ 'STK menu entry:': 'Пункт меню STK:',
+ 'position': 'позиция',
+ 'Other registry data:': 'Прочие данные реестра:',
+ 'NV used:': 'Занято NV:',
+ 'NV freed:': 'Освобождено NV:',
+ 'free': 'свободно',
'compact = the TS 102 226 5.2.1 command string (one C-APDU per secured packet, e.g. GET STATUS + GET RESPONSE); expanded = the AA/22 Command Scripting template (5.2.1.0). Detected per operation with a read-only GET STATUS probe; a card that rejects the deprecated compact P2.b2=0 form answers the expanded one.': 'compact = командная строка по TS 102 226 5.2.1 (один C-APDU на защищённый пакет, например GET STATUS + GET RESPONSE); expanded = шаблон Command Scripting AA/22 (5.2.1.0). Определяется для каждой операции пробным чтением GET STATUS; карта, отклоняющая устаревшую компактную форму P2.b2=0, отвечает в расширенной.',
'Card resources (GET DATA FF21)': 'Ресурсы карты (GET DATA FF21)',
'Extended Card Resources (GP tag FF21, ETSI TS 102 226 8.2.1.7.2): installed applications, free non-volatile and free volatile memory': 'Расширенные ресурсы карты (тег GP FF21, ETSI TS 102 226 8.2.1.7.2): число установленных приложений, свободная энергонезависимая и свободная энергозависимая память',
diff --git a/frontend/sw.js b/frontend/sw.js
index be246fa..b433dfd 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v323';
+const CACHE = 'simple-v324';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js
index 8dd7f26..5e9f1be 100644
--- a/frontend/tests/ram.test.js
+++ b/frontend/tests/ram.test.js
@@ -22,9 +22,9 @@ function extractFunc(src, name) {
}
// Extract chain builder functions and dependencies
-const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramCompatProbeNote', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu',
+const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtVersion', 'ramMenuState', 'ramEntryKind', 'ramMenuHtml', 'ramRawTlvsHtml', 'ramNvLine', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramMergeExpanded', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramCompatProbeNote', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu',
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu',
- '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry',
+ '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText',
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
let code = '';
@@ -33,8 +33,6 @@ for (const f of FNS) {
}
const m = html.match(/const _chains = \{\};/);
if (m) code += m[0].replace(/^const /, 'var ') + '\n';
-const lc = html.match(/const RAM_LIFECYCLE = \{[\s\S]*?\n\};/);
-if (lc) code += lc[0].replace(/^const /, 'var ') + '\n';
const pn = html.match(/const PRIVILEGE_NAMES = \[[\s\S]*?\n\];/);
if (pn) code += pn[0].replace(/^const /, 'var ') + '\n';
const an = html.match(/const JC_AID_NAMES = \{[\s\S]*?\n\};/);
@@ -222,7 +220,8 @@ test('ramRenderExploreHtml localizes every label and button', () => {
const out = ramRenderExploreHtml(
{ appCount: 5, freeNV: 100, freeV: 50 },
[{ aid: 'A000000151000000', lifecycle: '07', privileges: '', sdAid: 'A000000151000000' }],
- [{ aid: 'A1130001180001', lifecycle: '07', privileges: '80', implicitSel: '00', elfAid: 'ELF1' }],
+ [{ aid: 'A1130001180001', lifecycle: '07', privileges: '80', implicitSel: '00', elfAid: 'ELF1',
+ menuEntries: [{ pos: '01', id: '80', state: '01' }], rawTlvs: [{ tag: '8F', value: '752B' }] }],
[{ aid: 'ELF1', lifecycle: '01', version: '1.0', moduleAids: ['M1'], sdAid: null }]
);
delete global.t;
@@ -240,6 +239,9 @@ test('ramRenderExploreHtml localizes every label and button', () => {
assert.ok(seen.includes('Application / Instance AID:'));
assert.ok(seen.includes('Load File AID / Package AID:'));
assert.ok(seen.includes('Executable Module AIDs / Applet Class AIDs:'));
+ assert.ok(seen.includes('STK menu entry:'));
+ assert.ok(seen.includes('position'));
+ assert.ok(seen.includes('Other registry data:'));
assert.ok(!out.includes('data-l10n'), out);
// the well-known ISD AID is annotated (GP Card Spec v2.3.1 H.1.3)
assert.ok(out.includes('A000000151000000
(GlobalPlatform Issuer Security Domain)'), out);
@@ -544,10 +546,17 @@ test('ramCardIdxAfterRemove keeps the remembered index aligned', () => {
test('ramExpandedQueryApdu matches the reference trace bytes', () => {
// TCA Loader: `AA14 2212 80F28002 0C 4F00 5C08 4F9F70C5C4CCCEEA 00`
- for (const p1 of ['80', '40', '20', '10']) {
+ for (const p1 of ['80', '40']) {
assert.strictEqual(ramExpandedQueryApdu(p1),
'80F2' + p1 + '020C4F005C084F9F70C5C4CCCEEA00');
}
+ // The ELF queries add '84' (Executable Module AIDs): with a tag list the
+ // card returns only the listed tags, so the expanded fallback would
+ // otherwise list packages without their modules (v3.6.51).
+ for (const p1 of ['20', '10']) {
+ assert.strictEqual(ramExpandedQueryApdu(p1),
+ '80F2' + p1 + '020D4F005C094F9F70C5C4CCCEEA8400');
+ }
});
test('ramParseAppStatus parses the expanded E3 listing (vendor traces)', () => {
@@ -586,6 +595,10 @@ test('ramParseElfStatus lists the compact ELF and module listings (F0414C4641610
const f041 = elfs.find(r => r.aid === 'F0414C46416101');
assert.ok(f041, JSON.stringify(elfs.map(r => r.aid)));
assert.strictEqual(f041.lifecycle, '01');
+ // the 4th byte is the privileges byte (the legacy GET STATUS shape);
+ // the compact listing has no version - it comes from the expanded CE tag
+ assert.strictEqual(f041.privileges, '00');
+ assert.strictEqual(f041.version, undefined);
assert.ok(elfs.some(r => r.aid === 'A1130001180002FFF7100E8904000200'), 'A113 ELF missing');
assert.ok(elfs.some(r => r.aid === 'A0000000090005FFFFFFFF8912000000'), 'uicc.toolkit ELF missing');
@@ -596,6 +609,150 @@ test('ramParseElfStatus lists the compact ELF and module listings (F0414C4641610
assert.deepStrictEqual(f041Row.moduleAids, ['F0414C4641610101']);
});
+test('_parseE3Entry collects Executable Module AIDs from every 84 TLV', () => {
+ // GP Table 11-37: one Executable Module AID per '84' TLV (the reference
+ // GPPro coding), repeated per module.
+ const one = _parseE3Entry('4F10A1130001180001FFFFFFFF89A10039008410A1130001180001FFFFFFFF89A1003908');
+ assert.deepStrictEqual(one.moduleAids, ['A1130001180001FFFFFFFF89A1003908']);
+ const two = _parseE3Entry('4F10A1130001180002FFF7100E89040002008410A1130001180002FFF7100E89040002088410A1130001180002FFF7100E89494D4508');
+ assert.deepStrictEqual(two.moduleAids,
+ ['A1130001180002FFF7100E8904000208', 'A1130001180002FFF7100E89494D4508']);
+ // a card concatenating 4F
TLVs inside one 84 still parses
+ const concat = _parseE3Entry('4F10A1130001180002FFF7100E890400020084244F10A1130001180002FFF7100E89040002084F10A1130001180002FFF7100E89494D4508');
+ assert.deepStrictEqual(concat.moduleAids,
+ ['A1130001180002FFF7100E8904000208', 'A1130001180002FFF7100E89494D4508']);
+});
+
+test('_parseE3Entry reads the SCP Registry Data menu entries (NP trace)', () => {
+ // Explore_NP.log app entry: ... EA 05 80 03 01 80 01 = menu entry
+ // position 1, identifier 0x80, enabled (TS 102 226 Tables 8.2/8.3).
+ const r = _parseE3Entry('4F10A1130001180001FFFFFFFF89A10039089F70020700C503000000C410A1130001180001FFFFFFFF89A1003900CE020903CC07A0000001510000EA058003018001');
+ assert.strictEqual(r.aid, 'A1130001180001FFFFFFFF89A1003908');
+ assert.strictEqual(r.lifecycle, '0700');
+ assert.strictEqual(r.version, '0903');
+ assert.strictEqual(r.sdAid, 'A0000001510000');
+ assert.strictEqual(r.elfAid, 'A1130001180001FFFFFFFF89A1003900');
+ assert.deepStrictEqual(r.menuEntries, [{ pos: '01', id: '80', state: '01' }]);
+ assert.strictEqual(r.rawTlvs, undefined);
+ // an empty EA (an ISD with no menu entries) is not an error and adds nothing
+ const empty = _parseE3Entry('4F08A000000151000000EA00');
+ assert.strictEqual(empty.menuEntries, undefined);
+ assert.strictEqual(empty.rawTlvs, undefined);
+});
+
+test('_parseE3Entry preserves registry data objects it does not decode', () => {
+ // Explore_NP.log ISD entry: the card returned 8F/90/91/92 beyond the
+ // requested GP tags - shown raw, never dropped.
+ const r = _parseE3Entry('4F07A00000015100009F70020F00C5039AFF80C407A0000001515350CE020001CC07A00000015100008F02752B9002012D910265D592020BD3');
+ assert.deepStrictEqual(r.rawTlvs, [
+ { tag: '8F', value: '752B' }, { tag: '90', value: '012D' },
+ { tag: '91', value: '65D5' }, { tag: '92', value: '0BD3' },
+ ]);
+ assert.strictEqual(r.privileges, '9AFF80');
+});
+
+test('ramFmtVersion formats the 2-byte CE version as major.minor', () => {
+ // GP Table 11-37 Note 1: Java Card CAP major/minor attributes, in order.
+ assert.strictEqual(ramFmtVersion('0903'), '9.3');
+ assert.strictEqual(ramFmtVersion('0100'), '1.0');
+ assert.strictEqual(ramFmtVersion('0A0B'), '10.11');
+ // other lengths stay raw (the format depends on the load file format)
+ assert.strictEqual(ramFmtVersion('09'), '09');
+ assert.strictEqual(ramFmtVersion(''), '');
+});
+
+test('ramFmtLifecycle decodes per GP object kind (Tables 11-3..11-6)', () => {
+ // ISD inherits the card life cycle (Table 11-6)
+ assert.strictEqual(ramFmtLifecycle('01', 'isd'), 'OP_READY');
+ assert.strictEqual(ramFmtLifecycle('07', 'isd'), 'INITIALIZED');
+ assert.strictEqual(ramFmtLifecycle('0F', 'isd'), 'SECURED');
+ assert.strictEqual(ramFmtLifecycle('7F', 'isd'), 'CARD_LOCKED');
+ assert.strictEqual(ramFmtLifecycle('FF', 'isd'), 'TERMINATED');
+ // Executable Load File (Table 11-3)
+ assert.strictEqual(ramFmtLifecycle('01', 'elf'), 'LOADED');
+ // Application (Table 11-4)
+ assert.strictEqual(ramFmtLifecycle('03', 'app'), 'INSTALLED');
+ assert.strictEqual(ramFmtLifecycle('07', 'app'), 'SELECTABLE');
+ assert.strictEqual(ramFmtLifecycle('0F', 'app'), 'SELECTABLE (0x0F)');
+ assert.strictEqual(ramFmtLifecycle('83', 'app'), 'LOCKED');
+ // Security Domain (Table 11-5)
+ assert.strictEqual(ramFmtLifecycle('0F', 'sd'), 'PERSONALIZED');
+ assert.strictEqual(ramFmtLifecycle('8F', 'sd'), 'LOCKED');
+ // a card may append extra bytes (NP trace: 9F70 02 0700)
+ assert.strictEqual(ramFmtLifecycle('0700', 'app'), 'SELECTABLE');
+ assert.strictEqual(ramFmtLifecycle('0701', 'app'), 'SELECTABLE (0x01)');
+ // unknown values stay visible
+ assert.strictEqual(ramFmtLifecycle('42', 'app'), '0x42');
+ assert.strictEqual(ramFmtLifecycle('', 'app'), '');
+});
+
+test('ramMenuState and ramEntryKind decode menu state and SD detection', () => {
+ global.t = s => s;
+ assert.strictEqual(ramMenuState('00'), 'Disabled');
+ assert.strictEqual(ramMenuState('01'), 'Enabled');
+ assert.strictEqual(ramMenuState('02'), '0x02');
+ delete global.t;
+ // GP Table 11-7 byte 1 b8 = Security Domain privilege
+ assert.strictEqual(ramEntryKind({ privileges: '9AFE80' }), 'sd');
+ assert.strictEqual(ramEntryKind({ privileges: '04' }), 'app');
+ assert.strictEqual(ramEntryKind({ type: 'elf', privileges: '00' }), 'elf');
+ assert.strictEqual(ramEntryKind(null), 'app');
+});
+
+test('the explorer shows versions, menu entries and unknown registry data', () => {
+ global.t = s => s;
+ const out = ramRenderExploreHtml(
+ { appCount: 1, freeNV: 10, freeV: 5 },
+ [],
+ [{ aid: 'A1130001180001FFFFFFFF89A1003908', lifecycle: '0700', privileges: '000000',
+ elfAid: 'A1130001180001FFFFFFFF89A1003900', sdAid: 'A0000001510000',
+ menuEntries: [{ pos: '01', id: '80', state: '01' }], rawTlvs: [{ tag: '8F', value: '752B' }] }],
+ [{ aid: 'A1130001180001FFFFFFFF89A1003900', lifecycle: '01', privileges: '00', version: '0309' }]
+ );
+ delete global.t;
+ // the app row: SELECTABLE from the 2-byte 0700, menu entry, raw TLV
+ assert.ok(out.includes('SELECTABLE'), out);
+ assert.ok(out.includes('STK menu entry: 0x80 (position 1, Enabled)'), out);
+ assert.ok(out.includes('Other registry data: 8F=752B'), out);
+ // the ELF row: LOADED, formatted version, no privileges noise for '00'
+ assert.ok(out.includes('LOADED'), out);
+ assert.ok(out.includes('Version: 3.9'), out);
+ const elfSection = out.slice(out.indexOf('Executable Load Files'));
+ assert.ok(!elfSection.includes('Privileges:'), elfSection);
+});
+
+test('ramMergeExpanded fills only what the compact listing lacks', () => {
+ // the compact ELF entry has no version (v3.6.51): the expanded CE version
+ // and SD AID merge in - this was blocked while the bogus compact "version"
+ // was still set
+ const compact = [{ type: 'elf', aid: 'A1130001180001FFFFFFFF89A1003900', lifecycle: '01', privileges: '00' }];
+ const expanded = [{ aid: 'A1130001180001FFFFFFFF89A1003900', version: '0309', sdAid: 'A0000001510000', lifecycle: '01' }];
+ ramMergeExpanded(compact, expanded);
+ assert.strictEqual(compact[0].version, '0309');
+ assert.strictEqual(compact[0].sdAid, 'A0000001510000');
+ // a field the compact entry already carries is never overwritten
+ const apps = [{ aid: 'A1', privileges: '04' }];
+ ramMergeExpanded(apps, [{ aid: 'A1', privileges: '9AFE80', menuEntries: [{ pos: '01', id: '80', state: '01' }] }]);
+ assert.strictEqual(apps[0].privileges, '04');
+ assert.deepStrictEqual(apps[0].menuEntries, [{ pos: '01', id: '80', state: '01' }]);
+ // module lists come from the expanded listing when it has them
+ const elfs = [{ aid: 'E1' }];
+ ramMergeExpanded(elfs, [{ aid: 'E1', moduleAids: ['M1'] }]);
+ assert.deepStrictEqual(elfs[0].moduleAids, ['M1']);
+});
+
+test('ramNvLine reports the measured NV footprint', () => {
+ global.t = s => s;
+ assert.strictEqual(ramNvLine({ nv_before: 50646, nv_after: 23400 }),
+ 'NV used: ~27246 B (free 50646 \u2192 23400)');
+ assert.strictEqual(ramNvLine({ nv_before: 100, nv_after: 150 }),
+ 'NV freed: ~50 B (free 100 \u2192 150)');
+ assert.strictEqual(ramNvLine({ nv_before: 100 }), '');
+ assert.strictEqual(ramNvLine({}), '');
+ assert.strictEqual(ramNvLine(null), '');
+ delete global.t;
+});
+
test('ramParseAppStatus keeps 16-byte AIDs (no rawLen-1 truncation)', () => {
const page = '08D276000005AA3F010704'
+ '0FD276000005AA060200000000B000000700'
diff --git a/frontend/tests/response_map.test.js b/frontend/tests/response_map.test.js
index cc5f16c..7cf4fd7 100644
--- a/frontend/tests/response_map.test.js
+++ b/frontend/tests/response_map.test.js
@@ -38,14 +38,19 @@ test('gp 6310 label does not reference removed P2=42 option', () => {
});
test('LIFECYCLE_MAP per GPC v2.3 life cycles', () => {
- assert.strictEqual(LIFECYCLE_MAP['01'], 'OP_READY (card) / LOADED (ELF)');
- assert.strictEqual(LIFECYCLE_MAP['03'], 'INSTALLED');
- assert.strictEqual(LIFECYCLE_MAP['07'], 'SELECTABLE');
- assert.strictEqual(LIFECYCLE_MAP['0F'], 'SECURED (card)');
- assert.strictEqual(LIFECYCLE_MAP['1F'], 'PERSONALIZED (SD)');
+ // GP Card Spec v2.3.1 11.1.1 Tables 11-3..11-6: the coding depends on the
+ // object kind (the ISD inherits the card life cycle), so the shared map
+ // names every interpretation. The Explore decodes per kind instead
+ // (ramFmtLifecycle).
+ assert.strictEqual(LIFECYCLE_MAP['01'], 'OP_READY (card/ISD) / LOADED (ELF)');
+ assert.strictEqual(LIFECYCLE_MAP['03'], 'INSTALLED (app/SD)');
+ assert.strictEqual(LIFECYCLE_MAP['07'], 'SELECTABLE (app/SD) / INITIALIZED (card/ISD)');
+ assert.strictEqual(LIFECYCLE_MAP['0F'], 'SECURED (card/ISD) / PERSONALIZED (SD)');
+ assert.strictEqual(LIFECYCLE_MAP['1F'], 'App-specific (app)');
assert.strictEqual(LIFECYCLE_MAP['7F'], 'CARD_LOCKED');
assert.strictEqual(LIFECYCLE_MAP['FF'], 'TERMINATED');
- assert.strictEqual(LIFECYCLE_MAP['83'], 'LOCKED (SD)');
+ assert.strictEqual(LIFECYCLE_MAP['83'], 'LOCKED');
+ assert.strictEqual(LIFECYCLE_MAP['8F'], 'LOCKED');
});
test('TS 51.011 SIM families resolve (94xx/98xx/92xx/9Exx/9Fxx)', () => {
diff --git a/pyproject.toml b/pyproject.toml
index 702a002..d6050fe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.6.50"
+version = "3.6.51"
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 c803860..acf0551 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.50'
+VERSION = '3.6.51'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -1479,13 +1479,18 @@ def _ram_step_result(step_name, last_sw, por, por_hex, bytes_, segments):
return step, error
-def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex):
+def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex, silent=False):
"""Send one GP APDU as an SCP80 secured packet and append its step result
to `state['steps']`. `sp` carries the SCP80 parameters (spi1, spi2, kic,
kid, tar, kic_key, kid_key, include_cpi); `state` carries the mutable run
state ({'steps', 'encode_error', 'failure', 'cntr'}) and the counter is
advanced only for a packet the card accepted. Returns True on success
- (the step record is the last element of state['steps'])."""
+ (the step record is the last element of state['steps']).
+
+ `silent=True` (the NV footprint reads) leaves no step record and no
+ failure note behind: the card's answer is only used by the caller, and a
+ rejected read must never fail the operation. The decoded step record is
+ still available as `state['last_step']`."""
spi1, spi2 = sp['spi1'], sp['spi2']
try:
sp_hex, _ = _build_secured_packet(spi1, spi2, sp['kic'], sp['kid'], sp['tar'],
@@ -1496,9 +1501,10 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex):
sys.stderr.write('RAM C-APDU (%s): %s\n' % (step_name, apdu_hex))
sys.stderr.write('RAM SECURED-PACKET (%s): %s\n' % (step_name, sp_hex))
except ValueError as e:
- state['encode_error'] = str(e)
- state['steps'].append({'name': step_name, 'por_status': 'encode_error',
- 'sw': str(e)})
+ if not silent:
+ state['encode_error'] = str(e)
+ state['steps'].append({'name': step_name, 'por_status': 'encode_error',
+ 'sw': str(e)})
sys.stderr.write('RAM-INSTALL: %s encode failed: %s\n' % (step_name, e))
return False
# Capture the PoR from either transport: inline in the ENVELOPE response
@@ -1516,10 +1522,11 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex):
scc, sp_hex, oa_number=server.sms_oa, sm_sc=server.sms_sc,
include_cpi=sp['include_cpi'], submit_handler=submit_handler)
if not result['success']:
- state['steps'].append({'name': step_name, 'por_status': 'envelope_error',
- 'sw': result.get('sw') or result.get('error'),
- 'bytes': result.get('bytes'),
- 'segments': result.get('segments')})
+ if not silent:
+ state['steps'].append({'name': step_name, 'por_status': 'envelope_error',
+ 'sw': result.get('sw') or result.get('error'),
+ 'bytes': result.get('bytes'),
+ 'segments': result.get('segments')})
sys.stderr.write('RAM-INSTALL: %s send failed: %s\n' % (
step_name, result.get('error')))
return False
@@ -1541,7 +1548,9 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex):
sp['kic_key'], sp['kid_key'], por_hex)
step, step_error = _ram_step_result(step_name, last_sw, por, por_hex,
result['bytes'], result['segments'])
- state['steps'].append(step)
+ state['last_step'] = step
+ if not silent:
+ state['steps'].append(step)
sys.stderr.write('RAM-INSTALL: %s PoR[%s] status=%s remote_sw=%s%s (%d B, %d SM)\n' % (
step_name, por_src, step.get('por_status', '?'), step.get('por_sw', '-'),
(' error=%s' % step_error) if step_error else '',
@@ -1550,7 +1559,8 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex):
state['cntr'] = _ram_next_cntr(
state['cntr'], step.get('por_status') in ('por_ok', 'no_por'))
if step_error:
- state['failure']['error'] = '%s: %s' % (step_name, step_error)
+ if not silent:
+ state['failure']['error'] = '%s: %s' % (step_name, step_error)
return False
return True
finally:
@@ -1558,6 +1568,41 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex):
scc._tp.proactive_handler = old_proactive
+def _ram_free_nv(por_data_hex):
+ """Free non-volatile memory (bytes) from a GET DATA FF21 R-APDU, or None.
+ Reuses the FF21 decoder (TS 102 226 8.2.1.7.2: '81' applet count /
+ '82' free NV / '83' free volatile)."""
+ try:
+ rapdu = bytes.fromhex(por_data_hex or '')
+ except ValueError:
+ return None
+ mem = _scp81_decode_memory(rapdu)
+ return mem.get('free_nv') if mem else None
+
+
+def _ram_read_ff21(server, scc, sp, state, ram_format, label):
+ """Best-effort GET DATA FF21 (Extended Card Resources) read around an
+ install: returns the free non-volatile memory in bytes, or None (a card
+ without FF21 or a rejected read must never fail the operation). The read
+ is sent through the normal step sender with `silent=True`, so it consumes
+ and advances the counter correctly but leaves no step record or failure
+ note behind."""
+ apdu = _ram_format_apdu('80CAFF2100', ram_format)
+ if not _ram_send_gp_apdu(server, scc, sp, state, label, apdu, silent=True):
+ return None
+ step = state.get('last_step') or {}
+ return _ram_free_nv(step.get('por_data'))
+
+
+def _ram_nv_fields(nv_before, nv_after):
+ """NV footprint fields for the install responses: the free-NV values and
+ their delta (None when the card has no FF21 readout)."""
+ if nv_before is None and nv_after is None:
+ return {}
+ delta = (nv_before - nv_after) if (nv_before is not None and nv_after is not None) else None
+ return {'nv_before': nv_before, 'nv_after': nv_after, 'nv_delta': delta}
+
+
def _parse_response_scripting(data):
"""Parse a Response Scripting template (TS 102 226 5.2.2, tables
5.10/5.10a): `AB ` (definite) or `AF 80 ... 00 00` (indefinite),
@@ -6765,6 +6810,8 @@ class PysimHandler(BaseHTTPRequestHandler):
# Live progress for the PWA's operation modal (the request holds
# _CARD_LOCK until the chain is done; /api/status carries this)
_ram_progress_begin('install-cap', len(seq) + 1)
+ nv_before = None
+ nv_after = None
try:
# RAM command format: detected per operation (a read-only
# probe step) unless the caller pinned compact/expanded.
@@ -6774,6 +6821,10 @@ class PysimHandler(BaseHTTPRequestHandler):
ram_format = _ram_detect_format(self.server, scc, sp_state, state)
sys.stderr.write('RAM-INSTALL: %d APDUs (INSTALL / %d x LOAD / INSTALL, %s) loadfile_aid=%s\n' % (
len(seq), len(seq) - 2, ram_format, loadfile_aid))
+ # NV footprint: free non-volatile memory before the chain
+ # (best effort - a card without FF21 skips it).
+ nv_before = _ram_read_ff21(self.server, scc, sp_state, state,
+ ram_format, 'NV before')
for apdu_idx, gp_apdu in enumerate(seq):
if apdu_idx == 0:
@@ -6786,6 +6837,11 @@ class PysimHandler(BaseHTTPRequestHandler):
if not _ram_send_gp_apdu(self.server, scc, sp_state, state,
step_name,
_ram_format_apdu(gp_apdu, ram_format)):
+ # What the failed attempt consumed (a rejected load
+ # may keep or discard its partial file - the delta
+ # tells which).
+ nv_after = _ram_read_ff21(self.server, scc, sp_state, state,
+ ram_format, 'NV after')
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': (state['encode_error'] or state['failure'].get('error')
or ('%s failed' % step_name)),
@@ -6794,9 +6850,13 @@ class PysimHandler(BaseHTTPRequestHandler):
'load_block_size': block_size,
'load_block_size_requested': block_size_req,
'load_block_size_auto': not block_size_req}
+ resp.update(_ram_nv_fields(nv_before, nv_after))
self._send_json(resp)
self._log_resp(resp)
return
+ # NV footprint: what the successful chain consumed.
+ nv_after = _ram_read_ff21(self.server, scc, sp_state, state,
+ ram_format, 'NV after')
finally:
_ram_progress_end()
@@ -6805,6 +6865,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'ram_format': ram_format, 'load_block_size': block_size,
'load_block_size_requested': block_size_req,
'load_block_size_auto': not block_size_req}
+ resp.update(_ram_nv_fields(nv_before, nv_after))
sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
loadfile_aid, module_aid, state['cntr']))
self._send_json(resp)
diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py
index 21e3d04..2fc0dfb 100644
--- a/tests/test_fetch_routes.py
+++ b/tests/test_fetch_routes.py
@@ -16,6 +16,7 @@ import threading
import unittest
import urllib.error
import urllib.request
+from unittest import mock
from pysim_simple_server.__main__ import _build_http_server
from pysim_simple_server.server import PysimHandler, _CARD_FREE_GET, _CARD_LOCK
@@ -222,5 +223,91 @@ class CapCompatHttpTests(unittest.TestCase):
self.assertIn('import probe', resp['error'])
+class RamInstallNvFootprintHttpTests(unittest.TestCase):
+ """The RAM install response carries the measured NV footprint (v3.6.51):
+ the server reads GET DATA FF21 before and after the chain (best effort)
+ and reports the free-NV values and their delta."""
+
+ def setUp(self):
+ from pysim_simple_server import server as srv
+ self.srv = srv
+ self.server = _build_http_server('127.0.0.1', 0, PysimHandler)
+ self.server.log_requests = False
+ self.server.app = None
+ self.server.sl = None
+ self.server.scc = object()
+ self.server.sms_oa = '12345'
+ self.server.sms_sc = '12345678912'
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
+ self.port = self.server.server_address[1]
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.server.server_close()
+
+ def _post(self, path, body):
+ req = urllib.request.Request(
+ 'http://127.0.0.1:%d%s' % (self.port, path),
+ data=json.dumps(body).encode(),
+ headers={'Content-Type': 'application/json'}, method='POST')
+ try:
+ with urllib.request.urlopen(req, timeout=5) as res:
+ return res.status, json.loads(res.read() or b'{}')
+ except urllib.error.HTTPError as e:
+ return e.code, json.loads(e.read() or b'{}')
+
+ def _install(self, fail_at=None, nv_before=50000, nv_after=20000):
+ def fake_send(server, scc, sp, state, name, apdu, silent=False):
+ if fail_at == name:
+ state['steps'].append({'name': name, 'por_status': 'por_error', 'por_sw': '6A88'})
+ return False
+ state['steps'].append({'name': name, 'por_status': 'por_ok', 'por_sw': '9000'})
+ return True
+
+ def fake_read(server, scc, sp, state, ram_format, label):
+ return nv_before if label == 'NV before' else nv_after
+
+ patches = [
+ mock.patch.object(self.srv, '_cap_parse',
+ lambda cap_hex: ('F0414C46416101', 'F0414C4641610101', b'\x01\x02')),
+ mock.patch.object(self.srv, '_cap_apdu_sequence',
+ lambda *a, **k: ['80E60200', '80E80000', '80E60000']),
+ mock.patch.object(self.srv, '_ram_detect_format', lambda *a, **k: 'compact'),
+ mock.patch.object(self.srv, '_ram_send_gp_apdu', fake_send),
+ mock.patch.object(self.srv, '_ram_read_ff21', fake_read),
+ ]
+ for p in patches:
+ p.start()
+ try:
+ return self._post('/api/ram-install', {'cap_hex': 'AA'})
+ finally:
+ for p in reversed(patches):
+ p.stop()
+
+ def test_success_reports_the_nv_delta(self):
+ status, resp = self._install()
+ self.assertEqual(status, 200, resp)
+ self.assertTrue(resp['success'], resp)
+ self.assertEqual(resp['nv_before'], 50000)
+ self.assertEqual(resp['nv_after'], 20000)
+ self.assertEqual(resp['nv_delta'], 30000)
+
+ def test_failed_install_still_reports_the_nv_delta(self):
+ status, resp = self._install(fail_at='LOAD (1/1)')
+ self.assertEqual(status, 200, resp)
+ self.assertFalse(resp['success'], resp)
+ self.assertEqual(resp['failed_step'], 2)
+ self.assertEqual(resp['nv_before'], 50000)
+ self.assertEqual(resp['nv_after'], 20000)
+ self.assertEqual(resp['nv_delta'], 30000)
+
+ def test_a_card_without_ff21_has_no_nv_fields(self):
+ status, resp = self._install(nv_before=None, nv_after=None)
+ self.assertEqual(status, 200, resp)
+ self.assertTrue(resp['success'], resp)
+ for key in ('nv_before', 'nv_after', 'nv_delta'):
+ self.assertNotIn(key, resp)
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py
index 6c3b0d1..c89e787 100644
--- a/tests/test_ota_helpers.py
+++ b/tests/test_ota_helpers.py
@@ -37,6 +37,9 @@ from pysim_simple_server.server import (
_por_remote_sw,
_ram_next_cntr,
_ram_format_apdu,
+ _ram_free_nv,
+ _ram_read_ff21,
+ _ram_nv_fields,
_wrap_expanded_apdu,
_ram_remote_sw_ok,
_sms_submit_por,
@@ -1585,6 +1588,90 @@ class RamSendGpApduLoggingTest(unittest.TestCase):
self.assertIn('RAM SECURED-PACKET (LOAD (1/2)): AABB', out)
+class RamNvFootprintTests(unittest.TestCase):
+ """GET DATA FF21 reads around an install (v3.6.51): the free-NV values and
+ their delta. The reads are best-effort - a card without FF21 (or a
+ rejected read) must never fail the operation."""
+
+ def test_free_nv_from_ff21_response(self):
+ # TS 102 226 8.2.1.7.2: '81' applet count, '82' free NV, '83' free
+ # volatile (the length byte follows FF21).
+ data = 'FF210C' + '810102' + '820300C5D6' + '83020064'
+ self.assertEqual(_ram_free_nv(data), 0xC5D6)
+ # CR-set tag variants (0x01/0x02/0x03) decode the same
+ self.assertEqual(_ram_free_nv('FF210C' + '010102' + '020300C5D6' + '03020064'), 0xC5D6)
+ # not an FF21 response / empty / garbage
+ self.assertIsNone(_ram_free_nv('6A88'))
+ self.assertIsNone(_ram_free_nv(''))
+ self.assertIsNone(_ram_free_nv('not-hex'))
+
+ def test_nv_fields(self):
+ self.assertEqual(_ram_nv_fields(None, None), {})
+ self.assertEqual(_ram_nv_fields(50000, 20000),
+ {'nv_before': 50000, 'nv_after': 20000, 'nv_delta': 30000})
+ # a freed delta is negative (memory released by the operation)
+ self.assertEqual(_ram_nv_fields(100, 150),
+ {'nv_before': 100, 'nv_after': 150, 'nv_delta': -50})
+ # only one read available: the delta stays None
+ self.assertEqual(_ram_nv_fields(100, None),
+ {'nv_before': 100, 'nv_after': None, 'nv_delta': None})
+
+ def test_read_ff21_uses_the_silent_sender(self):
+ from pysim_simple_server import server as srv
+
+ calls = []
+
+ def fake_send(server, scc, sp, state, name, apdu, silent=False):
+ calls.append((name, apdu, silent))
+ if not silent:
+ state['steps'].append({'name': name})
+ state['last_step'] = {'por_data': 'FF210C810102820300C5D683020064'}
+ return True
+
+ state = {'steps': [], 'encode_error': None, 'failure': {}, 'cntr': '0000000001'}
+ with mock.patch.object(srv, '_ram_send_gp_apdu', fake_send):
+ nv = _ram_read_ff21(None, None, {}, state, 'expanded', 'NV before')
+ self.assertEqual(nv, 0xC5D6)
+ self.assertEqual(calls, [('NV before', _wrap_expanded_apdu('80CAFF2100'), True)])
+ self.assertEqual(state['steps'], [])
+ self.assertEqual(state['failure'], {})
+
+ def test_read_ff21_returns_none_when_the_read_fails(self):
+ from pysim_simple_server import server as srv
+
+ def fake_send(server, scc, sp, state, name, apdu, silent=False):
+ return False
+
+ state = {'steps': [], 'encode_error': None, 'failure': {}, 'cntr': '0000000001'}
+ with mock.patch.object(srv, '_ram_send_gp_apdu', fake_send):
+ self.assertIsNone(_ram_read_ff21(None, None, {}, state, 'compact', 'NV after'))
+
+ def test_silent_send_leaves_no_step_or_failure(self):
+ import contextlib
+ import io
+ from pysim_simple_server import server as srv
+
+ class FakeServer:
+ sms_oa = '12345'
+ sms_sc = '12345678912'
+
+ sp = {'spi1': '16', 'spi2': '01', 'kic': '25', 'kid': '25', 'tar': '000000',
+ 'kic_key': 'AA', 'kid_key': 'BB', 'include_cpi': True}
+ state = {'steps': [], 'encode_error': None, 'failure': {}, 'cntr': '0000000001'}
+ with mock.patch.object(srv, '_build_secured_packet', lambda *a, **k: ('AABB', {})), \
+ mock.patch.object(srv, '_send_secured_packet',
+ lambda *a, **k: {'success': False, 'error': 'stub'}):
+ buf = io.StringIO()
+ with contextlib.redirect_stderr(buf):
+ ok = srv._ram_send_gp_apdu(FakeServer(), object(), sp, state,
+ 'NV before', '80CAFF2100', silent=True)
+ self.assertFalse(ok)
+ self.assertEqual(state['steps'], [])
+ self.assertEqual(state['failure'], {})
+ self.assertIsNone(state['encode_error'])
+ self.assertIn('RAM C-APDU (NV before): 80CAFF2100', buf.getvalue())
+
+
class RamCommandFormatTests(unittest.TestCase):
"""RAM command format detection helpers (TS 102 226 5.2.1, v3.6.24)."""