feat: Explore exposes the rest of the registry data + install NV footprint (v3.6.51)

The compact (legacy) GET STATUS entry is <aid_len><AID><life cycle>
<privileges> + (P1=10) <module count> (<len><module AID>)*, cross-checked
against GlobalPlatformPro's populate_legacy (the 4th byte is privileges;
0x00 is required for load files).  The parser read it as a 'version' one
byte late, so ELF rows showed the module count / the next entry's length as
Version: 00/10 - the compact listing has no version at all; it now comes
from the expanded CE tag (ramFmtVersion: 2 bytes -> major.minor, GP Table
11-37 Note 1) and merges (the bogus compact value no longer blocked it).

New data exposed:
- EA SCP Registry Data (TS 102 226 8.2.1.6) -> 80 Menu Parameters -> the
  applet's STK menu entries: STK menu entry: 0x80 (position 1, Enabled)
- unknown registry objects are preserved (rawTlvs) and shown as
  'Other registry data: 8F=752B . 90=012D ...' instead of being dropped
- 84 parses the GP coding (one Executable Module AID per TLV, repeated; the
  concatenated 4F<len><aid> form stays as fallback) and the ELF expanded
  queries add 84 to the tag list (with a tag list the card returns only the
  listed tags - the fallback path would otherwise lose the module lists and
  their Install buttons)
- life cycle names decode per object kind (GP Tables 11-3..11-6: ISD/card,
  app, SD - detected via the Security Domain privilege - and ELF), with
  multi-byte 9F70 (02 0700 on live cards) decoding the first byte and
  surfacing non-zero extras; the R-APDU parser's LIFECYCLE_MAP labels were
  aligned with the same tables

/api/ram-install measures the NV footprint: a silent GET DATA FF21 read
before and after the chain (also on failure) returns nv_before/nv_after/
nv_delta; the reads never fail the operation (no FF21 -> no fields) and the
PWA shows 'NV used: ~N B (free A -> B)' in the install result.

sw cache simple-v324; tests: 548 Python / 698 Node.
This commit is contained in:
2026-09-30 09:00:33 +03:00
parent be411a7b4b
commit 599d84fd66
11 changed files with 637 additions and 76 deletions
+163 -6
View File
@@ -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 <span class="text-gray-400 dark:text-slate-500">(GlobalPlatform Issuer Security Domain)</span>'), 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<len><aid> 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'
+11 -6
View File
@@ -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)', () => {