diff --git a/frontend/index.html b/frontend/index.html index f7db758..76b9b94 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -615,6 +615,294 @@ function desCbcDecrypt(keyBytes, ivBytes, ctBytes) { return out; } +// ──────────────────── C-APDU parsing (vendored from otaman) ──────────────────── +// Parses C-APDU hex streams (e.g. SCP80 secured-packet application messages) +// into {label, hex, desc, children} trees. +const PARSE_INS = { + 'A4': {name:'SELECT', data:'lc'}, + 'B0': {name:'READ BINARY', data:'le'}, + 'B2': {name:'READ RECORD(S)', data:'le'}, + 'DC': {name:'UPDATE RECORD', data:'lc'}, + 'D6': {name:'UPDATE BINARY', data:'lc'}, + '0E': {name:'ERASE BINARY', data:'none'}, + '44': {name:'ACTIVATE FILE', data:'none'}, + '04': {name:'DEACTIVATE FILE', data:'none'}, + '20': {name:'VERIFY', data:'lc'}, + '24': {name:'CHANGE', data:'lc'}, + '26': {name:'DISABLE PIN', data:'lc'}, + '28': {name:'ENABLE PIN', data:'lc'}, + '2C': {name:'UNBLOCK PIN', data:'lc'}, + 'E6': {name:'INSTALL', data:'lc'}, + 'E8': {name:'LOAD', data:'lc'}, + 'D4': {name:'RESIZE FILE', data:'lc'}, + 'E0': {name:'CREATE FILE', data:'lc'}, + 'E4': {name:'DELETE', data:'lc'}, + 'F2': {name:'GET STATUS', data:'lc'}, + 'CA': {name:'GET DATA', data:'none'}, + 'DA': {name:'PUT DATA', data:'lc'}, + 'CB': {name:'RETRIEVE DATA', data:'le'}, + 'DB': {name:'SET DATA', data:'lc'}, + 'A2': {name:'SEARCH RECORD', data:'lc'}, + '32': {name:'INCREASE', data:'lc'}, + '84': {name:'GET CHALLENGE', data:'le'}, + '88': {name:'AUTHENTICATE', data:'lc'}, + '89': {name:'AUTHENTICATE (odd INS)', data:'lc'}, + 'E2': {name:'STORE DATA', data:'lc'}, + 'F0': {name:'SET STATUS', data:'lc'}, + '82': {name:'EXTERNAL AUTHENTICATE', data:'lc'}, + 'C0': {name:'GET RESPONSE', data:'le'}, + '10': {name:'TERMINAL PROFILE', data:'lc'}, + 'C2': {name:'ENVELOPE', data:'lc'}, + '12': {name:'FETCH', data:'le'}, + '14': {name:'TERMINAL RESPONSE', data:'lc'}, + '76': {name:'SUSPEND UICC', data:'lc'}, + 'AA': {name:'TERMINAL CAPABILITY', data:'lc'}, + '73': {name:'MANAGE SECURE CHANNEL', data:'lc'}, + '75': {name:'TRANSACT DATA', data:'lc'}, + '78': {name:'GET IDENTITY', data:'le'}, + '7A': {name:'EXCHANGE CAPABILITIES', data:'lc'}, + '7C': {name:'MANAGE LSI', data:'lc'}, + '50': {name:'INITIALIZE UPDATE', data:'lc'}, + 'D8': {name:'PUT KEY', data:'lc'}, + '70': {name:'MANAGE CHANNEL', data:'none'}, +}; + +function describeP1P2(ins, p1, p2) { + const v1 = parseInt(p1, 16); + const v2 = parseInt(p2, 16); + let d1 = '', d2 = ''; + if (ins === 'A4') { + d1 = {'00':'by FID','01':'child DF','03':'parent DF','04':'by DF name (AID)','08':'path from MF','09':'path from current DF'}[p1] || ''; + const resp = (v2 >> 2) & 0x07; + if (resp === 0x01) d2 = 'return FCP template'; + else if (resp === 0x03) d2 = 'no response data'; + if (v1 === 0x04) { + const occName = {'00':'first or only occurrence','01':'last occurrence','10':'next occurrence','11':'previous occurrence'}[(v2 & 0x03).toString(2).padStart(2,'0')]; + if (occName) d2 += (d2 ? ', ' : '') + occName; + } + } else if (ins === 'B0' || ins === 'D6') { + if (v1 & 0x80) d1 = 'SFI mode, SFI ' + (v1 & 0x1F); + else d1 = 'offset high'; + d2 = 'offset low'; + } else if (ins === 'B2' || ins === 'DC') { + const modeKey = (v2 & 0x07).toString(16).padStart(2, '0'); + d2 = {'02':'next record','03':'previous record','04':'absolute/current'}[modeKey] || ''; + const sfi = (v2 >> 3) & 0x1F; + if (sfi) d2 += (d2 ? ', ' : '') + 'SFI ' + sfi; + if ((v2 & 0x07) === 0x02 || (v2 & 0x07) === 0x03) d1 = v1 === 0 ? 'ignored' : 'record # (ignored)'; + else if (v1 > 0) d1 = 'record #' + v1; + } else if (ins === 'F2') { + d1 = {'80':'ISD','40':'Applications incl. SSDs','20':'Executable Load Files','10':'ELFs and Modules'}[p1] || ''; + d2 = {'02':'first/all, TLV format','03':'next, TLV format','00':'first/all, deprecated raw','01':'next, deprecated raw'}[p2] || ''; + } else if (ins === 'E6') { + const roles = []; + if (v1 & 0x02) roles.push('for load'); + if (v1 & 0x04) roles.push('for install'); + if (v1 & 0x08) roles.push('for make selectable'); + if (v1 & 0x10) roles.push('for extradition'); + if (v1 & 0x20) roles.push('for personalization'); + if (v1 & 0x40) roles.push('for registry update'); + if (roles.length) d1 = roles.join(' + '); + if (v1 & 0x80) d1 = 'more INSTALL commands follow; ' + d1; + } else if (ins === 'F0') { + d1 = {'80':'ISD','40':'Application or SSD','60':'SD + associated apps'}[p1] || ''; + if (p1 === '80') { + d2 = {'01':'OP_READY','07':'INITIALIZED','0F':'SECURED','7F':'CARD_LOCKED','FF':'TERMINATED'}[p2] || ''; + } else { + d2 = {'00':'unlock (previous state)','80':'LOCKED'}[p2] || ''; + } + } else if (ins === '20' || ins === '24') { + if (v2 >= 1 && v2 <= 8) d2 = 'PIN ref ' + v2; + } + return {d1, d2}; +} + +function describeInstallDataLv(p1val, data) { + let fields; + if (p1val & 0x40) { + fields = ['SD AID', '(reserved)', 'Application AID', 'Privileges', 'RU parameters', 'RU token']; + } else if (p1val & 0x20) { + fields = ['(reserved)', '(reserved)', 'Application AID', 'Privileges', 'Personalization parameters', 'Personalization token']; + } else if (p1val & 0x10) { + fields = ['Target SD AID', '(reserved)', 'Application/ELF AID', '(reserved)', 'Extradition parameters', 'Extradition token']; + } else if ((p1val & 0x08) && !(p1val & 0x04)) { + fields = ['(reserved)', '(reserved)', 'Application AID', 'Privileges', 'MS parameters', 'MS token']; + } else if (p1val & 0x04) { + fields = ['ELF AID', 'Module AID', 'Application AID', 'Privileges', 'Install parameters', 'Install token']; + } else if (p1val & 0x02) { + fields = ['Load File AID', 'SD AID', 'LFDB hash', 'Load parameters', 'Load token']; + } else { + return null; + } + const nodes = []; + let i = 0; + for (const name of fields) { + if (i >= data.length) return null; + const f = readLvField(data, i); + if (!f) return null; + const node = {label: name, hex: f.lenHex + f.value, desc:''}; + if (name === 'Privileges') node.desc = decodePrivileges(f.value); + else if (name.endsWith('parameters')) { + const walked = walkInstallParams(f.value); + node.children = walked || [{label:'Value', hex:f.value, desc:f.value}]; + node.desc = f.value ? '' : '(empty)'; + } else if (f.value) { + node.desc = f.value; + } else { + node.desc = '(empty)'; + } + nodes.push(node); + i += f.consumed; + } + if (i !== data.length) return null; + return nodes; +} + +function parseOneApdu(hex) { + hex = (hex || '').toUpperCase(); + const isActivateDeactivate = hex.length >= 4 && (hex.substr(2, 2) === '44' || hex.substr(2, 2) === '04'); + if (hex.length < 10 && !(isActivateDeactivate && hex.length === 8)) return null; + const cla = hex.substr(0, 2); + const ins = hex.substr(2, 2); + const p1 = hex.substr(4, 2); + const p2 = hex.substr(6, 2); + const p3 = hex.substr(8, 2); + const insInfo = PARSE_INS[ins]; + const labels = describeP1P2(ins, p1, p2); + const children = [ + {label:'CLA', hex:cla, desc:cla === '80' ? 'GP/RAM' : cla === 'A0' ? 'SIM' : parseInt(cla,16) >= 0x84 && parseInt(cla,16) <= 0x87 ? 'GlobalPlatform (secure messaging)' : 'UICC'}, + {label:'INS', hex:ins, desc:insInfo ? insInfo.name : 'Unknown'}, + {label:'P1', hex:p1, desc:labels.d1}, + {label:'P2', hex:p2, desc:labels.d2}, + ]; + if (isActivateDeactivate && hex.length === 8) { + return {label:'APDU (case 1)', hex:hex, desc:'no data, no Le', children}; + } + if (ins === 'CA') { + if (hex.length === 10) { + children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)}); + return {label:'APDU', hex:hex, desc:'', children}; + } + const lc = parseInt(p3, 16); + children.push({label:'Lc', hex:p3, desc:'Data length: ' + lc}); + const data = hex.substr(10, lc * 2); + children.push({label:'Data', hex:data, desc:data.startsWith('5C') && data.length >= 4 ? 'Tag list: ' + data : data}); + const restLen = hex.length - (10 + lc * 2); + if (restLen >= 2) children.push({label:'Le', hex:hex.substr(10 + lc * 2, 2), desc:'Expected length: ' + parseInt(hex.substr(10 + lc * 2, 2), 16)}); + return {label:'APDU', hex:hex, desc:'', children}; + } + if (insInfo && insInfo.data === 'le') { + children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)}); + } else if (isActivateDeactivate && hex.length >= 10) { + if (parseInt(p3, 16) === 0 && hex.length === 10) { + children.push({label:'P3', hex:p3, desc:'empty Lc (legacy form)'}); + } else { + const alc = parseInt(p3, 16); + children.push({label:'Lc', hex:p3, desc:'Data length: ' + alc}); + if (alc > 0 && hex.length >= 10 + alc * 2) { + const adata = hex.substr(10, alc * 2); + const adesc = adata.length === 4 ? 'FID ' + adata : (adata.length > 4 && adata.length % 2 === 0 ? 'Path ' + adata : adata); + children.push({label:'Data', hex:adata, desc:adesc}); + } + } + } else if (insInfo && insInfo.data === 'none') { + children.push({label:'P3', hex:p3, desc:'No data'}); + } else { + const lc = parseInt(p3, 16); + children.push({label:'Lc', hex:p3, desc:'Data length: ' + lc}); + if (lc > 0 && hex.length >= 10 + lc * 2) { + const data = hex.substr(10, lc * 2); + let dataDesc = data; + let dataChildren = null; + if (ins === 'E6') { + const lv = describeInstallDataLv(parseInt(p1, 16), data); + if (lv) { dataDesc = ''; dataChildren = lv; } + else dataDesc = data; + } else if (ins === 'F0') { + if (p1 === '80') dataDesc = 'ignored for ISD'; + else if (data.startsWith('4F') && data.length >= 4 && 2 + 2 + parseInt(data.substr(2, 2), 16) * 2 === data.length) dataDesc = 'Legacy 4F-TLV AID: ' + data.substr(4); + else dataDesc = 'AID (raw): ' + data; + } + const node = {label:'Data', hex:data, desc:dataDesc}; + if (dataChildren) node.children = dataChildren; + children.push(node); + if (hex.length > 10 + lc * 2) { + const le = hex.substr(10 + lc * 2, 2); + children.push({label:'Le', hex:le, desc:'Expected response length: ' + parseInt(le, 16)}); + } + } + } + const totalLen = hex.length; + return {label:'APDU', hex:hex.substr(0, totalLen), desc:'', children}; +} + +function parseCompactApdus(hex) { + hex = (hex || '').toUpperCase(); + const apdus = []; + let i = 0; + let prevCla = ''; + while (i + 4 <= hex.length) { + const first = hex.substr(i, 2); + const second = hex.substr(i + 2, 2); + let fullCla = ''; + let fullIns = ''; + let consumed = 0; + const fb = parseInt(first, 16); + const knownCla = first === '80' || first === 'A0' || first === '00' || (fb >= 0x84 && fb <= 0x87); + if (knownCla && PARSE_INS[second]) { + fullCla = first; + fullIns = second; + consumed = 2; + } else if (PARSE_INS[first] && prevCla && i > 0) { + fullCla = prevCla; + fullIns = first; + consumed = 0; + } + if (fullCla) { + const p3Byte = parseInt(hex.substr(i + 6 + consumed, 2), 16); + const dataType = PARSE_INS[fullIns].data; + let apduLen = 8; + if (fullIns === 'CA') { + const remAvail = hex.length - (i + consumed); + if (p3Byte > 0 && remAvail >= p3Byte * 2 + 2) apduLen += p3Byte * 2 + 2; + } else if (fullIns === '44' || fullIns === '04') { + const avail = hex.length - i; + const p3Hex = avail >= consumed + 8 ? hex.substr(i + 6 + consumed, 2) : null; + if (p3Hex !== null) { + if (p3Byte > 0) apduLen += p3Byte * 2; + } else if (avail >= consumed + 6) { + apduLen -= 2; + } + } else if (dataType === 'lc') { + apduLen += p3Byte * 2; + } + if (i + consumed + apduLen <= hex.length) { + const apduHex = fullCla + hex.substr(i + consumed, apduLen); + const parsed = parseOneApdu(apduHex); + if (parsed) { + prevCla = fullCla; + parsed.hex = hex.substr(i, consumed + apduLen); + parsed.label = consumed === 0 ? 'APDU (implied CLA)' : 'APDU'; + apdus.push(parsed); + i += consumed + apduLen; + continue; + } + } + } + apdus.push({label:'Unknown', hex:hex.substr(i, 2), desc:'Unrecognized'}); + i += 2; + } + if (i < hex.length) { + if (apdus.length > 0 && hex.length - i === 2) { + const leHex = hex.substr(i); + apdus.push({label:'Le', hex:leHex, desc:'Expected response length: ' + parseInt(leHex, 16)}); + } else { + apdus.push({label:'Trailing (unrecognized)', hex:hex.substr(i), desc:hex.substr(i)}); + } + } + return apdus; +} + // Decrypt a ciphered secured packet block with the user-supplied key hex. // Returns {cntr, pcntr, rc, data, pad} byte arrays; throws on bad key/size. async function decryptSecured(sec, keyHex) { @@ -953,6 +1241,43 @@ function cmdRows(cmd) { rows.push(['Secured data (decrypted)', bytesToHex(dec.data)]); } } + // Application message: the secured data is a C-APDU stream — + // plaintext when unciphered, decrypted content otherwise. + const appSrc = !sec.ciphered && sec.data ? {hex: sec.data, src: 'plaintext'} + : (sec.cipher_block && dec && !dec.error) ? {hex: bytesToHex(dec.data), src: 'decrypted'} + : null; + if (appSrc && appSrc.hex) { + const apdus = parseCompactApdus(appSrc.hex) + .filter(a => a.label !== 'Unknown'); + if (apdus.length) { + rows.push(['Application message (' + appSrc.src + ')', + apdus.length + ' C-APDU' + (apdus.length > 1 ? 's' : '')]); + let n = 0; + for (const apdu of apdus) { + if (apdu.label === 'Le') { + rows.push(['· Le (trailing)', + (apdu.hex || '') + (apdu.desc ? ' — ' + apdu.desc : '')]); + continue; + } + n++; + const kids = apdu.children || []; + const insKid = kids.find(k => k.label === 'INS'); + const name = insKid ? (insKid.desc || '') : apdu.label; + rows.push(['C-APDU #' + n + ' — ' + (name || apdu.label), + apdu.hex || '' + (apdu.label === 'APDU (implied CLA)' ? ' (implied CLA)' : '')]); + for (const k of kids) { + rows.push(['· ' + k.label, + (k.hex || '') + (k.desc ? ' — ' + k.desc : '')]); + if (k.children) for (const c of k.children) { + rows.push(['· · ' + c.label, + (c.hex || '') + (c.desc ? ' — ' + c.desc : '')]); + } + } + } + } else { + rows.push(['Application message (' + appSrc.src + ')', appSrc.hex]); + } + } } const rp = t.response_packet; @@ -2126,7 +2451,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '1.19.0'; +const PWA_VERSION = '1.20.0'; const MIN_SERVER_VERSION = '1.0.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index 83385e7..b3def48 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v27'; +const CACHE = 'simtrace-analyser-v28'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index 3c109fd..7a380d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace2-pysniff" -version = "1.19.0" +version = "1.20.0" 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 faf65cd..a0a4713 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.19.0' +__version__ = '1.20.0' __all__ = [ 'SniffSession', 'DeviceDisconnected', diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 11639cb..5eb9010 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.19.0') + self.assertEqual(__version__, '1.20.0') def test_status_includes_version(self): import os