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:
catarrh committed 2026-08-24 22:34:23 +03:00
1 parent 6da5acca21
commit 6d7e87b52a
9 files changed
+291 -8

No files matched your search

+18 -4
View File
@@ -913,9 +913,23 @@ async function decryptSecured(sec, keyHex) {
if (nibble === 0x02) { // AES-CBC if (nibble === 0x02) { // AES-CBC
const key = hexToBytes(keyHex); const key = hexToBytes(keyHex);
if (![16, 24, 32].includes(key.length)) throw new Error('AES key must be 16/24/32 bytes'); 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']); if (ct.length % 16) throw new Error('cipher block not AES-aligned');
const buf = await crypto.subtle.decrypt({name: 'AES-CBC', iv: new Uint8Array(16)}, k, new Uint8Array(ct)); // WebCrypto CBC decrypt always validates PKCS#7, which SCP80
pt = Array.from(new Uint8Array(buf)); // 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 } else if (nibble === 0x05 || nibble === 0x09) { // 3DES-CBC 2/3 keys
const key = hexToBytes(keyHex); const key = hexToBytes(keyHex);
if (nibble === 0x05 && key.length !== 16 && key.length !== 24) if (nibble === 0x05 && key.length !== 16 && key.length !== 24)
@@ -2451,7 +2465,7 @@ function onFilterChange() {
} }
// ──────────────────── Versioning ──────────────────── // ──────────────────── Versioning ────────────────────
const PWA_VERSION = '1.20.0'; const PWA_VERSION = '1.20.1';
const MIN_SERVER_VERSION = '1.0.0'; const MIN_SERVER_VERSION = '1.0.0';
function parseVersion(v) { function parseVersion(v) {
+8
View File
@@ -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
View File
@@ -1,4 +1,4 @@
const CACHE = 'simtrace-analyser-v28'; const CACHE = 'simtrace-analyser-v29';
const PRECACHE = [ const PRECACHE = [
'index.html', 'index.html',
+92
View File
@@ -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');
});
+57
View File
@@ -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 };
+112
View File
@@ -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');
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project] [project]
name = "simtrace2-pysniff" name = "simtrace2-pysniff"
version = "1.20.0" version = "1.20.1"
description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)" description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)"
readme = {file = "README.md", content-type = "text/markdown"} readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "GPL-2.0-or-later"} license = {text = "GPL-2.0-or-later"}
+1 -1
View File
@@ -6,7 +6,7 @@ from .gsmtap import GsmtapSender, GsmtapReceiver
from .pcap import PcapWriter from .pcap import PcapWriter
from .dump import FileDumper, format_message from .dump import FileDumper, format_message
__version__ = '1.20.0' __version__ = '1.20.1'
__all__ = [ __all__ = [
'SniffSession', 'SniffSession',
'DeviceDisconnected', 'DeviceDisconnected',
+1 -1
View File
@@ -197,7 +197,7 @@ class TestImportEndpoint(unittest.TestCase):
class TestVersion(unittest.TestCase): class TestVersion(unittest.TestCase):
def test_package_version(self): def test_package_version(self):
from simtrace2_pysniff import __version__ from simtrace2_pysniff import __version__
self.assertEqual(__version__, '1.20.0') self.assertEqual(__version__, '1.20.1')
def test_status_includes_version(self): def test_status_includes_version(self):
import os import os