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
+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"'));