Add node:test suite for vendored C-APDU parser and SCP80 decryption
frontend/tests/ (otaman-style function extraction from index.html):
- apdu_parse.test.js: PARSE_INS registry, parseOneApdu (SELECT by FID,
READ BINARY Le-form, UPDATE BINARY, GET DATA, lowercase input),
parseCompactApdus (concatenated implied-CLA stream, graceful
unrecognized-byte degradation).
- scp80_decrypt.test.js: decryptSecured known-answer tests with
node-crypto generated ciphertexts — 3DES-CBC 2/3-key (real CNTR=200
revealed), AES-CBC 128, single DES, wrong-key padding guard, key
length validation, unsupported-algorithm rejection.
- helpers.js: brace-matched function/const/section extraction from
the single-file frontend; package.json ("test": "node --test").
Fix AES-CBC decryption path found by the new tests: SubtleCrypto CBC
decrypt always validates PKCS#7, which SCP80 PCNTR-style padding
violates. Workaround: append a synthetic block C* = E(C_last XOR
padBlock) so the validator sees a full 0x10 padding block, decrypt,
then discard the synthetic plaintext. Validated: CNTR=200 case
decrypts correctly.
Version 1.20.1, SW cache simtrace-analyser-v29.
This commit is contained in:
1 parent
6da5acca21
commit
6d7e87b52a
9 files changed
+291
-8
No files matched your search
+18
-4
@@ -913,9 +913,23 @@ async function decryptSecured(sec, keyHex) {
|
||||
if (nibble === 0x02) { // AES-CBC
|
||||
const key = hexToBytes(keyHex);
|
||||
if (![16, 24, 32].includes(key.length)) throw new Error('AES key must be 16/24/32 bytes');
|
||||
const k = await crypto.subtle.importKey('raw', new Uint8Array(key), 'AES-CBC', false, ['decrypt']);
|
||||
const buf = await crypto.subtle.decrypt({name: 'AES-CBC', iv: new Uint8Array(16)}, k, new Uint8Array(ct));
|
||||
pt = Array.from(new Uint8Array(buf));
|
||||
if (ct.length % 16) throw new Error('cipher block not AES-aligned');
|
||||
// WebCrypto CBC decrypt always validates PKCS#7, which SCP80
|
||||
// padding (PCNTR) violates. Trick: append a synthetic block
|
||||
// C* = E(C_last XOR padBlock) — decrypting it yields a valid
|
||||
// full-block PKCS padding (16 × 0x10), so validation passes and
|
||||
// the real plaintext is recovered; the synthetic block is discarded.
|
||||
const k = await crypto.subtle.importKey('raw', new Uint8Array(key), 'AES-CBC', false, ['encrypt', 'decrypt']);
|
||||
const padBlock = new Array(16).fill(0x10);
|
||||
const last = ct.slice(ct.length - 16);
|
||||
const target = _xorArr(last, padBlock);
|
||||
const eRaw = await crypto.subtle.encrypt(
|
||||
{name: 'AES-CBC', iv: new Uint8Array(16)}, k, new Uint8Array(target));
|
||||
const cStar = Array.from(new Uint8Array(eRaw)).slice(0, 16);
|
||||
const buf = await crypto.subtle.decrypt(
|
||||
{name: 'AES-CBC', iv: new Uint8Array(16)}, k,
|
||||
new Uint8Array(ct.concat(cStar)));
|
||||
pt = Array.from(new Uint8Array(buf)).slice(0, ct.length);
|
||||
} else if (nibble === 0x05 || nibble === 0x09) { // 3DES-CBC 2/3 keys
|
||||
const key = hexToBytes(keyHex);
|
||||
if (nibble === 0x05 && key.length !== 16 && key.length !== 24)
|
||||
@@ -2451,7 +2465,7 @@ function onFilterChange() {
|
||||
}
|
||||
|
||||
// ──────────────────── Versioning ────────────────────
|
||||
const PWA_VERSION = '1.20.0';
|
||||
const PWA_VERSION = '1.20.1';
|
||||
const MIN_SERVER_VERSION = '1.0.0';
|
||||
|
||||
function parseVersion(v) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "simtrace-analyser-frontend",
|
||||
"private": true,
|
||||
"description": "Frontend tests for the vendored C-APDU parser and SCP80 decryption (node --test extracts the code from index.html)",
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simtrace-analyser-v28';
|
||||
const CACHE = 'simtrace-analyser-v29';
|
||||
|
||||
const PRECACHE = [
|
||||
'index.html',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { loadSrc, extractFunc, extractConst } = require('./helpers.js');
|
||||
|
||||
const src = loadSrc();
|
||||
// Single direct eval with var substitutions: function/var declarations
|
||||
// hoist into this module's scope (const would collide with itself).
|
||||
eval(
|
||||
extractConst(src, 'PARSE_INS').replace(/^const /m, 'var ') + '\n' +
|
||||
extractFunc(src, 'describeP1P2') + '\n' +
|
||||
extractFunc(src, 'describeInstallDataLv') + '\n' +
|
||||
extractFunc(src, 'parseOneApdu') + '\n' +
|
||||
extractFunc(src, 'parseCompactApdus')
|
||||
);
|
||||
|
||||
function child(tree, label) {
|
||||
return (tree.children || []).find(k => k.label === label);
|
||||
}
|
||||
|
||||
test('PARSE_INS registry: SIM/USIM + GP commands', () => {
|
||||
assert.equal(PARSE_INS.A4.name, 'SELECT');
|
||||
assert.equal(PARSE_INS.B0.name, 'READ BINARY');
|
||||
assert.equal(PARSE_INS.B0.data, 'le');
|
||||
assert.equal(PARSE_INS.D6.name, 'UPDATE BINARY');
|
||||
assert.equal(PARSE_INS.D6.data, 'lc');
|
||||
assert.equal(PARSE_INS.E6.name, 'INSTALL');
|
||||
});
|
||||
|
||||
test('parseOneApdu: SELECT by FID (case 3)', () => {
|
||||
const t = parseOneApdu('00A40000023F00');
|
||||
assert.equal(child(t, 'INS').desc, 'SELECT');
|
||||
assert.equal(child(t, 'P1').desc, 'by FID');
|
||||
const data = child(t, 'Data');
|
||||
assert.equal(data.hex, '3F00');
|
||||
const lc = child(t, 'Lc');
|
||||
assert.equal(lc.desc, 'Data length: 2');
|
||||
});
|
||||
|
||||
test('parseOneApdu: READ BINARY (case 2, Le)', () => {
|
||||
const t = parseOneApdu('00B0000009');
|
||||
assert.equal(child(t, 'INS').desc, 'READ BINARY');
|
||||
const le = child(t, 'Le');
|
||||
assert.equal(le.hex, '09');
|
||||
assert.match(le.desc, /Expected length: 9/);
|
||||
assert.equal(child(t, 'Data'), undefined);
|
||||
});
|
||||
|
||||
test('parseOneApdu: UPDATE BINARY (case 3)', () => {
|
||||
const t = parseOneApdu('00D600000CAABBCCDD1122334455667788');
|
||||
const data = child(t, 'Data');
|
||||
assert.equal(data.hex, 'AABBCCDD1122334455667788');
|
||||
const lc = child(t, 'Lc');
|
||||
assert.equal(lc.desc, 'Data length: 12');
|
||||
});
|
||||
|
||||
test('parseOneApdu: GET DATA (P1P2 = tag, P3 = Le)', () => {
|
||||
const t = parseOneApdu('80CA9F1700');
|
||||
assert.equal(child(t, 'INS').desc, 'GET DATA');
|
||||
const le = child(t, 'Le');
|
||||
assert.equal(le.hex, '00');
|
||||
});
|
||||
|
||||
test('parseOneApdu: lowercase input normalized', () => {
|
||||
const t = parseOneApdu('00a40000023f00');
|
||||
assert.equal(child(t, 'INS').desc, 'SELECT');
|
||||
assert.equal(child(t, 'Data').hex, '3F00');
|
||||
});
|
||||
|
||||
test('parseCompactApdus: concatenated stream with implied CLA', () => {
|
||||
// SELECT 3F00 | SELECT 2FE2 (implied CLA) | UPDATE BINARY 12B (implied CLA) | trailing Le
|
||||
const stream = '00A40000023F00' + 'A40000022FE2' +
|
||||
'D600000CAABBCCDD1122334455667788' + '99';
|
||||
const apdus = parseCompactApdus(stream);
|
||||
const named = apdus.filter(a => a.label !== 'Unknown');
|
||||
assert.equal(named.length, 4);
|
||||
assert.equal(child(named[0], 'INS').desc, 'SELECT');
|
||||
assert.equal(named[1].label, 'APDU (implied CLA)');
|
||||
assert.equal(child(named[1], 'INS').desc, 'SELECT');
|
||||
assert.equal(named[2].label, 'APDU (implied CLA)');
|
||||
assert.equal(child(named[2], 'INS').desc, 'UPDATE BINARY');
|
||||
assert.equal(named[3].label, 'Le');
|
||||
});
|
||||
|
||||
test('parseCompactApdus: unrecognized bytes degrade gracefully', () => {
|
||||
// Trailing 2 hex chars are interpreted as Le by design — no crash,
|
||||
// and the leading unrecognized bytes come back as Unknown entries.
|
||||
const apdus = parseCompactApdus('FFFFFF');
|
||||
assert.equal(apdus.length, 3);
|
||||
assert.equal(apdus[0].label, 'Unknown');
|
||||
assert.equal(apdus[1].label, 'Unknown');
|
||||
assert.equal(apdus[2].label, 'Le');
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// Shared helpers to extract functions/constants from the single-file
|
||||
// frontend (index.html) so they can be unit-tested with node:test.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const INDEX_HTML = path.join(__dirname, '..', 'index.html');
|
||||
|
||||
function loadSrc() {
|
||||
return fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
}
|
||||
|
||||
// Extract a `function name(...) { ... }` by brace matching (includes a
|
||||
// preceding `async` modifier when present).
|
||||
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 start = m.index;
|
||||
if (src.slice(Math.max(0, start - 6), start) === 'async ') start -= 6;
|
||||
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(start, i + 1);
|
||||
}
|
||||
|
||||
// Extract a `const NAME = { ... };` block by brace matching.
|
||||
function extractConst(src, name) {
|
||||
const re = new RegExp('const\\s+' + name + '\\s*=\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('const ' + 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) + ';';
|
||||
}
|
||||
|
||||
// Extract a `// marker ... ` section up to the next marker comment.
|
||||
function extractSection(src, startMarker, endMarker) {
|
||||
const i = src.indexOf(startMarker);
|
||||
if (i < 0) throw new Error('section not found: ' + startMarker);
|
||||
const j = endMarker ? src.indexOf(endMarker, i) : -1;
|
||||
return src.slice(i, j > i ? j : undefined);
|
||||
}
|
||||
|
||||
module.exports = { loadSrc, extractFunc, extractConst, extractSection, INDEX_HTML };
|
||||
@@ -0,0 +1,112 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const crypto = require('node:crypto');
|
||||
const { loadSrc, extractFunc, extractSection } = require('./helpers.js');
|
||||
|
||||
const src = loadSrc();
|
||||
|
||||
// Assemble the client-side decryption machinery: DES/3DES core section,
|
||||
// hex helpers, and the decryptSecured wrapper — all extracted verbatim
|
||||
// from index.html so the tests exercise exactly what ships. Single
|
||||
// direct eval: function declarations hoist into this module's scope.
|
||||
const desSection = extractSection(src, '// ── DES / 3DES core', '// Decrypt a ciphered secured packet');
|
||||
eval(
|
||||
extractFunc(src, 'hexToBytes') + '\n' +
|
||||
extractFunc(src, 'bytesToHex') + '\n' +
|
||||
desSection + '\n' +
|
||||
extractFunc(src, 'decryptSecured')
|
||||
);
|
||||
|
||||
const b2h = b => Buffer.from(b).toString('hex').toUpperCase();
|
||||
|
||||
function encryptBlock(algo, key, iv, plaintext) {
|
||||
// node/OpenSSL 3 has no single DES — encrypt the reference via
|
||||
// des-ede3-cbc with K1=K2=K3 (≡ single DES). node's des-ede3-cbc
|
||||
// also requires a 24-byte key — expand 2-key (16B) to K1,K2,K1.
|
||||
let k;
|
||||
if (algo === 'des-cbc') k = Buffer.concat([key, key, key]);
|
||||
else if (algo === 'des-ede3-cbc' && key.length === 16) k = Buffer.concat([key, key.slice(0, 8)]);
|
||||
else k = key;
|
||||
const c = crypto.createCipheriv(algo === 'des-cbc' ? 'des-ede3-cbc' : algo, k, iv);
|
||||
c.setAutoPadding(false); // SCP80 pads via PCNTR, not PKCS
|
||||
return Buffer.concat([c.update(plaintext), c.final()]);
|
||||
}
|
||||
|
||||
// Build a fake secured-packet decode: cipher_block = 3DES/AES-CBC
|
||||
// ciphertext over [CNTR(5) ‖ PCNTR(1) ‖ RC(8) ‖ data ‖ padding].
|
||||
function makeSecured(algo, keyHex, cntrInt, dataHex) {
|
||||
const key = Buffer.from(keyHex, 'hex');
|
||||
const rc = Buffer.from('1122334455667788', 'hex');
|
||||
const data = Buffer.from(dataHex, 'hex');
|
||||
const block = algo === 'aes-128-cbc' ? 16 : 8;
|
||||
const iv = Buffer.alloc(block);
|
||||
const bodyLen = 5 + 1 + rc.length + data.length;
|
||||
const pad = block - (bodyLen % block);
|
||||
const pt = Buffer.concat([
|
||||
Buffer.from(cntrInt.toString(16).padStart(10, '0'), 'hex'),
|
||||
Buffer.from([pad]),
|
||||
rc,
|
||||
data,
|
||||
Buffer.alloc(pad),
|
||||
]);
|
||||
const ct = encryptBlock(algo, key, iv, pt);
|
||||
return {
|
||||
kic_raw: algo === 'aes-128-cbc' ? 0x02 : algo === 'des-cbc' ? 0x01 : 0x15,
|
||||
cipher_block: ct.toString('hex').toUpperCase(),
|
||||
rc_cc_ds: rc.toString('hex').toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
test('decryptSecured: 3DES-CBC (2 keys) reveals real CNTR/PCNTR/RC/data', async () => {
|
||||
const keyHex = '0123456789ABCDEFFEDCBA9876543210';
|
||||
const sec = makeSecured('des-ede3-cbc', keyHex, 200, 'DEADBEEFCAFE');
|
||||
const r = await decryptSecured(sec, keyHex);
|
||||
assert.equal(b2h(r.cntr), '00000000C8'); // the real counter, 200
|
||||
assert.equal(r.pcntr, 4); // padding octets
|
||||
assert.equal(b2h(r.rc), '1122334455667788');
|
||||
assert.equal(b2h(r.data), 'DEADBEEFCAFE');
|
||||
});
|
||||
|
||||
test('decryptSecured: 3DES-CBC (3 keys, 24-byte key)', async () => {
|
||||
const keyHex = '0123456789ABCDEFFEDCBA98765432100123456789ABCDEF';
|
||||
const sec = makeSecured('des-ede3-cbc', keyHex, 7, 'CAFE');
|
||||
const r = await decryptSecured(sec, keyHex);
|
||||
assert.equal(parseInt(b2h(r.cntr), 16), 7);
|
||||
assert.equal(b2h(r.data), 'CAFE');
|
||||
});
|
||||
|
||||
test('decryptSecured: AES-CBC (128-bit key)', async () => {
|
||||
const keyHex = '000102030405060708090A0B0C0D0E0F';
|
||||
const sec = makeSecured('aes-128-cbc', keyHex, 1234, 'BEEF');
|
||||
const r = await decryptSecured(sec, keyHex);
|
||||
assert.equal(parseInt(b2h(r.cntr), 16), 1234);
|
||||
assert.equal(b2h(r.data), 'BEEF');
|
||||
});
|
||||
|
||||
test('decryptSecured: wrong key detected via padding guard', async () => {
|
||||
const sec = makeSecured('des-ede3-cbc', '0123456789ABCDEFFEDCBA9876543210', 200, 'DEADBEEFCAFE');
|
||||
await assert.rejects(
|
||||
() => decryptSecured(sec, 'FFFFFFFFFEDCBA987654321001234567'),
|
||||
/wrong key|invalid padding|too short/
|
||||
);
|
||||
});
|
||||
|
||||
test('decryptSecured: key length validation', async () => {
|
||||
const sec = makeSecured('des-ede3-cbc', '0123456789ABCDEFFEDCBA9876543210', 200, 'DEADBEEFCAFE');
|
||||
await assert.rejects(() => decryptSecured(sec, '0102'), /key must be/);
|
||||
});
|
||||
|
||||
test('decryptSecured: unsupported algorithm nibble', async () => {
|
||||
const sec = makeSecured('des-ede3-cbc', '0123456789ABCDEFFEDCBA9876543210', 200, 'DEADBEEFCAFE');
|
||||
sec.kic_raw = 0x00; // 'implicit' — cannot pick a cipher
|
||||
await assert.rejects(() => decryptSecured(sec, '0123456789ABCDEFFEDCBA9876543210'),
|
||||
/not supported/);
|
||||
});
|
||||
|
||||
test('decryptSecured: DES-CBC single key (deprecated KIc 01)', async () => {
|
||||
const keyHex = '133457799BBCDFF1';
|
||||
const sec = makeSecured('des-cbc', keyHex, 42, '0123');
|
||||
const r = await decryptSecured(sec, keyHex);
|
||||
assert.equal(parseInt(b2h(r.cntr), 16), 42);
|
||||
assert.equal(b2h(r.data), '0123');
|
||||
});
|
||||
Reference in new issue
Block a user