Files
simple/frontend/tests/keysets.test.js
T
catarrh 6eeb8dacb3 feat: TAR probe + the SPI1 counter-tracking rule; keyless packets build again (v3.8.0)
Three things learned from probing a live card, plus the capability they enabled:

1. Counter tracking follows SPI1.b5b4 (TS 102 225 5.1.1): with 00 the counter
   is "present, ignored, never updated", so a packet without a counter check
   must leave every counter untouched.  _counter_tracked() now guards
   /api/send-ota (no final_cntr, no persistence, a log line), the RAM step
   sender and the test runner; _preset_counter_persist() requires a non-zero
   key version (the "single keyset" fallback could advance a keyset spuriously
   for a keyless send).  The PWA advances the form counter only when the server
   reports final_cntr and says "counter not tracked" otherwise.

2. The spec's no-security form builds again: _ota_keyset() refused the valid
   KIc/KID '00' (TS 102 225 A.2: '00' is legal with SPI1.b3=0 for KIc and
   SPI1.b2b1=00 for KID) because pySim wants an algorithm.  An unknown nibble
   is now refused only when the algorithm is actually used (ciphering / a
   ciphered PoR for KIc, RC/CC/DS / a MAC'd PoR for KID) and 'implicit' codes
   the nibble as 0 otherwise - POST /api/send-ota {apdu, spi1:"00", kic:"00",
   kid:"00"} now builds 0D00010000B0000000000000010000A40000, the exact packet
   the card accepted (a fake 15/15 was refused with 6200).  A no-security PoR
   is decodable again too, and a requested-but-missing PoR sets por_missing
   on the plain path as well.

3. TAR probe (POST /api/tar-probe, RAM op "Probe TARs"): one secured packet per
   TAR - the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1
   (TAR_PROBE_TARS), or a custom list - carrying a harmless C-APDU (SELECT MF)
   with the preset's keyset and SPI 16/01.  Each TAR is classified
   (registered / no_answer / no_por / refused) from the ENVELOPE verdict and
   the application's PoR, counters advance and persist normally, and the run is
   refused when SPI1 has no counter check.  Live on a card: 000000, B00000 and
   B00001 registered (compact-format applications, answering 6D00 / 6B00),
   the expanded-format TARs, USAT, multiplexing and CASD refused with 6200 -
   with the keyless form the same card accepted every TAR and answered no PoR.

Docs (api.md, help EN/RU, AGENTS), sw cache simple-v337.  Tests: 618 Python
(no-security build, the algorithm-only-when-used rule, the tracking rule, the
persist guard, the probe classification) and 736 Node (the probe renderer and
wiring, spCounterTracked).
2026-09-30 21:17:59 +03:00

134 lines
5.5 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = '';
for (const fn of ['spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'spCounterTracked', 'esc', 'escHtml']) {
code += extractFunc(html, fn) + '\n';
}
code += 'function t(s){return s;}\n';
eval(code);
const PRESET = {
name: 'Card A',
keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' },
{ kic: '29', kid: '29', kicKey: 'CC', kidKey: 'DD', cntr: '0000000005' },
],
};
test('spKeysetKvnOf reads the key version from the high nibble', () => {
assert.strictEqual(spKeysetKvnOf('15'), 1);
assert.strictEqual(spKeysetKvnOf('29'), 2);
assert.strictEqual(spKeysetKvnOf('3A'), 3);
assert.strictEqual(spKeysetKvnOf('05'), 0);
assert.strictEqual(spKeysetKvnOf('ff'), 15);
// not a byte: no key version
assert.strictEqual(spKeysetKvnOf(''), null);
assert.strictEqual(spKeysetKvnOf('5'), null);
assert.strictEqual(spKeysetKvnOf('ZZ'), null);
assert.strictEqual(spKeysetKvnOf(null), null);
});
test('spKeysetList converts a v3.8.0 flat preset into one keyset', () => {
assert.deepStrictEqual(spKeysetList({ kic: '15', kid: '15', kicKey: 'A', kidKey: 'B',
cntr: '0000000001' }), [{ kic: '15', kid: '15', kicKey: 'A', kidKey: 'B',
cntr: '0000000001' }]);
assert.deepStrictEqual(spKeysetList(PRESET).length, 2);
assert.deepStrictEqual(spKeysetList(null), []);
assert.deepStrictEqual(spKeysetList({}), []);
});
test('spKeysetFor finds a keyset by key version', () => {
assert.strictEqual(spKeysetFor(PRESET, 1).cntr, '0000000001');
assert.strictEqual(spKeysetFor(PRESET, 2).cntr, '0000000005');
assert.strictEqual(spKeysetFor(PRESET, 3), null);
assert.strictEqual(spKeysetFor(null, 1), null);
});
test('spKeysetCheck enforces the key-version rules', () => {
// a defined key version passes
assert.strictEqual(spKeysetCheck(PRESET, '15', '15'), '');
assert.strictEqual(spKeysetCheck(PRESET, '29', '29'), '');
// '00'/'00' means no security: no keyset needed (TS 102 225 A.2)
assert.strictEqual(spKeysetCheck(PRESET, '00', '00'), '');
// ... and one zero byte with a defined version passes too
assert.strictEqual(spKeysetCheck(PRESET, '15', '00'), '');
// KIc/KID versions must agree when both are non-zero (A.2)
assert.match(spKeysetCheck(PRESET, '15', '25'), /same key version/);
// the key version must be defined in the preset
assert.match(spKeysetCheck(PRESET, '35', '35'), /3 .*not defined/);
// one zero byte: the other byte's version applies (keyset 2 is defined)
assert.strictEqual(spKeysetCheck(PRESET, '00', '25'), '');
assert.match(spKeysetCheck(PRESET, '00', '35'), /3 .*not defined/);
// without a preset only the byte rules apply (hand-entered keys)
assert.strictEqual(spKeysetCheck(null, '35', '35'), '');
assert.match(spKeysetCheck(null, '15', '25'), /15 \/ 25/);
// malformed bytes are named
assert.match(spKeysetCheck(PRESET, '5', '15'), /KIc/);
assert.match(spKeysetCheck(PRESET, '15', 'ZZ'), /KID/);
});
test('spKeysetOptionsHtml lists the keysets and marks an undefined version', () => {
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 key version in the form and no keysets at all
assert.match(spKeysetOptionsHtml({}, '', ''), /no keyset/);
});
test('the Cards form wires the keyset editor', () => {
assert.match(html, /id="cards-keysets"/);
assert.match(html, /onclick="cardsKeysetAdd\(\)"/);
assert.match(html, /oninput="cardsKeysetKvnUpdate\(/);
assert.match(html, /data-l10n="Add keyset"/);
// 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\(\)"/);
// the packet actions run the guard
assert.ok(html.includes('const ksErr = spKeysetGuard();'));
assert.ok(html.includes('const ksErr = ramKeysetGuard();'));
});
test('spCounterTracked follows SPI1 b5b4 (TS 102 225 5.1.1)', () => {
// b5b4 = 00: "present, ignored, never updated" - no counter to track
for (const spi1 of ['00', '01', '06', '']) {
assert.strictEqual(spCounterTracked(spi1), false, spi1);
}
for (const spi1 of ['08', '10', '16', '18', '1E']) {
assert.strictEqual(spCounterTracked(spi1), true, spi1);
}
assert.strictEqual(spCounterTracked('zz'), false);
});