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).
This commit is contained in:
@@ -23,7 +23,7 @@ function extractFunc(src, name) {
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spKeysetOptionsHtml', 'esc', 'escHtml']) {
|
||||
'spKeysetOptionsHtml', 'spCounterTracked', 'esc', 'escHtml']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'function t(s){return s;}\n';
|
||||
@@ -120,3 +120,14 @@ test('the Cards form wires the keyset editor', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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 ['esc', 'escHtml', 'ramTarVerdictLabel', 'ramTarProbeHtml']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'function t(s){return s;}\n';
|
||||
eval(code);
|
||||
|
||||
const DATA = {
|
||||
success: true,
|
||||
registered: 2,
|
||||
total: 4,
|
||||
final_cntr: '0000000187',
|
||||
results: [
|
||||
{ tar: '000000', label: 'Issuer Security Domain (compact)', verdict: 'registered',
|
||||
sw: '9000', por_status: 'por_ok', por_sw: '6D00', por_data: '' },
|
||||
{ tar: 'B00000', label: 'UICC shared file system RFM (compact)', verdict: 'registered',
|
||||
sw: '9000', por_status: 'por_ok', por_sw: '6B00', por_data: '3F00' },
|
||||
{ tar: 'B00001', label: 'ADF RFM (compact)', verdict: 'no_por',
|
||||
sw: '9000', por_status: 'no_por', por_sw: null, por_data: null },
|
||||
{ tar: 'B00200', label: 'RFU (control)', verdict: 'refused',
|
||||
sw: '6200', por_status: 'envelope_error', por_sw: null, por_data: null },
|
||||
],
|
||||
};
|
||||
|
||||
test('ramTarVerdictLabel names every verdict', () => {
|
||||
assert.strictEqual(ramTarVerdictLabel('registered'), 'registered');
|
||||
assert.strictEqual(ramTarVerdictLabel('no_answer'), 'accepted, no answer');
|
||||
assert.strictEqual(ramTarVerdictLabel('no_por'), 'accepted, no PoR');
|
||||
assert.strictEqual(ramTarVerdictLabel('refused'), 'refused');
|
||||
assert.strictEqual(ramTarVerdictLabel('something'), 'something');
|
||||
});
|
||||
|
||||
test('ramTarProbeHtml renders the summary and one row per TAR', () => {
|
||||
const out = ramTarProbeHtml(DATA);
|
||||
assert.match(out, /Registered applications: <b>2 \/ 4<\/b>/);
|
||||
assert.match(out, /counter 0000000187/);
|
||||
for (const r of DATA.results) {
|
||||
assert.ok(out.includes(r.tar), r.tar);
|
||||
}
|
||||
assert.ok(out.includes('6D00') && out.includes('6B00'), 'the application SWs are shown');
|
||||
assert.ok(out.includes('6200'), 'the refused ENVELOPE SW is shown');
|
||||
// the verdict colours: registered = green, no_por = amber, refused = red
|
||||
assert.match(out, /text-emerald-700 dark:text-emerald-400">registered/);
|
||||
assert.match(out, /text-amber-600 dark:text-amber-400">accepted, no PoR/);
|
||||
assert.match(out, /text-red-600 dark:text-red-400">refused/);
|
||||
// an empty payload renders nothing
|
||||
assert.strictEqual(ramTarProbeHtml(null), '');
|
||||
assert.strictEqual(ramTarProbeHtml({}), '');
|
||||
});
|
||||
|
||||
test('the probe escapes what the card returned', () => {
|
||||
const out = ramTarProbeHtml({ results: [
|
||||
{ tar: '<b>x', label: '<i>y', verdict: 'registered', sw: '<', por_sw: '>', por_data: '&' },
|
||||
], registered: 1, total: 1 });
|
||||
assert.ok(!out.includes('<b>x'), 'the TAR must be escaped');
|
||||
assert.ok(!out.includes('<i>y'), 'the label must be escaped');
|
||||
});
|
||||
|
||||
test('the RAM form wires the TAR probe', () => {
|
||||
assert.match(html, /<option value="tar-probe" data-l10n="Probe TARs \(registered OTA applications\)">/);
|
||||
assert.match(html, /id="ram-tar-rows" class="hidden mb-3"/);
|
||||
assert.match(html, /id="ram-tar-apdu"[^>]*value="00A40000023F00"/);
|
||||
assert.match(html, /id="ram-tar-list"/);
|
||||
assert.ok(html.includes("setHidden('ram-tar-rows', !isProbe);"));
|
||||
assert.ok(html.includes("else if (op === 'tar-probe') await ramTarProbe(sp);"));
|
||||
assert.ok(html.includes("pysimFetch('/api/tar-probe', body)"));
|
||||
assert.ok(html.includes("if (data.final_cntr) ramSaveCntr(data.final_cntr);"));
|
||||
});
|
||||
Reference in New Issue
Block a user