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
|
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) {
|
||||||
|
|||||||
@@ -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 = [
|
const PRECACHE = [
|
||||||
'index.html',
|
'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');
|
||||||
|
});
|
||||||
+1
-1
@@ -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"}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user