6eeb8dacb3
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).
134 lines
5.5 KiB
JavaScript
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);
|
|
});
|