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