scp81: PSK by identity, scripts page, exact snapshots, SCP80 LOAD fit (v2.2.0)
Cards / SCP81:
- Cards is a top-level tab; presets gain PSK identity + key, HTTP-OTA
column, Edit/Update and a live PSK-map push into a running listener.
- SCP81 has Listener/Scripts pills; scripts are named local APDU lists
(Empty / Explore / Install from .cap / Delete templates), sent to the
server explicitly at start. The listener takes mode/host/port/script
only; PSK inputs and the .cap row are gone.
- Multi-PSK TLS listener: identity -> key lookup from the card presets
(POST /api/scp81/psk-map updates a running listener), unknown
identities log tls-psk-unknown and fail; handshake logs carry psk_match.
- Script engine: execution tracking (next/done/pending/results), a
resumed dialog sends only the unexecuted tail (unreported APDU is
resent), a fresh dialog restarts, listing continuation pages are
tracked separately (pending.pages/complete). Restart script button.
- POST /api/scp81/gen-install replaces the SCP81 ram-install queueing
(generation only; the .cap is never stored).
Profiler / snapshots:
- Snapshot comparison is always exact (mask checkboxes removed; the
first-4-bytes mask remains a profile-creation option).
- "matching records" line shows count + #record numbers.
- New Clone action: copy named "Copy of <profile>", opened in the editor.
- Matched-record count/numbers fix ("1 из 8 (#8)").
SCP80:
- Configurable / auto-fitted LOAD block size: each LOAD APDU encodes into
one SMS (pySim rejects secured packets above 140 octets, so a 240-byte
block could never be sent). Response reports the effective size and
clamps; encode failures are reported per step with the pySim message.
SW cache otaman-v161; docs/api.md, scp81-findings and help EN/RU updated.
Tests: 226 Python + 356 frontend.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// The extracted functions run in this module's scope; their free variables
|
||||
// (`cards`, `t`, ...) resolve to globals we stub here.
|
||||
global.t = (s) => s;
|
||||
eval(extractFunc(html, 'cardsPskMap'));
|
||||
eval(extractFunc(html, 'scriptsParseApdus'));
|
||||
eval(extractFunc(html, 'scp81DeleteApdus'));
|
||||
eval(extractFunc(html, 'scp81LogLine'));
|
||||
eval(extractFunc(html, 'scp81LogEntryHtml'));
|
||||
eval(extractFunc(html, 'scp81GroupResults'));
|
||||
eval(extractFunc(html, 'scp81ScriptStateText'));
|
||||
global.esc = (s) => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
test('explore template is the reference administration sequence', () => {
|
||||
const m = /const SCP81_EXPLORE_APDUS = \[(.*?)\];/s.exec(html);
|
||||
assert.ok(m, 'SCP81_EXPLORE_APDUS not found');
|
||||
const apdus = [...m[1].matchAll(/'([0-9A-F]+)'/g)].map(x => x[1]);
|
||||
assert.deepStrictEqual(apdus, ['80CAFF2100', '80F28002024F0000', '80CA008500',
|
||||
'80F24002024F0000', '80F22002024F0000', '80F21002024F0000']);
|
||||
for (const a of apdus) {
|
||||
assert.ok(/^[0-9A-F]+$/.test(a) && a.length % 2 === 0, a);
|
||||
}
|
||||
});
|
||||
|
||||
test('cardsPskMap keeps only cards with both identity and key', () => {
|
||||
global.cards = [
|
||||
{name: 'A', pskIdentity: 'id-1', pskKey: '00112233445566778899aabbccddeeff'},
|
||||
{name: 'B', pskIdentity: 'id-2'}, // no key
|
||||
{name: 'C', pskKey: '00112233'}, // no identity
|
||||
{name: 'D', pskIdentity: 'id-4', pskKey: 'AA BB CC'}, // spaces stripped
|
||||
{name: 'E', pskIdentity: 'id-5', pskKey: 'not-hex'},
|
||||
];
|
||||
assert.deepStrictEqual(cardsPskMap(), [
|
||||
{identity: 'id-1', psk_hex: '00112233445566778899aabbccddeeff'},
|
||||
{identity: 'id-4', psk_hex: 'AABBCC'},
|
||||
]);
|
||||
global.cards = [];
|
||||
assert.deepStrictEqual(cardsPskMap(), []);
|
||||
});
|
||||
|
||||
test('scriptsParseApdus accepts comments and whitespace, rejects bad lines', () => {
|
||||
assert.deepStrictEqual(
|
||||
scriptsParseApdus('80CAFF2100\n\n80F2 4002 024F 0000 # listing\n; note\n80E60200AB00'),
|
||||
{apdus: ['80CAFF2100', '80F24002024F0000', '80E60200AB00']});
|
||||
assert.strictEqual(scriptsParseApdus('80CAFF2100').error, undefined);
|
||||
assert.deepStrictEqual(scriptsParseApdus('ZZ'), {error: 'ZZ'});
|
||||
assert.deepStrictEqual(scriptsParseApdus('80CAF'), {error: '80CAF'});
|
||||
assert.deepStrictEqual(scriptsParseApdus('80CAFF2'), {error: '80CAFF2'});
|
||||
});
|
||||
|
||||
test('scp81DeleteApdus builds GP DELETE APDUs per AID', () => {
|
||||
assert.deepStrictEqual(scp81DeleteApdus(['A000000003000000'], '00'),
|
||||
['80E4000008A00000000300000000']);
|
||||
assert.deepStrictEqual(scp81DeleteApdus(['A000000003000000', 'A000000100'], '80'),
|
||||
['80E4800008A00000000300000000', '80E4800005A00000010000']);
|
||||
assert.deepStrictEqual(scp81DeleteApdus([], '00'), []);
|
||||
});
|
||||
|
||||
test('scp81LogEntryHtml marks matched and unknown PSK identities', () => {
|
||||
global.cards = [{name: 'Foobar SIM', pskIdentity: 'id-1'}];
|
||||
global.cardsPskName = (identity) =>
|
||||
(global.cards.find(c => c.pskIdentity === identity) || {}).name || '';
|
||||
const matched = scp81LogEntryHtml(
|
||||
{seq: 1, kind: 'tls-handshake', cipher: 'PSK-AES128-CBC-SHA256',
|
||||
identity: 'id-1', psk_match: true});
|
||||
assert.match(matched, /id=id-1/);
|
||||
assert.match(matched, /\[matched: Foobar SIM\]/);
|
||||
const unknown = scp81LogEntryHtml(
|
||||
{seq: 2, kind: 'tls-handshake', identity: 'who', psk_match: false});
|
||||
assert.match(unknown, /\[unknown identity\]/);
|
||||
const rejected = scp81LogEntryHtml({seq: 3, kind: 'tls-psk-unknown', identity: 'who'});
|
||||
assert.match(rejected, /\[unknown identity\]/);
|
||||
// non-handshake lines get no badge
|
||||
const plain = scp81LogEntryHtml({seq: 4, kind: 'script-send', index: 1, apdu: '80CAFF2100'});
|
||||
assert.doesNotMatch(plain, /\[\]/);
|
||||
});
|
||||
|
||||
test('scp81ScriptStateText separates script progress from listing pages', () => {
|
||||
// untouched / empty scripts show no state line
|
||||
assert.strictEqual(scp81ScriptStateText({kind: 'none', total: 0}), '');
|
||||
assert.strictEqual(scp81ScriptStateText({kind: 'Explore', total: 0}), '');
|
||||
// still executing the configured APDUs
|
||||
assert.strictEqual(
|
||||
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3], script: []}),
|
||||
'Explore: 4/6 executed');
|
||||
// a configured APDU is awaiting the card's report
|
||||
assert.strictEqual(
|
||||
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4],
|
||||
pending: {index: 9, pos: 5, page: false, apdu: '80F21002024F0000'}}),
|
||||
'Explore: 5/6 executed · waiting for card');
|
||||
// all script APDUs executed; only a listing page is in flight
|
||||
assert.strictEqual(
|
||||
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
|
||||
pending: {index: 17, pos: null, page: true, apdu: '80F21003024F0000'},
|
||||
pages: 11, complete: false}),
|
||||
'Explore: 6/6 executed · listing pages (11)…');
|
||||
// queued page, nothing sent yet
|
||||
assert.strictEqual(
|
||||
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
|
||||
pending: null, pages: 11, pages_queued: 1, complete: false}),
|
||||
'Explore: 6/6 executed · listing pages (11)…');
|
||||
// everything drained
|
||||
assert.strictEqual(
|
||||
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
|
||||
pending: null, pages: 11, pages_queued: 0, complete: true}),
|
||||
'Explore: 6/6 executed · completed');
|
||||
});
|
||||
|
||||
test('scp81GroupResults groups pages by originating command', () => {
|
||||
const groups = scp81GroupResults({results: [
|
||||
{index: 1, pos: 0, page: false, apdu: '80F24002024F0000', rapdu: 'E3', sw: 'CAFE'},
|
||||
{index: 2, pos: null, page: true, apdu: '80F24003024F0000', rapdu: 'E3', sw: '9000'},
|
||||
{index: 3, pos: 1, page: false, apdu: '80CAFF2100', rapdu: 'FF21', sw: '9000'},
|
||||
]});
|
||||
assert.strictEqual(groups.length, 2);
|
||||
assert.strictEqual(groups[0].key, '80F240');
|
||||
assert.strictEqual(groups[0].results.length, 2); // origin + continuation page
|
||||
assert.strictEqual(groups[1].key, '80CAFF');
|
||||
});
|
||||
Reference in New Issue
Block a user