diff --git a/frontend/index.html b/frontend/index.html
index 76b9b94..3a7a223 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -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) {
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..1fad462
--- /dev/null
+++ b/frontend/package.json
@@ -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"
+ }
+}
diff --git a/frontend/sw.js b/frontend/sw.js
index b3def48..a11734f 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simtrace-analyser-v28';
+const CACHE = 'simtrace-analyser-v29';
const PRECACHE = [
'index.html',
diff --git a/frontend/tests/apdu_parse.test.js b/frontend/tests/apdu_parse.test.js
new file mode 100644
index 0000000..474307f
--- /dev/null
+++ b/frontend/tests/apdu_parse.test.js
@@ -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');
+});
diff --git a/frontend/tests/helpers.js b/frontend/tests/helpers.js
new file mode 100644
index 0000000..90d18a8
--- /dev/null
+++ b/frontend/tests/helpers.js
@@ -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 };
diff --git a/frontend/tests/scp80_decrypt.test.js b/frontend/tests/scp80_decrypt.test.js
new file mode 100644
index 0000000..aa7ce81
--- /dev/null
+++ b/frontend/tests/scp80_decrypt.test.js
@@ -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');
+});
diff --git a/pyproject.toml b/pyproject.toml
index 7a380d2..02a2500 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "simtrace2-pysniff"
-version = "1.20.0"
+version = "1.20.1"
description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)"
readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "GPL-2.0-or-later"}
diff --git a/simtrace2_pysniff/__init__.py b/simtrace2_pysniff/__init__.py
index a0a4713..61651b8 100644
--- a/simtrace2_pysniff/__init__.py
+++ b/simtrace2_pysniff/__init__.py
@@ -6,7 +6,7 @@ from .gsmtap import GsmtapSender, GsmtapReceiver
from .pcap import PcapWriter
from .dump import FileDumper, format_message
-__version__ = '1.20.0'
+__version__ = '1.20.1'
__all__ = [
'SniffSession',
'DeviceDisconnected',
diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py
index 5eb9010..7e5dada 100644
--- a/tests/test_pcap_import.py
+++ b/tests/test_pcap_import.py
@@ -197,7 +197,7 @@ class TestImportEndpoint(unittest.TestCase):
class TestVersion(unittest.TestCase):
def test_package_version(self):
from simtrace2_pysniff import __version__
- self.assertEqual(__version__, '1.20.0')
+ self.assertEqual(__version__, '1.20.1')
def test_status_includes_version(self):
import os