v3.26.0: per-TAR default keysets (the preset/SCP80/RAM rework)

Every preset TAR entry carries `keyset` (int 1-15) - the keyset every
operation addressed to that TAR uses: mandatory for any non-zero MSL
(the counters are keyset-linked), optional for MSL 00 (allowed: some
cards reject the no-security 00/00 KIc/KID form).  The store fills it
automatically when the preset has a single keyset, the validation
refuses an entry naming an undefined keyset, and the schema is 6 (a v5
file loads leniently; editing it forces the fix).

The keyset selectors are gone: the SCP80 TAR field became a select of
the preset's entries (applying the TAR's keyset to the form and seeding
the SPI1 from its MSL); the RAM operations use the preset's ISD role
entry (the PWA sends only the preset id, the ISD TAR, SPI and the
counter override - the server resolves the keyset and the keys:
presets.tar_keyset, _preset_tar_keyset, _resolve_operation_keyset,
_op_keyset, used by send-ota's APDU path, ram-install, ram-install-app
and cap-compat); the counter probe and the test runner's scp80 steps
default to their TAR's keyset (an explicit kvn stays an override).  A
security TAR without a keyset refuses the operation ("set it in the
Cards tab") - never a silent keyless send - and the start buttons wait
for a usable operation-TAR keyset.

The TAR probe checks the preset's TAR list only (the standard
TAR_PROBE_TARS list and the custom-TAR localStorage are gone; a `tars`
list selects a subset, a TAR outside the preset is refused), each row
using its own MSL and default keyset.  The Cards editor's TAR table
gained a Keyset column (the only keyset preselected, an amber marker
when a security MSL lacks one).

Docs: help EN/RU, api.md's tar-probe contract, both AGENTS.  Tests: the
store (the field, the auto-fill, the schema, the string forms), the
server resolution (send-ota + the refusal, the probe's per-row keysets
and refusals), the PWA (the TAR select, the RAM ISD resolution, the
keyset-free requests) - several fixtures updated for the new schema.
891 frontend + 885 Python; sw cache simple-v434.
This commit is contained in:
2026-10-14 12:21:16 +03:00
parent 6e4494195c
commit cb40662e4e
18 changed files with 955 additions and 611 deletions
+23 -8
View File
@@ -34,12 +34,12 @@ for (const fn of ['cardsTarList', 'cardsRoleDefault', 'cardsRoleTar',
'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrEffective', 'spCntrAhead',
'spCntrAdjust',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard',
'spTarListRender',
'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard',
'ramKeysetGuard',
'esc',
]) {
code += extractFunc(html, fn) + '\n';
}
@@ -61,9 +61,22 @@ globalThis.t = s => s;
function fakeEnv(selValue, selId, presetCntr) {
const els = {};
for (const id of ['sp-spi1', 'sp-spi2-hex', 'sp-kic-idx', 'sp-kic-alg',
'sp-kid-idx', 'sp-kid-alg', 'sp-tar', 'sp-cntr', 'sp-kic-key', 'sp-kid-key']) {
'sp-kid-idx', 'sp-kid-alg', 'sp-cntr', 'sp-kic-key', 'sp-kid-key']) {
els[id] = { value: '' };
}
// the TAR is a select (v3.26.0): the stub models the surface the renderer
// touches - setting innerHTML parses the option values and selectedIndex
// moves the value, like a real <select>
els['sp-tar'] = {
value: '', disabled: false, options: [], _sel: 0, _html: '',
set innerHTML(v) {
this._html = v;
this.options = Array.from(v.matchAll(/value="([^"]*)"/g), m => ({ value: m[1] }));
},
get innerHTML() { return this._html; },
set selectedIndex(i) { this._sel = i; if (this.options[i]) this.value = this.options[i].value; },
get selectedIndex() { return this._sel; },
};
// the combined key bytes the server reads (updateSpKic maintains them in
// the real form): the fake preset's keyset resolves to kvn 1
els['sp-kic-hex'] = { value: '15' };
@@ -114,14 +127,16 @@ test('spRefreshFromPreset for the RAM selector targets the ISD TAR', () => {
assert.strictEqual(env.els['sp-tar'].value, '000000');
});
test('the pre-send refresh keeps a hand-edited packet TAR (v3.6.21)', () => {
test('the pre-send refresh keeps the selected packet TAR', () => {
// v3.26.0: the TAR is a preset selector - the pre-send refresh must keep
// the operator's selection (a TAR outside the preset is not offered)
const env = fakeEnv('0', 'sp-card-sel', '0000000020');
env.els['sp-tar'].value = 'AF4D01'; // e.g. a push/link trigger target
env.els['sp-tar'].value = 'B00000'; // the UICC RFM entry of the preset
assert.strictEqual(spRefreshFromPreset('sp-card-sel', true), '0000000020');
assert.strictEqual(env.els['sp-tar'].value, 'AF4D01', 'the typed TAR must survive the send');
assert.strictEqual(env.els['sp-tar'].value, 'B00000', 'the selected TAR must survive the send');
assert.strictEqual(env.els['sp-cntr'].value, '', 'the counter field stays an override');
assert.match(env.els['sp-cntr'].placeholder, /0000000020/);
// an explicit preset apply (or a pack) still sets the TAR
// an explicit preset apply (or a pack) selects the operation's TAR
cardsApplyFields(0);
assert.strictEqual(env.els['sp-tar'].value, 'B00000');
});
+78 -37
View File
@@ -35,13 +35,13 @@ for (const fn of ['cardsFormValues', 'cardsClearForm', 'cardsEdit',
'spCntrAhead', 'spCntrLocalSync', 'ramSaveCntr',
'cardsApplyFields', 'cardsApply', 'ramApplyCard',
'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent',
'spTarKeyForPack', 'spPresetTar', 'spTarListRender', 'packToSp',
'spTarKeyForPack', 'spPresetTar', 'packToSp',
'esc', 'escHtml',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard', 'ramKeysetGuard',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard', 'ramKeysetGuard',
'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar',
'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'spMslWarningText',
'cardsTarRowHtml', 'cardsTarRowsRender', 'cardsTarAdd', 'cardsTarRemove',
@@ -73,12 +73,11 @@ const CARD_IDS = ['cards-name','cards-iccid','cards-release','cards-pin1','cards
// the TAR table rows: 0-2 are the fixed role rows, 3+ the free rows
'cards-tr-0-tar','cards-tr-0-msl','cards-tr-1-tar','cards-tr-1-msl',
'cards-tr-2-tar','cards-tr-2-msl',
'cards-tr-3-tar','cards-tr-3-msl','cards-tr-3-desc',
'cards-tr-4-tar','cards-tr-4-msl','cards-tr-4-desc'];
'cards-tr-3-tar','cards-tr-3-msl','cards-tr-3-desc','cards-tr-3-keyset',
'cards-tr-4-tar','cards-tr-4-msl','cards-tr-4-desc','cards-tr-4-keyset'];
const SP_IDS = ['sp-card-sel','sp-tar','sp-spi1','sp-spi2','sp-spi2-sm','sp-spi2-hex',
'sp-kic-idx','sp-kic-alg','sp-kic-hex','sp-kid-idx','sp-kid-alg','sp-kid-hex',
'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex','sp-tar-list',
'sp-keyset-sel','ram-keyset-sel'];
'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex'];
const CHAIN_IDS = ['chain-sim-preview','chain-usim-preview','chain-ram-preview',
'ber-result','hota-preview'];
@@ -91,10 +90,10 @@ function ksRow(els, i, ks) {
}
function tarRow(els, i, e) {
for (const field of ['tar', 'msl', 'desc']) {
for (const field of ['tar', 'msl', 'desc', 'keyset']) {
const id = 'cards-tr-' + i + '-' + field;
if (!els[id]) els[id] = fakeEl();
els[id].value = e[field] || '';
els[id].value = (e[field] === undefined || e[field] === null) ? '' : String(e[field]);
}
}
@@ -108,6 +107,34 @@ function presetFixture(extra) {
}, extra || {});
}
// A <select>-like fake (the TAR selector): setting innerHTML parses the option
// values (and the selected one) and selectedIndex moves the value, like a real
// element. The accessors need defineProperties - Object.assign would flatten
// them into data properties.
function selectEl() {
const el = fakeEl();
el.options = [];
el._sel = 0;
el._html = '';
el.disabled = false;
Object.defineProperties(el, {
innerHTML: {
get() { return this._html; },
set(v) {
this._html = v;
this.options = Array.from(v.matchAll(/value="([^"]*)"/g), m => ({ value: m[1] }));
const sel = v.match(/<option value="([^"]*)" selected/);
if (sel) this.value = sel[1];
},
},
selectedIndex: {
get() { return this._sel; },
set(i) { this._sel = i; if (this.options[i]) this.value = this.options[i].value; },
},
});
return el;
}
function fakeEl() {
const classes = new Set();
return {
@@ -134,6 +161,7 @@ let apiCalls = [];
function setup() {
const els = {};
for (const id of CARD_IDS.concat(SP_IDS, CHAIN_IDS)) els[id] = fakeEl();
els['sp-tar'] = selectEl(); // the TAR selector (v3.26.0)
globalThis.document = { getElementById: id => els[id] || null, querySelectorAll: () => [] };
globalThis.cards = [];
globalThis._cardsEditIdx = null;
@@ -257,9 +285,9 @@ test('clearing the card form keeps the role TAR/MSL defaults', () => {
assert.match(els['cards-tars'].innerHTML, /cards-tr-2-tar"[^>]*value="B00001"/);
// ... and the table reads back as the three role entries
assert.deepStrictEqual(cardsTarsFromForm(), [
{ role: 'isd', tar: '000000', msl: '16', desc: '' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '' },
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '' },
{ role: 'isd', tar: '000000', msl: '16', desc: '', keyset: null },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '', keyset: null },
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '', keyset: null },
]);
});
@@ -312,10 +340,10 @@ test('the form values carry the codes and the TAR table', () => {
_cardsTarCount = 1;
const v = cardsFormValues();
assert.deepStrictEqual(v.tars, [
{ role: 'isd', tar: '000001', msl: '0A', desc: '' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '' },
{ role: 'usimRfm', tar: 'B00003', msl: '1A', desc: '' },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' },
{ role: 'isd', tar: '000001', msl: '0A', desc: '', keyset: null },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '', keyset: null },
{ role: 'usimRfm', tar: 'B00003', msl: '1A', desc: '', keyset: null },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet', keyset: null },
]);
assert.strictEqual(v.pin1, '1234');
assert.strictEqual(v.puk1, '12345678');
@@ -342,6 +370,11 @@ test('saving a preset posts the form with the pre-settled TAR table', async () =
els['cards-name'].value = 'New card';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
globalThis._cardsKeysetCount = 1;
// the editor's render preselects the only keyset on every TAR row
// (v3.26.0); the fake DOM is static, so the test does it explicitly
tarRow(els, 0, { keyset: 1 });
tarRow(els, 1, { keyset: 1 });
tarRow(els, 2, { keyset: 1 });
// the role fields are pre-settled by the markup; the test's fake elements
// start empty - the defaults fill in
els['cards-adm'].value = 'aa bb';
@@ -352,9 +385,9 @@ test('saving a preset posts the form with the pre-settled TAR table', async () =
assert.deepStrictEqual(apiCalls[0].body.keysets, [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }]);
assert.deepStrictEqual(apiCalls[0].body.tars, [
{ role: 'isd', tar: '000000', msl: '16', desc: '' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '' },
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '' },
{ role: 'isd', tar: '000000', msl: '16', desc: '', keyset: 1 },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '', keyset: 1 },
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '', keyset: 1 },
]);
assert.strictEqual(apiCalls[0].body.adm, 'AABB');
});
@@ -457,11 +490,14 @@ test('applying a preset uses the TAR of the current operation and seeds the SPI1
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, 'B0000D');
assert.strictEqual(els['sp-spi1'].value, '1A');
// an empty stored TAR -> spec default
// a preset without TAR entries (hand-made; the store fills the three
// roles): the selector has no options and the SPI1 falls back to the
// default MSL
globalThis.cards = [{ name: 'D', keysets: [], tars: [] }];
globalThis._spTarKey = 'isd';
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, '000000');
assert.strictEqual(els['sp-tar'].value, '');
assert.match(els['sp-tar'].innerHTML, /no TAR in the preset/);
assert.strictEqual(els['sp-spi1'].value, '16');
});
@@ -494,15 +530,18 @@ test('spPresetTar reads the selected preset and falls back to the spec defaults'
assert.strictEqual(spPresetTar('uiccRfm'), 'B00000');
});
test('the TAR field suggestions come from the preset table', () => {
test('the TAR selector lists the preset table', () => {
// v3.26.0: the TAR is a select of the preset's entries (role label or
// description, MSL and default keyset in the option text)
const els = setup();
spTarListRender(presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16' },
spTarRender(presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16', keyset: 1 },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' }] }));
assert.match(els['sp-tar-list'].innerHTML, /value="000000"[^>]*label="ISD \(MSL 16\)"/);
assert.match(els['sp-tar-list'].innerHTML, /value="AF4D02"[^>]*label="My applet \(MSL 0A\)"/);
spTarListRender(null);
assert.strictEqual(els['sp-tar-list'].innerHTML, '');
assert.match(els['sp-tar'].innerHTML, /value="000000"[^>]*>000000 \u00b7 ISD \u00b7 MSL 16 \u00b7 Keyset 1</);
assert.match(els['sp-tar'].innerHTML, /value="AF4D02"[^>]*>AF4D02 \u00b7 My applet \u00b7 MSL 0A</);
spTarRender(null);
assert.match(els['sp-tar'].innerHTML, /select a card preset/);
assert.strictEqual(els['sp-tar'].disabled, true);
});
test('packing a chain selects the TAR of its target', () => {
@@ -533,17 +572,18 @@ test('packing a chain selects the TAR of its target', () => {
assert.strictEqual(els['sp-apdu'].value, 'AA0100');
});
test('packing without a selected preset uses the spec default TAR', () => {
test('packing without a selected preset keeps the role for the next apply', () => {
// v3.26.0: the TAR options come from the preset - without one the selector
// stays on its placeholder and the role is applied when a preset arrives
const els = setup();
els['chain-sim-preview'].value = 'A0A4000002';
packToSp('chain-sim-preview');
assert.strictEqual(globalThis._spTarKey, 'uiccRfm');
assert.strictEqual(els['sp-tar'].value, '', 'no preset -> no TAR options yet');
globalThis.cards = [presetFixture()];
els['sp-card-sel'].value = '0';
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, 'B00000');
els['chain-usim-preview'].value = '00A4000002';
packToSp('chain-usim-preview');
assert.strictEqual(els['sp-tar'].value, 'B00001');
els['chain-ram-preview'].value = '80E29000';
packToSp('chain-ram-preview');
assert.strictEqual(els['sp-tar'].value, '000000');
});
test('the TAR row values are left-aligned like the headers', () => {
@@ -586,7 +626,8 @@ test('the TAR table renders the role rows and adds/removes free rows', () => {
assert.strictEqual(_cardsTarCount, 2);
// the new row renders empty
tarRow(els, 4, { tar: '', msl: '', desc: '' });
assert.deepStrictEqual(cardsFreeTarsFromForm(), [{ tar: 'AF4D02', msl: '0A', desc: 'two' }]);
assert.deepStrictEqual(cardsFreeTarsFromForm(),
[{ tar: 'AF4D02', msl: '0A', desc: 'two', keyset: null }]);
// the role rows are never removable
cardsTarRemove(0);
assert.strictEqual(_cardsTarCount, 2);
+6
View File
@@ -43,6 +43,12 @@ code += extractFunc(html, 'cardsScp80Complete') + '\n';
code += extractFunc(html, 'cardsScp81Complete') + '\n';
code += extractFunc(html, 'cardsMatchedPreset') + '\n';
code += extractFunc(html, 'spKeysetStartReady') + '\n';
code += extractFunc(html, 'spKeysetGuard') + '\n';
code += extractFunc(html, 'ramKeysetGuard') + '\n';
code += extractFunc(html, 'spTarKeysetGuard') + '\n';
code += extractFunc(html, 'cardsTarEntry') + '\n';
code += extractFunc(html, 'cardsRoleTar') + '\n';
code += extractFunc(html, 'cardsRoleDefault') + '\n';
code += extractFunc(html, 'spPresetIdx') + '\n';
code += extractFunc(html, 'ramPresetIdx') + '\n';
code += extractFunc(html, 'tarPresetIdx') + '\n';
+46 -57
View File
@@ -23,8 +23,9 @@ function extractFunc(src, name) {
let code = '';
for (const fn of ['spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'spCounterTracked', 'spPresetIdx', 'ramPresetIdx',
'tarPresetIdx', 'spKeysetSync', 'ramRender', 'esc', 'escHtml']) {
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'spCounterTracked', 'spPresetIdx', 'ramPresetIdx',
'tarPresetIdx', 'spKeysetSync', 'ramRender', 'esc', 'escHtml',
'cardsTarList', 'cardsTarEntry', 'cardsRoleTar']) {
code += extractFunc(html, fn) + '\n';
}
code += 'function t(s){return s;}\n';
@@ -91,20 +92,23 @@ test('spKeysetCheck enforces the keyset-number rules', () => {
assert.match(spKeysetCheck(PRESET, '15', 'ZZ'), /KID/);
});
test('spKeysetOptionsHtml lists the keysets and marks an undefined number', () => {
const html1 = spKeysetOptionsHtml(PRESET, '15', '15');
assert.match(html1, /value="1" selected/);
assert.match(html1, /value="2"/);
assert.ok(html1.indexOf('0000000005') > 0, 'the counters are shown');
// a version the preset does not define is offered but marked
const html2 = spKeysetOptionsHtml(PRESET, '35', '35');
assert.match(html2, /value="3" selected/);
assert.match(html2, /not defined in the preset/);
// a preset without keysets: the form's version shows as undefined
const html3 = spKeysetOptionsHtml({ keysets: [] }, '15', '15');
assert.match(html3, /not defined in the preset/);
// no keyset number in the form and no keysets at all
assert.match(spKeysetOptionsHtml({}, '', ''), /no keyset/);
test('spTarKeysetGuard follows the TAR default keyset (v3.26.0)', () => {
const preset = Object.assign({}, PRESET, { tars: [
{ role: 'isd', tar: '000000', msl: '16', keyset: 1 },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', keyset: 2 },
{ role: 'usimRfm', tar: 'B00001', msl: '00' },
{ tar: 'B00011', msl: '16', keyset: 3 }] });
assert.strictEqual(spTarKeysetGuard(preset, '000000'), '');
assert.strictEqual(spTarKeysetGuard(preset, 'B00001'), ''); // MSL 00: keyless is fine
// a TAR naming a keyset the preset does not define
assert.match(spTarKeysetGuard(preset, 'B00011'), /is not defined in the preset/);
// a security TAR without a default keyset
const noKeyset = Object.assign({}, PRESET, { tars: [
{ role: 'isd', tar: '000000', msl: '16' }] });
assert.match(spTarKeysetGuard(noKeyset, '000000'), /needs a default keyset/);
// a TAR outside the preset is the server's business; no preset: nothing
assert.strictEqual(spTarKeysetGuard(preset, 'B00099'), '');
assert.strictEqual(spTarKeysetGuard(null, '000000'), '');
});
test('the Cards form wires the keyset editor', () => {
@@ -117,9 +121,14 @@ test('the Cards form wires the keyset editor', () => {
// the keyset rows are built with the KIc/KID/key/counter inputs
assert.match(html, /cards-ks-' \+ i \+ '-kic-key/);
assert.match(html, /cards-ks-' \+ i \+ '-cntr/);
// the selectors exist in both SCP80 views
assert.match(html, /id="sp-keyset-sel" onchange="spKeysetChanged\(\)"/);
assert.match(html, /id="ram-keyset-sel" onchange="ramKeysetChanged\(\)"/);
// v3.26.0: the keyset selectors are gone - the SCP80 TAR select drives
// the keyset, the RAM operations use the preset's ISD entry, and the TAR
// table gained a keyset column
assert.match(html, /id="sp-tar" onchange="spTarChanged\(\)"/);
assert.ok(!html.includes('id="sp-keyset-sel"'));
assert.ok(!html.includes('id="ram-keyset-sel"'));
assert.ok(!html.includes('id="tar-keyset-sel"'));
assert.match(html, /id="cards-tr-' \+ i \+ '-keyset"/);
// the packet actions run the guard
assert.ok(html.includes('const ksErr = spKeysetGuard();'));
assert.ok(html.includes('const ksErr = ramKeysetGuard();'));
@@ -136,14 +145,9 @@ test('spCounterTracked follows SPI1 b5b4 (TS 102 225 5.1.1)', () => {
assert.strictEqual(spCounterTracked('zz'), false);
});
test('ramRender rebuilds the keyset selector from the remembered preset', () => {
// regression: the auto-selection runs before the RAM select has options, so
// its keyset list was built with no preset and stayed on "not defined"
test('ramRender keeps the remembered preset (v3.26.0: no keyset selector)', () => {
const els = {
'ram-card-sel': { value: '', innerHTML: '', appendChild() {} },
'ram-keyset-sel': { value: '', innerHTML: '' },
'sp-keyset-sel': { value: '', innerHTML: '' },
'tar-keyset-sel': { value: '', innerHTML: '' },
'sp-kic-hex': { value: '25' },
'sp-kid-hex': { value: '25' },
};
@@ -160,9 +164,6 @@ test('ramRender rebuilds the keyset selector from the remembered preset', () =>
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '0',
'the preset select keeps the remembered card');
assert.match(els['ram-keyset-sel'].innerHTML, /value="2" selected/);
assert.ok(!els['ram-keyset-sel'].innerHTML.includes('not defined'),
'the keyset selector must not show a stale "not defined" entry');
});
test('the RAM and TAR views rebuild their keyset lists when their preset is known', () => {
@@ -179,44 +180,32 @@ test('the RAM and TAR views rebuild their keyset lists when their preset is know
assert.ok(html.includes("if (name === 'tar') { tarRender(); tarProbeRender(); }"));
});
test('the keyset selector is disabled and cleared while no preset is selected', () => {
test('the TAR selector waits for a preset and lists its entries', () => {
const els = {
'sp-keyset-sel': { value: '', innerHTML: '' },
'ram-keyset-sel': { value: '', innerHTML: '' },
'tar-keyset-sel': { value: '', innerHTML: '' },
'sp-tar': { value: '', disabled: false, innerHTML: '', options: [] },
'sp-card-sel': { value: '' },
'ram-card-sel': { value: '' },
'tar-card-sel': { value: '' },
'sp-kic-hex': { value: '' },
'sp-kid-hex': { value: '' },
};
globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [];
spKeysetSync();
for (const id of ['sp-keyset-sel', 'ram-keyset-sel', 'tar-keyset-sel']) {
assert.strictEqual(els[id].disabled, true, id + ' must be disabled');
assert.match(els[id].innerHTML, /select a card preset/);
assert.ok(!els[id].innerHTML.includes('not defined'), id);
}
// a selected preset enables the selector and lists its keysets
globalThis.cards = [{ name: 'EP', keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
spTarRender();
assert.strictEqual(els['sp-tar'].disabled, true, 'no preset: the selector waits');
assert.match(els['sp-tar'].innerHTML, /select a card preset/);
// a selected preset enables the selector and lists its TARs with their
// MSL and default keyset
globalThis.cards = [{ name: 'EP', tars: [
{ role: 'isd', tar: '000000', msl: '16', keyset: 1 },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' }] }];
els['sp-card-sel'].value = '0';
els['ram-card-sel'].value = '0';
els['tar-card-sel'].value = '0';
els['sp-kic-hex'].value = '15';
els['sp-kid-hex'].value = '15';
spKeysetSync();
for (const id of ['sp-keyset-sel', 'ram-keyset-sel', 'tar-keyset-sel']) {
assert.strictEqual(els[id].disabled, false, id + ' must be enabled');
assert.match(els[id].innerHTML, /value="1" selected/);
}
spTarRender();
assert.strictEqual(els['sp-tar'].disabled, false);
assert.match(els['sp-tar'].innerHTML, /value="000000"[^>]*>000000 \u00b7 ISD \u00b7 MSL 16 \u00b7 Keyset 1</);
assert.match(els['sp-tar'].innerHTML, /value="AF4D02"[^>]*>AF4D02 \u00b7 My applet \u00b7 MSL 0A</);
});
test('clearing the preset selection clears the keyset selectors', () => {
test('clearing the preset selection resets the TAR selector', () => {
const at = html.indexOf('function cardsApply(');
assert.ok(at > 0);
const body = html.slice(at, at + 600);
assert.ok(body.includes('spKeysetSync();'),
'cardsApply must sync the keyset selectors when no preset is selected');
assert.ok(body.includes('spTarRender(null);'),
'cardsApply must reset the TAR selector when no preset is selected');
});
+13 -10
View File
@@ -30,9 +30,9 @@ const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamB
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
'ramCntrLowBox', 'ramCntrLowClear',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'spPresetIdx', 'tarPresetIdx',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard', 'ramKeysetGuard',
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'spPresetIdx', 'tarPresetIdx',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
'ramPresetIdx', 'spKeysetGuard', 'ramKeysetGuard',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText',
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
let code = '';
@@ -1038,13 +1038,12 @@ test('every SCP80/RAM flow checks the low counter and stops', () => {
assert.ok(go.includes('cardsEdit('), go);
assert.ok(go.includes("getElementById('cards-ks-' + row + '-cntr')"), go);
// the notice's sync probe posts to the bounded server endpoint with the
// preset, the keyset the failed operation used and its SPI/TAR
// preset, the operation's TAR (v3.26.0: the RAM view uses the ISD entry,
// the server resolves the keyset) and its SPI
const sync = extractFunc(html, 'ramSyncCounter');
assert.ok(sync.includes("'/api/counter-probe'"), sync);
assert.ok(sync.includes("own = view === 'sp'"), sync);
assert.ok(sync.includes('kvnOf(own)'), sync);
assert.ok(sync.includes('kvnOf(other)'), sync);
assert.ok(sync.includes('spKeysetSync()'), 'the keyset labels must refresh');
assert.ok(sync.includes("view === 'ram' ? cardsRoleTar(preset, 'isd')"), sync);
assert.ok(sync.includes('spKeysetSync()'), 'the availability must refresh');
assert.ok(sync.includes('spCntrPlaceholder()'),
'the stored value must refresh the field placeholder (the field stays an override)');
assert.ok(sync.includes("ramListingSpi2('40')"),
@@ -1372,9 +1371,13 @@ test('getRamSpParams reads the computed SPI2 byte, not the base select', () => {
assert.ok(/spi2: document\.getElementById\('sp-spi2-hex'\)/.test(fn), fn);
});
test('getRamSpParams normalises the packet TAR to three bytes', () => {
test('getRamSpParams sends the preset ISD TAR and no keyset fields (v3.26.0)', () => {
// the server resolves the keyset from the preset's ISD TAR entry - the
// PWA sends only the preset id, the ISD TAR, SPI and the counter override
const fn = extractFunc(html, 'getRamSpParams');
assert.ok(/tar:[\s\S]*?padEnd\(6, '0'\)\.slice\(0, 6\),/.test(fn), fn);
assert.ok(fn.includes("tar: preset ? cardsRoleTar(preset, 'isd') : ''"), fn);
assert.ok(!fn.includes('kic:'), 'the keyset fields are resolved server-side');
assert.ok(!fn.includes('kicKey:'), 'the keys travel from the preset store');
});
test('ramListingSpi2 requests the SMS-submit PoR for the listing queries', () => {
+8 -2
View File
@@ -35,14 +35,20 @@ for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr
'ramCntrLowBox', 'ramCntrLowClear',
'escHtml', 'esc',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'cardsRoleTar', 'cardsTarEntry', 'cardsTarList', 'cardsRoleDefault', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard',
'ramPresetIdx', 'spKeysetGuard',
'ramKeysetGuard',
]) {
code += extractFunc(html, fn) + '\n';
}
code = 'var _cardsKeysetCount = 0;\n' + code;
// the role defaults the TAR helpers read (v3.26.0)
for (const c of ['CARDS_TAR_DEFAULTS', 'CARDS_TAR_ROLES', 'CARDS_MSL_DEFAULT']) {
const m = new RegExp('const\\s+' + c + '\\s*=\\s*([^;]+);').exec(html);
if (!m) throw new Error('const ' + c + ' not found');
code = 'var ' + c + ' = ' + m[1] + ';\n' + code;
}
eval(code);
function fakeEnv(por, success) {
+33 -65
View File
@@ -23,78 +23,43 @@ function extractFunc(src, name) {
let code = '';
for (const fn of ['esc', 'escHtml', 'tarProbeVerdictLabel', 'tarProbeHtml',
'tarProbeRowsHtml', 'tarProbeCustomLoad', 'tarProbeCustomSave',
'tarProbeAllRows', 'tarProbeChecked']) {
'tarProbeRowsHtml', 'tarProbeAllRows', 'tarProbeChecked', 'cardsTarList']) {
code += extractFunc(html, fn) + '\n';
}
code += "var TAR_PROBE_TARS = JSON.parse('" +
JSON.stringify(eval('(' + html.match(/const TAR_PROBE_TARS = \[[\s\S]*?\n\];/)[0]
.replace('const TAR_PROBE_TARS = ', '').replace(/;$/, '') + ')')) + "');\n";
code += "var TAR_PROBE_STORAGE = 'simple_tar_probe';\n";
code += 'function t(s){return s;}\n';
eval(code);
function fakeStorage() {
const map = {};
globalThis.localStorage = {
getItem: k => (k in map ? map[k] : null),
setItem: (k, v) => { map[k] = String(v); },
};
return map;
}
test('the standard list covers the Annex D allocations', () => {
const tars = TAR_PROBE_TARS.map(e => e.tar);
assert.ok(tars.length >= 10, tars.length);
for (const want of ['000000', 'B20100', 'B00000', 'B00001', 'B00010',
'B00120', 'B00130', 'B00140', 'B20000', 'B20200', 'B20201', 'B00200']) {
assert.ok(tars.includes(want), want);
}
// every row carries the spec document it comes from
TAR_PROBE_TARS.forEach(e => {
assert.match(e.tar, /^[0-9A-F]{6}$/);
assert.ok(e.label, e.tar);
});
});
test('tarProbeRowsHtml renders checked rows and marks custom ones', () => {
test('tarProbeRowsHtml renders the preset rows checked', () => {
// v3.26.0: the rows come from the preset - no custom rows, no remove
// buttons
const out = tarProbeRowsHtml([
{ tar: '000000', label: 'Issuer Security Domain (compact)', doc: 'TS 102 226' },
{ tar: '123456', label: 'my applet', custom: true },
{ tar: '000000', label: 'ISD', doc: 'MSL 16 \u00b7 Keyset 1' },
{ tar: 'AF4D02', label: 'My applet' },
]);
assert.match(out, /value="000000" checked/);
assert.match(out, /title="TS 102 226"/);
assert.match(out, /Issuer Security Domain \(compact\)/);
assert.match(out, /value="123456" checked/);
assert.match(out, /tarProbeRemoveCustom\('123456'\)/, 'custom rows get a remove button');
assert.ok(!/tarProbeRemoveCustom\('000000'\)/.test(out), 'standard rows have none');
assert.match(out, /title="MSL 16/);
assert.match(out, /ISD/);
assert.match(out, /value="AF4D02" checked/);
assert.ok(!out.includes('tarProbeRemoveCustom'), 'no custom rows anymore');
// escaping
const evil = tarProbeRowsHtml([{ tar: 'AA<BB', label: '<i>x', custom: true }]);
const evil = tarProbeRowsHtml([{ tar: 'AA<BB', label: '<i>x' }]);
assert.ok(!evil.includes('AA<BB'));
assert.ok(!evil.includes('<i>x'));
});
test('custom TARs round-trip through localStorage (best effort)', () => {
fakeStorage();
assert.deepStrictEqual(tarProbeCustomLoad(), []);
tarProbeCustomSave([{ tar: '123456', label: 'mine' }, { tar: 'aabbcc' }]);
const back = tarProbeCustomLoad();
assert.deepStrictEqual(back, [{ tar: '123456', label: 'mine', custom: true },
{ tar: 'AABBCC', label: '', custom: true }]);
// garbage is dropped, never fatal
localStorage.setItem('simple_tar_probe', '{not json');
assert.deepStrictEqual(tarProbeCustomLoad(), []);
localStorage.setItem('simple_tar_probe', JSON.stringify([{ tar: 'zz' }, { tar: 'B0B0B0' }]));
assert.deepStrictEqual(tarProbeCustomLoad().map(e => e.tar), ['B0B0B0']);
});
test('tarProbeAllRows appends the custom TARs to the standard ones', () => {
fakeStorage();
tarProbeCustomSave([{ tar: '123456', label: 'mine' }]);
const rows = tarProbeAllRows();
assert.strictEqual(rows.length, TAR_PROBE_TARS.length + 1);
assert.strictEqual(rows[rows.length - 1].tar, '123456');
assert.strictEqual(rows[rows.length - 1].custom, true);
test('tarProbeAllRows lists the selected preset TARs (v3.26.0)', () => {
globalThis.cards = [{ name: 'C', tars: [
{ role: 'isd', tar: '000000', msl: '16', keyset: 1 },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' }] }];
globalThis.tarPresetIdx = () => 0;
assert.deepStrictEqual(tarProbeAllRows(), [
{ tar: '000000', label: 'ISD', doc: 'MSL 16 \u00b7 Keyset 1' },
{ tar: 'AF4D02', label: 'My applet', doc: 'MSL 0A' },
]);
// no preset: no rows
globalThis.tarPresetIdx = () => -1;
assert.deepStrictEqual(tarProbeAllRows(), []);
delete globalThis.tarPresetIdx;
});
test('tarProbeChecked reads the checked boxes in list order', () => {
@@ -141,21 +106,24 @@ test('the SCP80 tab wires the TAR probe pill', () => {
assert.match(html, /<div id="scp80-sub-tar" class="hidden">/);
assert.ok(html.includes("document.getElementById('scp80-sub-tar').classList.toggle('hidden', name !== 'tar');"));
assert.ok(html.includes("tar: 'tar-probe'"), 'the help anchor points at the new section');
// its own preset + keyset selectors and the warning banner
// its own preset selector and the warning banner; v3.26.0: the probe
// checks the preset's TAR list - no keyset selector, no custom TARs
assert.match(html, /id="tar-card-sel" onchange="tarApplyCard\(this.value\)"/);
assert.match(html, /id="tar-keyset-sel" onchange="tarKeysetChanged\(\)"/);
assert.match(html, /id="tar-preset-warning"/);
// the checklist, the add form, the APDU and the Execute button
assert.ok(!html.includes('id="tar-keyset-sel"'), 'the probe keyset selector is gone');
assert.ok(!html.includes('tarProbeAddCustom'), 'no arbitrary TARs anymore');
assert.match(html, /TARs to probe \(the preset's TAR list\)/);
// the checklist, the APDU and the Execute button
assert.match(html, /id="tar-list"/);
assert.match(html, /onclick="tarProbeSelectAll\(true\)"/);
assert.match(html, /onclick="tarProbeAddCustom\(\)"/);
assert.match(html, /id="tar-apdu"[^>]*value="00A40000023F00"/);
assert.match(html, /onclick="tarExecute\(\)"/);
assert.match(html, /id="tar-result"/);
// the run
// the run: the preset and the checked TARs travel - the server resolves
// each row's keyset
assert.ok(html.includes("pysimFetch('/api/tar-probe', body)"));
assert.ok(html.includes('tars: tars'));
assert.ok(html.includes("getRamSpParams('tar-card-sel')"));
assert.ok(!html.includes("getRamSpParams('tar-card-sel')"));
assert.ok(html.includes('if (data.final_cntr) ramSaveCntr(data.final_cntr);'));
// the RAM-era leftovers are gone
assert.ok(!html.includes('id="ram-tar-rows"'));