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).
92 lines
3.7 KiB
JavaScript
92 lines
3.7 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 ['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);"));
|
|
});
|