diff --git a/frontend/index.html b/frontend/index.html index e4c0911..3354414 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -110,6 +110,7 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(- .apdu-type.atr { color: var(--green); } .apdu-type.pps { color: var(--yellow); } .apdu-data { font-family: monospace; color: var(--text-dim); word-break: break-all; flex: 1; } +.apdu-sw { font-family: monospace; color: var(--text-muted); white-space: nowrap; min-width: 52px; } /* APDU detail panel */ .apdu-detail { display: flex; flex-direction: column; max-height: 40%; overflow-y: auto; border-top: 2px solid var(--accent); background: var(--bg-panel); flex-shrink: 0; } @@ -441,6 +442,17 @@ function data_text(m) { return fmt_data(m.data); } +function cmd_description(m) { + if (m.decoded && m.decoded.summary) return m.decoded.summary; + return data_text(m); +} + +function sw_text(m) { + const d = m.decoded; + if (!d || !d.sw) return ''; + return d.sw.sw1 + ' ' + d.sw.sw2; +} + // ──────────────────── API ──────────────────── const API = { async _fetch(method, path, body) { @@ -515,8 +527,8 @@ function responseRows(resp) { if (resp.code !== undefined) { rows.push(['Result', resp.code + (resp.name ? ' — ' + resp.name : '')]); } - if (resp.duration !== undefined) { - rows.push(['Duration', resp.duration + ' s']); + if (resp.duration) { + rows.push(['Duration', resp.duration.value + ' ' + resp.duration.unit]); } if (resp.item_identifier !== undefined) { rows.push(['Item', resp.item_identifier]); @@ -570,7 +582,7 @@ function cmdRows(cmd) { rows.push(['Item ' + (it.id !== undefined ? '0x' + hx(it.id, 2) : '?'), it.text]); } } - if (cmd.duration !== undefined) rows.push(['Duration', cmd.duration + ' s']); + if (cmd.duration) rows.push(['Duration', cmd.duration.value + ' ' + cmd.duration.unit]); if (cmd.address) rows.push(['Address', cmd.address]); if (cmd.response_length) rows.push(['Response length', cmd.response_length.min + '…' + cmd.response_length.max]); @@ -578,14 +590,39 @@ function cmdRows(cmd) { const t = cmd.tpdu; if (t) { rows.push(['SMS TPDU', t.mti]); - if (t.oa) rows.push(['Originating addr', t.oa]); - if (t.da) rows.push(['Destination addr', t.da]); - if (t.mr !== undefined) rows.push(['Message ref', '0x' + hx(t.mr, 2)]); - if (t.pid !== undefined) rows.push(['PID', '0x' + hx(t.pid, 2) + (t.pid_name ? ' — ' + t.pid_name : '')]); - if (t.dcs !== undefined) rows.push(['DCS', '0x' + hx(t.dcs, 2) + ' — ' + t.encoding + (t.msg_class ? ' (class ' + t.msg_class + ')' : '')]); - if (t.text !== undefined) rows.push(['Text', t.text]); - if (t.payload) rows.push(['Payload', t.payload]); - if (t.udh) rows.push(['UDH', t.udh]); + if (t.oa) rows.push(['TP-OA', t.oa]); + if (t.da) rows.push(['TP-DA', t.da]); + if (t.mr !== undefined) rows.push(['TP-MR', '0x' + hx(t.mr, 2)]); + if (t.scts) rows.push(['TP-SCTS', t.scts]); + if (t.pid !== undefined) rows.push(['TP-PID', '0x' + hx(t.pid, 2) + (t.pid_name ? ' — ' + t.pid_name : '')]); + if (t.dcs !== undefined) { + const di = t.dcs_info || {}; + let dcsTxt = '0x' + hx(t.dcs, 2) + ' — ' + t.encoding; + if (di.group) dcsTxt += ' · ' + di.group; + if (di.compressed) dcsTxt += ' · compressed'; + if (di.indication) dcsTxt += ' · ' + di.indication + ' (' + di.sense + ')'; + else if (t.msg_class !== undefined && di.has_class) dcsTxt += ' · class ' + t.msg_class; + rows.push(['TP-DCS', dcsTxt]); + } + if (t.vp) rows.push(['TP-VP', t.vp]); + if (Array.isArray(t.udh)) { + for (const ie of t.udh) { + let v = ie.name || ('IEI ' + ie.iei); + const d = ie.data; + if (d) { + if (d.seq !== undefined && d.max !== undefined) + v += ` — ref ${d.reference}, part ${d.seq}/${d.max}`; + else if (d.dest_port !== undefined) + v += ` — dst ${d.dest_port}, src ${d.orig_port}`; + else if (d.count !== undefined) + v += ` — ${d.indication || 'messages'}: ${d.count}` + (d.store ? ' (store)' : ''); + } + if (ie.hex) v += ' · ' + ie.hex; + rows.push(['UDH', v]); + } + } + if (t.text !== undefined) rows.push(['TP-UD', t.text]); + if (t.payload) rows.push(['TP-UD (payload)', t.payload]); } if (!rows.length) return ''; @@ -593,6 +630,67 @@ function cmdRows(cmd) { rows.map(([l, v]) => `${l}${v}`).join(''); } +function ifaceValue(ifc) { + const v = []; + if (ifc.f !== undefined) v.push('F=' + ifc.f); + if (ifc.d !== undefined) v.push('D=' + ifc.d); + if (ifc.f_div_d !== undefined) v.push('F/D=' + ifc.f_div_d); + if (ifc.f_max !== undefined) v.push('f(max)=' + ifc.f_max + ' MHz'); + if (ifc.programming !== undefined) v.push(ifc.programming); + if (ifc.extra_guard_time !== undefined) v.push('N=' + ifc.extra_guard_time + ' etu'); + if (ifc.work_waiting_time !== undefined) v.push('WI=' + ifc.work_waiting_time); + if (ifc.specific !== undefined) v.push(ifc.specific ? 'specific mode' : 'negotiable mode'); + if (ifc.protocol !== undefined) v.push(ifc.protocol); + if (ifc.ifsc !== undefined) v.push('IFSC=' + ifc.ifsc); + if (ifc.bwi !== undefined) v.push('BWI=' + ifc.bwi + ', CWI=' + ifc.cwi); + if (ifc.clock_stop) v.push(ifc.clock_stop); + if (ifc.classes && ifc.classes.length) v.push('classes ' + ifc.classes.join(', ')); + if (ifc.spu !== undefined) v.push('SPU ' + ifc.spu); + return (v.length ? v.join(' · ') : '') + (ifc.hex ? ' (0x' + ifc.hex + ')' : ''); +} + +function atrPpsRows(decoded) { + const rows = []; + if (decoded.type === 'atr') { + rows.push(['Convention', decoded.convention || 'unknown']); + if (decoded.ts) rows.push(['TS', '0x' + decoded.ts]); + if (decoded.t0) rows.push(['T0', '0x' + decoded.t0 + ' — ' + decoded.historical_len + ' historical bytes']); + if (decoded.protocols) rows.push(['Protocols', decoded.protocols.join(', ')]); + if (decoded.interface) { + for (const ifc of decoded.interface) { + rows.push([ifc.name, ifaceValue(ifc)]); + } + } + if (decoded.historical) { + const h = decoded.historical; + let v = h.category || ''; + if (h.life_cycle) v += ' — ' + h.life_cycle; + if (h.sw) v += ' (SW ' + h.sw + ')'; + rows.push(['Historical', v + ' · ' + h.raw]); + } + if (decoded.tck) { + rows.push(['TCK', '0x' + decoded.tck + (decoded.tck_valid ? ' (valid)' : ' (mismatch)')]); + } + } else { + if (decoded.ppss) rows.push(['PPSS', '0x' + decoded.ppss]); + if (decoded.pps0) rows.push(['PPS0', '0x' + decoded.pps0]); + if (decoded.protocol) rows.push(['Protocol', decoded.protocol]); + if (decoded.fi_di) { + const fd = decoded.fi_di; + const v = []; + if (fd.f !== undefined) v.push('F=' + fd.f); + if (fd.d !== undefined) v.push('D=' + fd.d); + if (fd.f_div_d !== undefined) v.push('F/D=' + fd.f_div_d); + rows.push(['PPS1', v.join(' · ') + ' (0x' + fd.pps1 + ')']); + } + if (decoded.pps2 !== undefined) rows.push(['PPS2', '0x' + decoded.pps2 + (decoded.spu ? ' — ' + decoded.spu : '')]); + if (decoded.pck) rows.push(['PCK', '0x' + decoded.pck + (decoded.pck_valid ? ' (valid)' : ' (mismatch)')]); + if (decoded.note) rows.push(['Note', decoded.note]); + } + rows.push(['Raw', (decoded.raw || '')]); + return rows; +} + function showDetail(msg, entry) { // Highlight clicked entry document.querySelectorAll('.apdu-entry.selected').forEach(e => e.classList.remove('selected')); @@ -630,10 +728,11 @@ function showDetail(msg, entry) { } if (decoded.type === 'atr' || decoded.type === 'pps') { + const rows = atrPpsRows(decoded); d.innerHTML = `
${decoded.type.toUpperCase()}${fmt_elapsed(msg.elapsed)}
- + ${rows.map(([l, v]) => ``).join('')}
Data${(decoded.hex || '').toUpperCase()}
${l}${v}
`; return; @@ -687,6 +786,7 @@ function showDetail(msg, entry) {
+ ${catRow}${respRow}${trRow} @@ -821,7 +921,8 @@ function renderMessages() { return `
${fmt_elapsed(m.elapsed)} ${label} - ${data_text(m)} + ${sw_text(m)} + ${cmd_description(m)}
`; }).join(''); @@ -1278,7 +1379,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '1.2.0'; +const PWA_VERSION = '1.3.0'; const MIN_SERVER_VERSION = '1.0.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index 8b3d52c..7fe6f3f 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v4'; +const CACHE = 'simtrace-analyser-v5'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index 5f1d0b7..832ce42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace2-pysniff" -version = "1.2.0" +version = "1.3.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 65f3412..3a2fac8 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.2.0' +__version__ = '1.3.0' __all__ = [ 'SniffSession', 'DeviceDisconnected', diff --git a/simtrace2_pysniff/server/decode.py b/simtrace2_pysniff/server/decode.py index 6367565..c87c550 100644 --- a/simtrace2_pysniff/server/decode.py +++ b/simtrace2_pysniff/server/decode.py @@ -122,36 +122,303 @@ def decode_cla(cla): # ──────────────────── Known FIDs ──────────────────── +# Sourced from pySim (TS 102 221 common, TS 51.011 SIM, TS 31.102 USIM, +# TS 31.103 ISIM). Note: some FIDs are reused across DFs/ADFs; the +# USIM/ISIM interpretation is preferred here because the '7FFF' (current +# ADF) SELECT-path resolution is the primary use case. KNOWN_FIDS = { + # Master File and common files (TS 102 221 / TS 51.011) '3f00': 'MF', '2f00': 'EF_DIR', - '2fe2': 'EF_ICCID', '2f05': 'EF_PL', '2f06': 'EF_ARR', + '2f08': 'EF_UMPC', + '2fe2': 'EF_ICCID', + + # Dedicated files (MF/ADF level) '7f10': 'DF_TELECOM', '7f20': 'DF_GSM', - '5f3a': 'DF_GSM_ACCESS', - '2f07': 'EF_IMSI', + '5f3b': 'DF_GSM_ACCESS', + '5f40': 'DF_WLAN', + '5f50': 'DF_HNB', + '5f90': 'DF_ProSe', + '5ff0': 'DF_5G_ProSe', + '5fc0': 'DF_5GS', + '5fe0': 'DF_SNPN', + '5ff1': 'DF_5MBSUECONFIG', + + # SIM (TS 51.011) — DF_TELECOM / DF_GSM '6f05': 'EF_LI', '6f07': 'EF_IMSI', '6f20': 'EF_Kc', + '6f2c': 'EF_DCK', '6f30': 'EF_PLMNsel', '6f31': 'EF_HPPLMN', + '6f32': 'EF_CNL', '6f37': 'EF_ACMmax', - '6f38': 'EF_SST', + '6f38': 'EF_UST', '6f39': 'EF_ACM', + '6f3a': 'EF_ADN', + '6f3b': 'EF_FDN', + '6f3c': 'EF_SMS', + '6f3d': 'EF_CCP', '6f3e': 'EF_GID1', '6f3f': 'EF_GID2', + '6f40': 'EF_MSISDN', + '6f41': 'EF_PUCT', + '6f42': 'EF_SMSP', + '6f43': 'EF_SMSS', + '6f44': 'EF_LND', + '6f45': 'EF_CBMI', '6f46': 'EF_SPN', - '6f74': 'EF_ARR', + '6f47': 'EF_SMSR', + '6f48': 'EF_CBMID', + '6f49': 'EF_SDN', + '6f4a': 'EF_EXT1', + '6f4b': 'EF_EXT2', + '6f4c': 'EF_EXT3', + '6f4d': 'EF_BDN', + '6f4e': 'EF_EXT4', + '6f4f': 'EF_ECCP', + '6f50': 'EF_CBMIR', + '6f51': 'EF_NIA', + '6f52': 'EF_KcGPRS', + '6f53': 'EF_LOCI_GPRS', + '6f58': 'EF_CMI', + '6f63': 'EF_CPBCCH', + '6f64': 'EF_InvScan', + '6f74': 'EF_BCCH', '6f78': 'EF_ACC', '6f7b': 'EF_FPLMN', - '6fad': 'EF_ADN', + '6f7e': 'EF_LOCI', + '6fad': 'EF_AD', '6fae': 'EF_Phase', + '6fb1': 'EF_VGCS', + '6fb2': 'EF_VGCSS', + '6fb3': 'EF_VBS', + '6fb4': 'EF_VBSS', + '6fb5': 'EF_eMLPP', + '6fb6': 'EF_AAeM', + '6fb7': 'EF_ECC', + '6fc5': 'EF_PNN', + '6fc6': 'EF_OPL', + '6fc9': 'EF_MBI', + '6fca': 'EF_MWIS', + '6fcb': 'EF_CFIS', + '6fcd': 'EF_SPDI', + '6fce': 'EF_MMSN', + '6fd0': 'EF_MMSICP', + '6fd1': 'EF_MMSUP', + '6fd2': 'EF_MMSUCP', + + # USIM (TS 31.102) — ADF_USIM + '6f01': 'EF_eAKA', + '6f02': 'EF_IMPI', # ISIM EF_IMPI (USIM reuses 6F02 for EF_OCST) + '6f06': 'EF_ARR', + '6f08': 'EF_Keys', + '6f09': 'EF_KeysPS', # ISIM EF_P-CSCF also reuses 6F09 + '6f55': 'EF_EXT4', + '6f56': 'EF_EST', + '6f57': 'EF_ACL', + '6f5b': 'EF_START-HFN', + '6f5c': 'EF_THRESHOLD', + '6f60': 'EF_PLMNwAcT', + '6f61': 'EF_OPLMNwAcT', + '6f62': 'EF_HPLMNwAcT', + '6f65': 'EF_RPLMNAcT', + '6f73': 'EF_PSLOCI', + '6f80': 'EF_ICI', + '6f81': 'EF_OCI', + '6f82': 'EF_ICT', + '6f83': 'EF_OCT', + '6fc4': 'EF_NETPAR', + '6fc7': 'EF_MBDN', + '6fc8': 'EF_EXT6', + '6fcc': 'EF_EXT7', + '6fcf': 'EF_EXT8', + '6fd3': 'EF_NIA', + '6fd4': 'EF_VGCSCA', + '6fd5': 'EF_VBSCA', + '6fd6': 'EF_GBABP', + '6fd7': 'EF_MSK', + '6fd8': 'EF_MUK', + '6fd9': 'EF_EHPLMN', + '6fda': 'EF_GBANL', + '6fdb': 'EF_EHPLMNPI', + '6fdd': 'EF_NAFKCA', + '6fde': 'EF_SPNI', + '6fdf': 'EF_PNNI', + '6fe2': 'EF_NCP-IP', + '6fe3': 'EF_EPSLOCI', + '6fe4': 'EF_EPSNSC', + '6fe6': 'EF_UFC', + '6fe8': 'EF_NASCONFIG', + '6fec': 'EF_PWS', + '6fed': 'EF_FDNURI', + '6fee': 'EF_BDNURI', + '6fef': 'EF_SDNURI', + '6ff1': 'EF_IPS', + '6ff3': 'EF_ePDGId', + '6ff4': 'EF_ePDGSelection', + '6ff5': 'EF_ePDGIdEm', + '6ff6': 'EF_ePDGSelectionEm', + '6ff7': 'EF_FromPreferred', + '6ff8': 'EF_IMSConfigData', + '6ffa': 'EF_WebRTCURI', + '6ffc': 'EF_XCAPConfigData', + '6ffd': 'EF_EARFCNList', + '6ffe': 'EF_MuDMiDConfigData', + + # ISIM (TS 31.103) — ADF_ISIM + '6f03': 'EF_DOMAIN', + '6f04': 'EF_IMPU', + '6f0a': 'EF_GBAUAPI', + '6f0b': 'EF_IMSDCI', + '6fe7': 'EF_UICCIARI', } +# DF/ADF children (child FID → name), for context-aware SELECT-path +# resolution. The '4Fxx' files are only meaningful within their parent +# DF, and many FIDs collide across DFs (e.g. '4F01' differs between +# DF_ProSe, DF_5GS, DF_MCS, DF_V2X, DF_SNPN), so they cannot live in the +# flat KNOWN_FIDS table. Sourced from pySim (TS 31.102 / TS 31.102 telecom). +DF_CHILDREN = { + '7f10': { # DF_TELECOM + '5f3a': 'DF_PHONEBOOK', + '5f3b': 'DF_MULTIMEDIA', + '5f3d': 'DF_MCS', + '5f3e': 'DF_V2X', + }, + '5f3a': { # DF_PHONEBOOK + '4f22': 'EF_PSC', + '4f23': 'EF_CC', + '4f24': 'EF_PUID', + '4f30': 'EF_PBR', + }, + '5f3b': { # DF_GSM_ACCESS (ADF) + DF_MULTIMEDIA (DF_TELECOM) share 5F3B + '4f20': 'EF_Kc', + '4f52': 'EF_KcGPRS', + '4f63': 'EF_CPBCCH', + '4f64': 'EF_InvScan', + '4f47': 'EF_MML', + '4f48': 'EF_MMDF', + }, + '5f3d': { # DF_MCS + '4f01': 'EF_MST', + '4f02': 'EF_MCS_CONFIG', + }, + '5f3e': { # DF_V2X + '4f01': 'EF_VST', + '4f02': 'EF_V2X_CONFIG', + }, + '5f40': { # DF_WLAN + '4f41': 'EF_Pseudo', + '4f42': 'EF_UPLMNWLAN', + '4f43': 'EF_OPLMNWLAN', + '4f44': 'EF_UWSIDL', + '4f45': 'EF_OWSIDL', + '4f46': 'EF_WRI', + '4f47': 'EF_HWSIDL', + '4f48': 'EF_WEHPLMNPI', + '4f49': 'EF_WHPI', + '4f4a': 'EF_WLRPLMN', + '4f4b': 'EF_HPLMNDAI', + }, + '5f50': { # DF_HNB + '4f81': 'EF_ACSGL', + '4f82': 'EF_CSGT', + '4f83': 'EF_HNBN', + '4f84': 'EF_OCSGL', + '4f85': 'EF_OCSGT', + '4f86': 'EF_OHNBN', + }, + '5f90': { # DF_ProSe + '4f01': 'EF_PROSE_MON', + '4f02': 'EF_PROSE_ANN', + '4f03': 'EF_PROSEFUNC', + '4f04': 'EF_PROSE_RADIO_COM', + '4f05': 'EF_PROSE_RADIO_MON', + '4f06': 'EF_PROSE_RADIO_ANN', + '4f07': 'EF_PROSE_POLICY', + '4f08': 'EF_PROSE_PLMN', + '4f09': 'EF_PROSE_GC', + '4f10': 'EF_PST', + '4f11': 'EF_UIRC', + '4f12': 'EF_PROSE_GM_DISCOVERY', + '4f13': 'EF_PROSE_RELAY', + '4f14': 'EF_PROSE_RELAY_DISCOVERY', + }, + '5fc0': { # DF_5GS + '4f01': 'EF_5GS3GPPLOCI', + '4f02': 'EF_5GSN3GPPLOCI', + '4f03': 'EF_5GS3GPPNSC', + '4f04': 'EF_5GSN3GPPNSC', + '4f05': 'EF_5GAUTHKEYS', + '4f06': 'EF_UAC_AIC', + '4f07': 'EF_SUCI_Calc_Info', + '4f08': 'EF_OPL5G', + '4f09': 'EF_SUPI_NAI', + '4f0a': 'EF_Routing_Indicator', + '4f0b': 'EF_URSP', + '4f0c': 'EF_TN3GPPSNN', + '4f0d': 'EF_CAG', + '4f0e': 'EF_SOR-CMCI', + '4f0f': 'EF_DRI', + '4f10': 'EF_5GSEDRX', + '4f11': 'EF_5GNSWO_CONF', + '4f15': 'EF_MCHPPLMN', + '4f16': 'EF_KAUSF_DERIVATION', + }, + '5fd0': { # DF_SAIP + '4f01': 'EF_SUCI_Calc_Info', + }, + '5fe0': { # DF_SNPN + '4f01': 'EF_PWS_SNPN', + '4f02': 'EF_NID', + }, + '5ff0': { # DF_5G_ProSe + '4f02': 'EF_5G_PROSE_DD', + '4f03': 'EF_5G_PROSE_DC', + '4f04': 'EF_5G_PROSE_U2NRU', + '4f05': 'EF_5G_PROSE_RU', + '4f06': 'EF_5G_PROSE_UIR', + '4f07': 'EF_5G_PROSE_U2URU', + '4f08': 'EF_5G_PROSE_EU', + }, + '5ff1': { # DF_5MBSUECONFIG + '4f01': 'EF_5MBSUECONFIG', + }, +} + + +def _decode_select_path(body_hex): + """Decode a SELECT path body into a human-readable path. + + Splits the body into 2-byte FIDs and names each component, tracking + the parent DF so DF-specific children (e.g. '4Fxx') resolve against + the correct DF. '7fff' is the implicit FID for the current + application's ADF, whose children are the flat KNOWN_FIDS files. + """ + pairs = [body_hex[i:i + 4] for i in range(0, len(body_hex), 4)] + parts = [] + parent = None + for p in pairs: + if p == '7fff': + parts.append('current ADF') + parent = None + continue + name = None + if parent and parent in DF_CHILDREN: + name = DF_CHILDREN[parent].get(p) + if name is None: + name = KNOWN_FIDS.get(p) + parts.append(name if name else p.upper()) + parent = p if p in DF_CHILDREN else None + return '/'.join(parts) + + # ──────────────────── Per-INS specifications ──────────────────── APDU_SPEC = { @@ -160,11 +427,10 @@ APDU_SPEC = { 'p1': { 0x00: 'DF/EF/MF by file ID', 0x01: 'Child DF', - 0x02: 'EF under current DF', 0x03: 'Parent DF', 0x04: 'Application DF by name', 0x08: 'Path from MF', - 0x09: 'Application by AID', + 0x09: 'Path from current DF', }, 'p2': { 0x00: 'No indication', @@ -663,8 +929,12 @@ def _decode_datetime(val): if len(val) < 7: return None b = val[:7] + if any((x >> 4) > 9 or (x & 0x0F) > 9 for x in b[:6]): + return None bcd = lambda x: (x >> 4) * 10 + (x & 0x0F) yy, mm, dd, hh, mi, ss = (bcd(x) for x in b[:6]) + if not (1 <= mm <= 12 and 1 <= dd <= 31 and hh <= 23 and mi <= 59 and ss <= 59): + return None tz_raw = b[6] if (tz_raw & 0x0F) == 0x0F: tz_str = 'unknown' @@ -713,6 +983,17 @@ def _decode_local_info(tag, value): return None +# TS 102 223 §8.8 — Duration data object: [time unit][time interval] +_TIME_UNITS = {0x00: 'minutes', 0x01: 'seconds', 0x02: 'tenths of seconds'} + + +def _decode_duration(value): + """Decode a Duration data object value (time unit + time interval).""" + if len(value) < 2: + return None + return {'value': value[1], 'unit': _TIME_UNITS.get(value[0], f'unit 0x{value[0]:02X}')} + + def _decode_tr_response(body): """Decode the TLVs of a TERMINAL RESPONSE body. @@ -728,8 +1009,8 @@ def _decode_tr_response(body): result['code'] = f'0x{code:02X}' result['name'] = TR_RESULTS.get(code) result['raw'] = value.hex().upper() - elif tag == 0x04 and len(value) >= 2: # Duration (POLL INTERVAL) - result['duration'] = (value[0] << 8) | value[1] + elif tag == 0x04: # Duration (POLL INTERVAL) + result['duration'] = _decode_duration(value) elif tag == 0x05 and value: # Item Identifier (SELECT ITEM) result['item_identifier'] = value[0] else: @@ -1001,6 +1282,177 @@ def _decode_dcs(dcs): return encoding, (dcs & 0x03) +def _decode_dcs_full(dcs): + """Decode TP-DCS into a rich dict (TS 23.038 §4). + + Returns encoding/msg_class (same as _decode_dcs) plus the coding + group name and group-specific flags: compression, message class + presence, and Message Waiting Indication details. + """ + encoding, msg_class = _decode_dcs(dcs) + result = {'hex': f'{dcs:02x}', 'encoding': encoding, 'msg_class': msg_class} + group = dcs >> 4 + if group in (0x0, 0x1): + result['group'] = ('General Data Coding indication' + if group == 0 else 'Message Marked for Automatic Deletion') + result['compressed'] = bool(dcs & 0x20) + result['has_class'] = bool(dcs & 0x10) + elif group in (0xC, 0xD, 0xE): + result['group'] = 'Message Waiting Indication' + result['action'] = 'discard' if group == 0xC else 'store' + result['sense'] = 'active' if (dcs & 0x08) else 'inactive' + result['indication'] = {0: 'voicemail', 1: 'fax', 2: 'email', 3: 'other'}.get(dcs & 0x03) + elif group == 0xF: + result['group'] = 'Data coding / message class' + else: # 0x8..0xB reserved + result['group'] = 'reserved' + return result + + +# TS 23.040 §9.2.3.9 — TP-Protocol-Identifier values (SMS-DELIVER / SMS-SUBMIT) +PID_NAMES = { + 0x00: 'SME-to-SME (implicit)', + 0x20: 'Telematic: implicit', + 0x21: 'Telematic: telex', + 0x22: 'Telematic: group 3 telefax', + 0x23: 'Telematic: group 4 telefax', + 0x24: 'Telematic: voice telephone', + 0x25: 'Telematic: ERMES', + 0x26: 'Telematic: national paging', + 0x27: 'Telematic: videotex', + 0x28: 'Telematic: teletex (unspecified)', + 0x2A: 'Telematic: teletex (CSPDN)', + 0x2D: 'Telematic: teletex (ISDN)', + 0x30: 'Message handling facility', + 0x31: 'X.400-based MHS', + 0x32: 'Internet Electronic Mail', + 0x3F: 'GSM/UMTS mobile station', + 0x40: 'Short Message Type 0', + 0x41: 'Replace Short Message Type 1', + 0x42: 'Replace Short Message Type 2', + 0x43: 'Replace Short Message Type 3', + 0x44: 'Replace Short Message Type 4', + 0x45: 'Replace Short Message Type 5', + 0x46: 'Replace Short Message Type 6', + 0x47: 'Replace Short Message Type 7', + 0x48: 'Device Triggering Short Message', + 0x5F: 'Return Call Message', + 0x7C: 'ANSI-136 R-DATA', + 0x7D: 'ME Data download', + 0x7E: 'ME De-personalization Short Message', + 0x7F: '(U)SIM Data download', +} + + +def _decode_pid(pid): + return PID_NAMES.get(pid) + + +# TS 23.040 §9.2.3.24 — User Data Header Information Element Identifiers +IEI_NAMES = { + 0x00: 'Concatenated short messages, 8-bit reference number', + 0x01: 'Special SMS Message Indication', + 0x04: 'Application port addressing scheme, 8 bit address', + 0x05: 'Application port addressing scheme, 16 bit address', + 0x06: 'SMSC Control Parameters', + 0x07: 'UDH Source Indicator', + 0x08: 'Concatenated short message, 16-bit reference number', + 0x09: 'Wireless Control Message Protocol', + 0x0A: 'Text Formatting', + 0x0B: 'Predefined Sound', + 0x0C: 'User Defined Sound', + 0x0D: 'Predefined Animation', + 0x0E: 'Large Animation', + 0x0F: 'Small Animation', + 0x10: 'Large Picture', + 0x11: 'Small Picture', + 0x12: 'Variable Picture', + 0x13: 'User prompt indicator', + 0x14: 'Extended Object', + 0x15: 'Reused Extended Object', + 0x16: 'Compression Control', + 0x17: 'Object Distribution Indicator', + 0x18: 'Standard WVG object', + 0x19: 'Character Size WVG object', + 0x1A: 'Extended Object Data Request Command', + 0x20: 'RFC 5322 E-Mail Header', + 0x21: 'Hyperlink format element', + 0x22: 'Reply Address Element', + 0x23: 'Enhanced Voice Mail Information', + 0x24: 'National Language Single Shift', + 0x25: 'National Language Locking Shift', + 0x26: 'Filler', +} + +_SPECIAL_SMS_TYPES = {0: 'voice', 1: 'fax', 2: 'email', 3: 'other'} + + +def _decode_udh(udh): + """Parse a TP-User-Data-Header byte string into a list of IEI dicts. + + Each element is ``{'iei', 'name', 'length', 'hex'}`` plus an IE-specific + ``'data'`` dict for the common cases (concatenation, application port, + special SMS indication). + """ + elements = [] + i = 0 + while i + 2 <= len(udh): + iei = udh[i] + length = udh[i + 1] + data = udh[i + 2:i + 2 + length] + i += 2 + length + el = {'iei': f'0x{iei:02X}', 'name': IEI_NAMES.get(iei), + 'length': length, 'hex': data.hex().upper()} + if iei == 0x00 and length >= 3: # concatenated, 8-bit reference + el['data'] = {'reference': data[0], 'max': data[1], 'seq': data[2]} + elif iei == 0x08 and length >= 4: # concatenated, 16-bit reference + el['data'] = {'reference': int.from_bytes(data[:2], 'big'), + 'max': data[2], 'seq': data[3]} + elif iei == 0x01 and length >= 2: # special SMS message indication + el['data'] = {'store': bool(data[0] & 0x80), + 'indication': _SPECIAL_SMS_TYPES.get(data[0] & 0x03), + 'count': data[1]} + elif iei == 0x04 and length >= 2: # application port, 8-bit + el['data'] = {'dest_port': data[0], 'orig_port': data[1]} + elif iei == 0x05 and length >= 4: # application port, 16-bit + el['data'] = {'dest_port': int.from_bytes(data[:2], 'big'), + 'orig_port': int.from_bytes(data[2:4], 'big')} + elements.append(el) + return elements + + +def _decode_vp_relative(v): + """Decode a 1-octet TP-Validity-Period (relative format, TS 23.040 §9.2.3.12.1).""" + if 0 <= v <= 143: + return f'{(v + 1) * 5} min' + if 144 <= v <= 167: + hours = 12 + (v - 143) // 2 + mins = ((v - 143) % 2) * 30 + return f'{hours}h{mins:02d}m' + if 168 <= v <= 196: + return f'{v - 166} d' + if 197 <= v <= 255: + return f'{v - 192} wk' + return None + + +def _decode_8bit_text(data): + """Best-effort printable text for 8-bit TP-UD data. + + If every byte is ASCII (<= 0x7F) or every byte has the high bit set + (>= 0x80, latin-1), decode to a printable string with non-printable + characters replaced by '·'. Mixed ranges are treated as binary and + return None. + """ + if not data: + return None + if all(b <= 0x7F for b in data): + return ''.join(chr(b) if 0x20 <= b < 0x7F else '\u00b7' for b in data) + if all(b >= 0x80 for b in data): + return ''.join(chr(b) if b >= 0xA0 else '\u00b7' for b in data) + return None + + def _decode_gsm7_octets(data): """Decode 8-bit-per-octet GSM 7-bit default alphabet text (TS 102 221).""" out = [] @@ -1085,7 +1537,16 @@ def _decode_sm_tpdu(data): result['pid'] = data[i] result['dcs'] = data[i + 1] i += 2 - i += 7 # TP-SCTS (service centre time stamp) + pid_name = _decode_pid(result['pid']) + if pid_name: + result['pid_name'] = pid_name + result['dcs_info'] = _decode_dcs_full(result['dcs']) + result['encoding'] = result['dcs_info']['encoding'] + result['msg_class'] = result['dcs_info']['msg_class'] + scts = _decode_datetime(data[i:i + 7]) # TP-SCTS (service centre time stamp) + if scts: + result['scts'] = scts + i += 7 if i < len(data): udl = data[i] i += 1 @@ -1106,9 +1567,24 @@ def _decode_sm_tpdu(data): result['pid'] = data[i] result['dcs'] = data[i + 1] i += 2 - if vpf in (1, 3): # 1-octet validity period + pid_name = _decode_pid(result['pid']) + if pid_name: + result['pid_name'] = pid_name + result['dcs_info'] = _decode_dcs_full(result['dcs']) + result['encoding'] = result['dcs_info']['encoding'] + result['msg_class'] = result['dcs_info']['msg_class'] + if vpf == 1: # relative: 1-octet validity period + if i < len(data): + vp = _decode_vp_relative(data[i]) + if vp: + result['vp'] = vp i += 1 - elif vpf == 2: # 7-octet relative validity period + elif vpf == 2: # absolute: 7-octet validity period (semi-octet time) + vp = _decode_datetime(data[i:i + 7]) + result['vp'] = vp if vp else data[i:i + 7].hex().upper() + i += 7 + elif vpf == 3: # enhanced: 7-octet validity period + result['vp'] = data[i:i + 7].hex().upper() i += 7 if i < len(data): udl = data[i] @@ -1131,7 +1607,8 @@ def _decode_ud(ud, udl, dcs, udhi, result): if udhi and ud: udhl = ud[0] - result['udh'] = ud[1:1 + udhl].hex().upper() + result['udhl'] = udhl + result['udh'] = _decode_udh(ud[1:1 + udhl]) body = ud[1 + udhl:] if encoding == 'GSM 7-bit': fill_bits = (udhl + 1) * 8 @@ -1141,6 +1618,9 @@ def _decode_ud(ud, udl, dcs, udhi, result): result['text'] = body[:udl].decode('utf-16-be', errors='replace') else: result['payload'] = body[:udl].hex().upper() + text = _decode_8bit_text(body[:udl]) + if text is not None: + result['text'] = text else: if encoding == 'GSM 7-bit': result['text'] = _decode_gsm7(ud, udl) @@ -1148,6 +1628,9 @@ def _decode_ud(ud, udl, dcs, udhi, result): result['text'] = ud[:udl].decode('utf-16-be', errors='replace') else: result['payload'] = ud[:udl].hex().upper() + text = _decode_8bit_text(ud[:udl]) + if text is not None: + result['text'] = text return result @@ -1221,7 +1704,7 @@ def _decode_envelope(body): for t, _l, v in inner: if t == 0x06: result['smsc'] = _decode_bcd_address(v) - elif t == 0x86: # SMS TPDU (SMS-DELIVER) + elif t in (0x0B, 0x8B): # SMS TPDU (SMS-DELIVER) result['tpdu'] = _decode_sm_tpdu(v) elif t == 0x02: result['device_ids'] = v.hex().upper() @@ -1285,8 +1768,8 @@ def _decode_proactive(body): items.append({'id': value[0], 'text': _decode_annex_a(value[1:])}) elif base == _P_SMS_TPDU and value: result['tpdu'] = _decode_sm_tpdu(value) - elif base == _P_DURATION and len(value) >= 2: - result['duration'] = (value[0] << 8) | value[1] + elif base == _P_DURATION: + result['duration'] = _decode_duration(value) elif base == _P_RESPONSE_LEN and len(value) >= 2: result['response_length'] = {'min': value[0], 'max': value[1]} elif base == _P_EVENT_LIST and value: @@ -1304,6 +1787,95 @@ def _decode_proactive(body): # ──────────────────── Decode entry point ──────────────────── +def _build_summary(result): + """Build a concise human-readable description of a decoded command. + + Used to replace raw hex in the APDU list view. Returns None when + nothing meaningful is extractable (caller falls back to raw hex). + Sensitive bodies (PIN, AUTH) are never included. + """ + parts = [] + ins = result.get('ins_hex') + + p1 = result.get('p1') + p2 = result.get('p2') + p1txt = p2txt = None + if 'p1p2' in result: + p1txt = f"{result['p1p2']['label']}: 0x{result['p1p2']['value']:04X}" + else: + if p1: + p1txt = p1.get('name') + if not p1txt and p1.get('label') is not None and p1.get('value') is not None: + p1txt = f"{p1['label']}: {p1['value']}" + if not p1txt and p1.get('bits'): + p1txt = ', '.join(p1['bits']) + if p2: + p2txt = p2.get('name') + if not p2txt and p2.get('bits'): + p2txt = ', '.join(p2['bits']) + + body = result.get('body') + bodytxt = None + if body and ins == 'a4': # SELECT → FID/AID is the object of the command + bodytxt = body['hex'].upper() + if body.get('note'): + bodytxt = f"{bodytxt} ({body['note']})" + + if ins == 'a4' and p1txt and bodytxt: + parts.append(f"{p1txt}: {bodytxt}") + if p2txt and p2txt != 'No indication': + parts.append(p2txt) + else: + if p1txt and p1txt != 'No indication': + parts.append(p1txt) + if p2txt and p2txt != 'No indication': + parts.append(p2txt) + if bodytxt: + parts.append(bodytxt) + + cmd = result.get('cmd') + if cmd: + if cmd.get('context'): + parts.append(cmd['context']) + if cmd.get('title'): + parts.append(cmd['title']) + items = cmd.get('items') + if items: + n = len(items) + parts.append(f"{n} item{'s' if n != 1 else ''}") + if cmd.get('text'): + parts.append(cmd['text']) + if cmd.get('smsc'): + parts.append(f"SMSC {cmd['smsc']}") + if cmd.get('events'): + parts.append(', '.join(cmd['events'])) + if cmd.get('address'): + parts.append(f"to {cmd['address']}") + duration = cmd.get('duration') + if isinstance(duration, dict) and duration.get('value') is not None: + parts.append(f"{duration['value']} {duration['unit']}") + tpdu = cmd.get('tpdu') + if tpdu: + s = tpdu.get('mti', '') + if tpdu.get('oa'): + s += f" from {tpdu['oa']}" + if tpdu.get('da'): + s += f" to {tpdu['da']}" + if tpdu.get('text'): + s += f" \u00ab{tpdu['text']}\u00bb" + parts.append(s) + + if result.get('response_to'): + parts.append('\u2192 ' + result['response_to']) + if result.get('response_for'): + parts.append('response for ' + result['response_for']) + response = result.get('response') + if response and response.get('name'): + parts.append(response['name']) + + return ', '.join(parts) if parts else None + + def decode_message(raw_data, prev=None): """Decode a raw TPDU byte string into a structured dict. @@ -1373,6 +1945,9 @@ def decode_message(raw_data, prev=None): if h in fids: result['body']['note'] = fids[h] + if ins == 0xA4 and p1 in (0x08, 0x09): # SELECT by path (from MF / current DF) + result['body']['note'] = _decode_select_path(body.hex()) + if ins == 0x14: response_to = decode_tr_command(body) if response_to: @@ -1406,6 +1981,8 @@ def decode_message(raw_data, prev=None): else: result['response_for'] = None + result['summary'] = _build_summary(result) + return result @@ -1454,6 +2031,217 @@ def decode_fidi(raw_data): } +# ──────────────────── ATR / PPS decoding (ISO 7816-3) ──────────────────── + +# TA1/PPS1: Fi (clock rate conversion factor), f(max), Di (baud rate adjustment) +_ATR_FI = {0: 372, 1: 372, 2: 558, 3: 744, 4: 1116, 5: 1488, 6: 1860, + 9: 512, 10: 768, 11: 1024, 12: 1536, 13: 2048} +_ATR_FMAX = {0: 4, 1: 5, 2: 6, 3: 8, 4: 12, 5: 16, 6: 20, + 9: 5, 10: 7.5, 11: 10, 12: 15, 13: 20} +_ATR_DI = {1: 1, 2: 2, 3: 4, 4: 8, 5: 16, 6: 32, 7: 64, 8: 12, 9: 20} + +_T_PROTOCOLS = {0: 'T=0', 1: 'T=1', 2: 'T=2', 3: 'T=3', 4: 'T=4', 14: 'T=14', 15: 'T=15'} + +# TA3 (first TA for T=15): clock stop indicator (bits 8-7) + class indicator (bits 1-3) +_CLOCK_STOP = {0: 'clock stop not supported', 1: 'state L preferred', + 2: 'state H preferred', 3: 'no preference'} +_CLASS_BITS = {0x01: 'A', 0x02: 'B', 0x04: 'C'} + + +def _bit_swap(b): + """Reverse the bits of a byte (inverse convention ATR decoding).""" + return int(f'{b:08b}'[::-1], 2) + + +def _fidi_dict(fi, di): + """Decode an Fi/Di nibble pair into a display dict.""" + entry = {'fi': fi, 'di': di} + f = _ATR_FI.get(fi) + d = _ATR_DI.get(di) + if f is not None: + entry['f'] = f + if d is not None: + entry['d'] = d + if f is not None and d is not None: + f_div_d = f / d + entry['f_div_d'] = int(f_div_d) if f_div_d == int(f_div_d) else f_div_d + if fi in _ATR_FMAX: + entry['f_max'] = _ATR_FMAX[fi] + return entry + + +def _decode_historical(hist): + """Decode ATR historical bytes (ISO 7816-4 §8.1.1).""" + result = {'raw': hist.hex().upper()} + cat = hist[0] + if cat == 0x80 and len(hist) >= 3: + result['category'] = 'status information (life cycle)' + result['sw'] = f'{hist[1]:02X}{hist[2]:02X}' + life_cycle = _LIFE_CYCLE.get(hist[2]) + if life_cycle: + result['life_cycle'] = life_cycle + elif cat == 0x8F: + result['category'] = 'TLV list' + elif 0x00 <= cat <= 0x7F or 0x90 <= cat <= 0x9F: + result['category'] = 'proprietary' + elif 0x81 <= cat <= 0x8E or 0xA0 <= cat <= 0xFE: + result['category'] = 'reserved' + elif cat == 0xFF: + result['category'] = 'default' + else: + result['category'] = f'0x{cat:02X}' + return result + + +def _decode_atr(data): + """Decode an Answer-To-Reset (ISO 7816-3 §8) into a structured dict.""" + if not data: + return {'type': 'atr', 'raw': ''} + ts = data[0] + raw = data.hex().upper() + if ts not in (0x3B, 0x3F): + return {'type': 'atr', 'convention': 'unknown', 'raw': raw} + inverse = ts == 0x3F + body = data[1:] + if inverse: + body = bytes(_bit_swap(b) for b in body) + + result = { + 'type': 'atr', + 'ts': f'{ts:02X}', + 'convention': 'inverse' if inverse else 'direct', + 'raw': raw, + } + if not body: + return result + + t0 = body[0] + result['t0'] = f'{t0:02X}' + result['historical_len'] = t0 & 0x0F + + interface = [] + protocols = [] + flags = t0 & 0xF0 + i = 1 + level = 1 + cur_t = None # protocol T governing the TA/TB/TC bytes at this level (None = global) + + while flags and i < len(body): + if flags & 0x10: # TA + b = body[i] + i += 1 + if level == 1: + entry = {'name': 'TA1', 'hex': f'{b:02X}'} + entry.update(_fidi_dict(b >> 4, b & 0x0F)) + elif level == 2: + entry = {'name': 'TA2', 'hex': f'{b:02X}', + 'specific': bool(b & 0x10), + 'protocol': _T_PROTOCOLS.get(b & 0x0F, f'T={b & 0x0F}')} + elif cur_t == 15: # first TA for T=15 → clock stop + class indicator + classes = [name for bit, name in _CLASS_BITS.items() if b & bit] + entry = {'name': f'TA{level}', 'hex': f'{b:02X}', + 'clock_stop': _CLOCK_STOP.get((b >> 6) & 0x03), + 'classes': classes} + else: # TA3 for T=1 → IFSC + entry = {'name': f'TA{level}', 'hex': f'{b:02X}', 'ifsc': b} + interface.append(entry) + if flags & 0x20: # TB + b = body[i] + i += 1 + if level == 1: + interface.append({'name': 'TB1', 'hex': f'{b:02X}', + 'programming': 'not used' if b == 0 else f'0x{b:02X}'}) + elif level == 2: + interface.append({'name': 'TB2', 'hex': f'{b:02X}', + 'programming': 'not used' if b == 0 else f'0x{b:02X}'}) + elif cur_t == 15: # first TB for T=15 → SPU + interface.append({'name': f'TB{level}', 'hex': f'{b:02X}', + 'spu': 'not used' if b == 0 else f'0x{b:02X}'}) + else: # TB3 for T=1 → BWI/CWI + interface.append({'name': f'TB{level}', 'hex': f'{b:02X}', + 'bwi': b >> 4, 'cwi': b & 0x0F}) + if flags & 0x40: # TC + b = body[i] + i += 1 + if level == 1: + interface.append({'name': 'TC1', 'hex': f'{b:02X}', 'extra_guard_time': b}) + elif level == 2: + interface.append({'name': 'TC2', 'hex': f'{b:02X}', 'work_waiting_time': b}) + else: + interface.append({'name': f'TC{level}', 'hex': f'{b:02X}'}) + if flags & 0x80: # TD + b = body[i] + i += 1 + cur_t = b & 0x0F + protocols.append(_T_PROTOCOLS.get(cur_t, f'T={cur_t}')) + flags = b & 0xF0 + level += 1 + else: + flags = 0 + + if interface: + result['interface'] = interface + if protocols: + result['protocols'] = protocols + + k = result['historical_len'] + if k and i + k <= len(body): + result['historical'] = _decode_historical(body[i:i + k]) + i += k + + # TCK present unless only T=0 is proposed (ISO 7816-3 §8.2.5). + only_t0 = not protocols or protocols == ['T=0'] + if not only_t0 and i < len(body): + tck = body[i] + check = 0 + for b in body[:i]: + check ^= b + result['tck'] = f'{tck:02X}' + result['tck_valid'] = check == tck + + return result + + +def _decode_pps(data): + """Decode a Protocol and Parameter Selection exchange (ISO 7816-3 §9.2).""" + if not data: + return {'type': 'pps', 'raw': ''} + raw = data.hex().upper() + result = {'type': 'pps', 'raw': raw} + if data[0] != 0xFF: + result['note'] = 'missing PPSS' + return result + result['ppss'] = 'FF' + if len(data) < 2: + return result + pps0 = data[1] + result['pps0'] = f'{pps0:02X}' + result['protocol'] = _T_PROTOCOLS.get(pps0 & 0x0F, f'T={pps0 & 0x0F}') + i = 2 + if pps0 & 0x10 and i < len(data): # PPS1 → Fi/Di + pps1 = data[i] + i += 1 + entry = {'pps1': f'{pps1:02X}'} + entry.update(_fidi_dict(pps1 >> 4, pps1 & 0x0F)) + result['fi_di'] = entry + if pps0 & 0x20 and i < len(data): # PPS2 → SPU + pps2 = data[i] + i += 1 + result['pps2'] = f'{pps2:02X}' + result['spu'] = 'not used' if pps2 == 0 else f'0x{pps2:02X}' + if pps0 & 0x40 and i < len(data): # PPS3 (reserved) + result['pps3'] = f'{data[i]:02X}' + i += 1 + if i < len(data): + pck = data[i] + check = 0 + for b in data[:i]: + check ^= b + result['pck'] = f'{pck:02X}' + result['pck_valid'] = check == pck + return result + + def decode_sniff_msg(raw_data, msg_type, flags=0, prev=None): """Decode a raw sniff message of the given type. @@ -1470,9 +2258,9 @@ def decode_sniff_msg(raw_data, msg_type, flags=0, prev=None): if msg_type == 'fidi' and raw_data: return decode_fidi(raw_data) if msg_type == 'atr': - return {'type': 'atr', 'hex': raw_data.hex() if raw_data else ''} + return _decode_atr(raw_data) if msg_type == 'pps': - return {'type': 'pps', 'hex': raw_data.hex() if raw_data else ''} + return _decode_pps(raw_data) return None diff --git a/tests/test_decode.py b/tests/test_decode.py index ee78113..46ce6d9 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -92,6 +92,21 @@ class TestCatDecoding(unittest.TestCase): self.assertEqual(r['ins_name'], 'SELECT') self.assertNotIn('cat_command', r) + def test_envelope_sms_pp_download(self): + r = decode_message(bytes.fromhex( + '80C2000022D120020283810607919740430900F40B1104038154F50004' + '260812143000210248699000')) + self.assertEqual(r['ins_name'], 'ENVELOPE') + self.assertEqual(r['cat_command'], 'SMS-PP DOWNLOAD') + cmd = r['cmd'] + self.assertEqual(cmd['smsc'], '+79043490004') + self.assertEqual(cmd['device_ids'], '8381') + t = cmd['tpdu'] + self.assertEqual(t['mti'], 'SMS-DELIVER') + self.assertEqual(t['oa'], '455') + self.assertEqual(t['text'], 'Hi') + self.assertEqual(t['scts'], '2026-08-12 14:30:00 (UTC+03:00)') + class TestCommandTypeTables(unittest.TestCase): def test_known_values(self): @@ -267,7 +282,7 @@ class TestTrAdditionalInfo(unittest.TestCase): r = decode_message(bytes.fromhex( '80140000108103010300020282810301000402011E9000')) self.assertEqual(r['response_to'], 'POLL INTERVAL') - self.assertEqual(r['response']['duration'], 286) + self.assertEqual(r['response']['duration'], {'value': 30, 'unit': 'seconds'}) def test_pli_location_info(self): r = decode_message(bytes.fromhex( @@ -291,6 +306,14 @@ class TestTrAdditionalInfo(unittest.TestCase): _decode_datetime(bytes.fromhex('260812143000FF')), '2026-08-12 14:30:00 (UTCunknown)') + def test_datetime_invalid_bcd(self): + from simtrace2_pysniff.server.decode import _decode_datetime + self.assertIsNone(_decode_datetime(bytes.fromhex('A505326C333939'))) + + def test_datetime_invalid_semantics(self): + from simtrace2_pysniff.server.decode import _decode_datetime + self.assertIsNone(_decode_datetime(bytes.fromhex('02209121654021'))) + def test_pli_language(self): from simtrace2_pysniff.server.decode import _decode_local_info, PLI_LANGUAGE self.assertEqual(_decode_local_info(PLI_LANGUAGE, b'en'), @@ -404,12 +427,14 @@ class TestSmTpdu(unittest.TestCase): def test_8bit_no_text(self): from simtrace2_pysniff.server.decode import _decode_sm_tpdu oa = b'\x91' + self._bcd('79031234567') + # Mixed low/high bytes are treated as binary → no text decode. + payload = bytes.fromhex('00018081A0FF07') tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x04' + b'\x00' * 7 + - b'\x10' + bytes(range(16))) + bytes([len(payload)]) + payload) r = _decode_sm_tpdu(tpdu) self.assertEqual(r['encoding'], '8-bit data') self.assertNotIn('text', r) - self.assertEqual(r['payload'], '000102030405060708090A0B0C0D0E0F') + self.assertEqual(r['payload'], '00018081A0FF07') def test_sim_data_download(self): from simtrace2_pysniff.server.decode import _decode_sm_tpdu @@ -420,6 +445,139 @@ class TestSmTpdu(unittest.TestCase): self.assertEqual(r['pid_name'], 'SIM data download (secured packet)') self.assertEqual(r['payload'], '000102030405060708090A0B0C0D0E0F') + def test_deliver_scts(self): + from simtrace2_pysniff.server.decode import _decode_sm_tpdu + oa = b'\x91' + self._bcd('79031234567') + scts = bytes.fromhex('26081214300021') + tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x00' + scts + b'\x00') + r = _decode_sm_tpdu(tpdu) + self.assertEqual(r['scts'], '2026-08-12 14:30:00 (UTC+03:00)') + + def test_deliver_pid_name(self): + from simtrace2_pysniff.server.decode import _decode_sm_tpdu + oa = b'\x91' + self._bcd('79031234567') + tpdu = (b'\x00' + bytes([11]) + oa + b'\x48\x00' + b'\x00' * 7 + b'\x00') + r = _decode_sm_tpdu(tpdu) + self.assertEqual(r['pid_name'], 'Device Triggering Short Message') + + def test_deliver_dcs_info(self): + from simtrace2_pysniff.server.decode import _decode_sm_tpdu + oa = b'\x91' + self._bcd('79031234567') + tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x11' + b'\x00' * 7 + b'\x00') + r = _decode_sm_tpdu(tpdu) + self.assertEqual(r['dcs_info']['group'], 'Message Marked for Automatic Deletion') + self.assertEqual(r['dcs_info']['has_class'], True) + self.assertEqual(r['dcs_info']['msg_class'], 1) + + def test_dcs_mwi(self): + from simtrace2_pysniff.server.decode import _decode_dcs_full + d = _decode_dcs_full(0xD0) + self.assertEqual(d['group'], 'Message Waiting Indication') + self.assertEqual(d['action'], 'store') + self.assertEqual(d['sense'], 'inactive') + self.assertEqual(d['indication'], 'voicemail') + + def test_udh_concat_8bit(self): + from simtrace2_pysniff.server.decode import _decode_udh + el = _decode_udh(bytes.fromhex('0003A10201')) + self.assertEqual(el[0]['name'], 'Concatenated short messages, 8-bit reference number') + self.assertEqual(el[0]['data'], {'reference': 0xA1, 'max': 2, 'seq': 1}) + + def test_udh_concat_16bit(self): + from simtrace2_pysniff.server.decode import _decode_udh + el = _decode_udh(bytes.fromhex('080403000201')) + self.assertEqual(el[0]['name'], 'Concatenated short message, 16-bit reference number') + self.assertEqual(el[0]['data'], {'reference': 0x0300, 'max': 2, 'seq': 1}) + + def test_udh_app_port_8bit(self): + from simtrace2_pysniff.server.decode import _decode_udh + el = _decode_udh(bytes.fromhex('0402B5A5')) + self.assertEqual(el[0]['data'], {'dest_port': 0xB5, 'orig_port': 0xA5}) + + def test_udh_app_port_16bit(self): + from simtrace2_pysniff.server.decode import _decode_udh + el = _decode_udh(bytes.fromhex('05040B8423F0')) + self.assertEqual(el[0]['data'], {'dest_port': 0x0B84, 'orig_port': 0x23F0}) + + def test_udh_special_sms(self): + from simtrace2_pysniff.server.decode import _decode_udh + el = _decode_udh(bytes.fromhex('01028102')) + self.assertEqual(el[0]['data'], {'store': True, 'indication': 'fax', 'count': 2}) + + def test_deliver_udh_ucs2(self): + from simtrace2_pysniff.server.decode import _decode_sm_tpdu + oa = b'\x91' + self._bcd('79031234567') + ud = bytes.fromhex('050003A10201') + 'Hi'.encode('utf-16-be') + tpdu = (b'\x40' + bytes([11]) + oa + b'\x00\x08' + b'\x00' * 7 + + bytes([len(ud)]) + ud) + r = _decode_sm_tpdu(tpdu) + self.assertEqual(r['udh'][0]['data'], {'reference': 0xA1, 'max': 2, 'seq': 1}) + self.assertEqual(r['text'], 'Hi') + + def test_submit_vp_relative(self): + from simtrace2_pysniff.server.decode import _decode_sm_tpdu + da = b'\x81' + self._bcd('999') + tpdu = b'\x09' + b'\x2A' + bytes([len(da)]) + da + b'\x00\x00' + b'\x8F' + b'\x00' + r = _decode_sm_tpdu(tpdu) + self.assertEqual(r['mti'], 'SMS-SUBMIT') + self.assertEqual(r['vp'], '720 min') + + def test_submit_vp_absolute(self): + from simtrace2_pysniff.server.decode import _decode_sm_tpdu + da = b'\x81' + self._bcd('999') + scts = bytes.fromhex('26081214300021') + tpdu = b'\x11' + b'\x2A' + bytes([len(da)]) + da + b'\x00\x00' + scts + b'\x00' + r = _decode_sm_tpdu(tpdu) + self.assertEqual(r['vp'], '2026-08-12 14:30:00 (UTC+03:00)') + + def test_submit_no_ud_encoding(self): + # Real capture: absolute VP with non-BCD bytes, no TP-UD. + from simtrace2_pysniff.server.decode import _decode_sm_tpdu + tpdu = bytes.fromhex('11FF038154F50004A505326C333939') + r = _decode_sm_tpdu(tpdu) + self.assertEqual(r['da'], '455') + self.assertEqual(r['encoding'], '8-bit data') + self.assertEqual(r['msg_class'], 0) + self.assertEqual(r['vp'], 'A505326C333939') + + def test_8bit_ascii_text(self): + from simtrace2_pysniff.server.decode import _decode_sm_tpdu + oa = b'\x91' + self._bcd('79031234567') + payload = b'Hello World' + tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x04' + b'\x00' * 7 + + bytes([len(payload)]) + payload) + r = _decode_sm_tpdu(tpdu) + self.assertEqual(r['encoding'], '8-bit data') + self.assertEqual(r['text'], 'Hello World') + + def test_8bit_ascii_ctrl_replaced(self): + from simtrace2_pysniff.server.decode import _decode_sm_tpdu + oa = b'\x91' + self._bcd('79031234567') + payload = b'Hi\x00There' + tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x04' + b'\x00' * 7 + + bytes([len(payload)]) + payload) + r = _decode_sm_tpdu(tpdu) + self.assertEqual(r['text'], 'Hi\u00b7There') + + def test_8bit_high_text(self): + from simtrace2_pysniff.server.decode import _decode_sm_tpdu + oa = b'\x91' + self._bcd('79031234567') + payload = '\u00e9\u00fc\u00f1'.encode('latin-1') + tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x04' + b'\x00' * 7 + + bytes([len(payload)]) + payload) + r = _decode_sm_tpdu(tpdu) + self.assertEqual(r['text'], '\u00e9\u00fc\u00f1') + + def test_8bit_mixed_no_text(self): + from simtrace2_pysniff.server.decode import _decode_sm_tpdu + oa = b'\x91' + self._bcd('79031234567') + payload = b'\x00\x01\xff\xfe' + tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x04' + b'\x00' * 7 + + bytes([len(payload)]) + payload) + r = _decode_sm_tpdu(tpdu) + self.assertNotIn('text', r) + self.assertEqual(r['payload'], '0001FFFE') + class TestBcdAddress(unittest.TestCase): def test_international(self): @@ -453,7 +611,7 @@ class TestProactiveDecode(unittest.TestCase): def test_poll_interval_duration(self): r = decode_message(bytes.fromhex( '801200000FD00D8103010300820281820402011E9000')) - self.assertEqual(r['cmd']['duration'], 286) + self.assertEqual(r['cmd']['duration'], {'value': 30, 'unit': 'seconds'}) def test_send_short_message_tpdu(self): r = decode_message(bytes.fromhex( @@ -464,6 +622,18 @@ class TestProactiveDecode(unittest.TestCase): self.assertEqual(r['cmd']['tpdu']['mti'], 'SMS-SUBMIT') self.assertEqual(r['cmd']['tpdu']['da'], '999') + def test_send_short_message_rich_tpdu(self): + r = decode_message(bytes.fromhex( + '801200001AD01881030113008202818305000B0B0101038199F9000402' + '48699000')) + t = r['cmd']['tpdu'] + self.assertEqual(r['cmd']['type'], 'SEND SHORT MESSAGE') + self.assertEqual(t['mti'], 'SMS-SUBMIT') + self.assertEqual(t['da'], '999') + self.assertEqual(t['pid_name'], 'SME-to-SME (implicit)') + self.assertEqual(t['encoding'], '8-bit data') + self.assertEqual(t['text'], 'Hi') + def test_setup_menu_long_form_length(self): # Real Mi A1 startup trace: D0 length encoded as 0x81 0xBC (188 bytes). r = decode_message(bytes.fromhex( @@ -508,5 +678,147 @@ class TestDcsText(unittest.TestCase): self.assertEqual(_decode_dcs_text(b'\x04caf\xe9'), 'café') +class TestAtrPps(unittest.TestCase): + def test_atr_simple_t0_only(self): + from simtrace2_pysniff.server.decode import _decode_atr + # 3B 00: direct, T0=00, no interface bytes, 0 historical bytes, only T=0 → no TCK + r = _decode_atr(bytes.fromhex('3B00')) + self.assertEqual(r['convention'], 'direct') + self.assertEqual(r['t0'], '00') + self.assertEqual(r['historical_len'], 0) + self.assertNotIn('tck', r) + + def test_atr_ta1(self): + from simtrace2_pysniff.server.decode import _decode_atr + # 3B 12 11 00 00: TA1=11 (Fi=1,Di=1), 2 historical bytes, T=0 → no TCK + r = _decode_atr(bytes.fromhex('3B12110000')) + self.assertEqual(r['convention'], 'direct') + self.assertEqual(r['interface'][0]['name'], 'TA1') + self.assertEqual(r['interface'][0]['f'], 372) + self.assertEqual(r['interface'][0]['d'], 1) + self.assertEqual(r['historical_len'], 2) + self.assertNotIn('tck', r) + + def test_atr_td1_t1_has_tck(self): + from simtrace2_pysniff.server.decode import _decode_atr + # 3B 80 01 80 31 80 66 ... : construct T=1 with TCK + # T0=80 → TD1 present, 0 historical; TD1=01 (T=1, no more interface); then TCK + # bytes: 3B 80 01 xx where xx = XOR(80,01) = 81 + r = _decode_atr(bytes.fromhex('3B800181')) + self.assertEqual(r['protocols'], ['T=1']) + self.assertIn('tck', r) + self.assertTrue(r['tck_valid']) + self.assertEqual(r['tck'], '81') + + def test_atr_inverse_convention(self): + from simtrace2_pysniff.server.decode import _decode_atr + # Inverse: TS=3F, then inverted bytes. Take direct ATR "3B 00" and invert body. + # Inverted 0x00 is 0x00, so ATR = 3F 00 → convention inverse, T0=00. + r = _decode_atr(bytes.fromhex('3F00')) + self.assertEqual(r['convention'], 'inverse') + self.assertEqual(r['t0'], '00') + + def test_atr_historical_life_cycle(self): + from simtrace2_pysniff.server.decode import _decode_atr + # T0 = 0x03 (3 historical bytes); historical = 80 00 05 (life cycle: operational) + r = _decode_atr(bytes.fromhex('3B03800005')) + self.assertEqual(r['historical_len'], 3) + self.assertEqual(r['historical']['category'], 'status information (life cycle)') + self.assertEqual(r['historical']['life_cycle'], 'operational state (activated)') + + def test_pps(self): + from simtrace2_pysniff.server.decode import _decode_pps + # FF 10 11 xx: PPS0=10 (T=0, PPS1 present), PPS1=11 (Fi=1,Di=1), PCK=XOR(FF,10,11)=FE + r = _decode_pps(bytes.fromhex('FF1011FE')) + self.assertEqual(r['protocol'], 'T=0') + self.assertEqual(r['fi_di']['f'], 372) + self.assertEqual(r['fi_di']['d'], 1) + self.assertTrue(r['pck_valid']) + self.assertEqual(r['pck'], 'FE') + + +class TestSummary(unittest.TestCase): + def test_select_path_from_mf(self): + r = decode_message(bytes.fromhex('a0a40804022fe29000')) + self.assertEqual(r['summary'], 'Path from MF: 2FE2 (EF_ICCID), Return FCI template') + + def test_select_by_file_id(self): + r = decode_message(bytes.fromhex('a0a40000023f009000')) + self.assertEqual(r['summary'], 'DF/EF/MF by file ID: 3F00 (MF)') + + def test_read_record(self): + r = decode_message(bytes.fromhex('a0b2010400')) + self.assertEqual(r['summary'], 'Record number: 1, use record number from P1') + + def test_verify_pin_no_leak(self): + r = decode_message(bytes.fromhex('0020000000')) + self.assertEqual(r['summary'], 'Verify PIN1') + + def test_fetch_setup_menu(self): + r = decode_message(bytes.fromhex( + '8012000030D02E810301250082028182050B416C6661204D6F62696C' + '658F16808112089DB0C1C2C0BEB9BAB82F53657474696E67739000')) + self.assertEqual(r['summary'], 'Alfa Mobile, 1 item') + + def test_envelope_sms_pp(self): + r = decode_message(bytes.fromhex( + '80C2000022D120020283810607919740430900F40B1104038154F50004' + '260812143000210248699000')) + self.assertEqual(r['summary'], 'SMSC +79043490004, SMS-DELIVER from 455 «Hi»') + + def test_terminal_response(self): + r = decode_message(bytes.fromhex( + '80140000108103010300020282810301000402011E9000')) + self.assertEqual(r['summary'], '→ POLL INTERVAL, Command performed successfully') + + def test_get_response_for(self): + r = decode_message(bytes.fromhex('a0c000000f'), + prev={'ins_name': 'SELECT', 'sw1': '61'}) + self.assertEqual(r['summary'], 'response for SELECT') + + +class TestSelectPath(unittest.TestCase): + def test_path_adf_alias(self): + # 7FFF = current ADF; 6F05 after it → EF_LI (USIM). + r = decode_message(bytes.fromhex('a0a40804047fff6f059000')) + self.assertEqual(r['body']['note'], 'current ADF/EF_LI') + self.assertEqual(r['summary'], + 'Path from MF: 7FFF6F05 (current ADF/EF_LI), Return FCI template') + + def test_path_full(self): + r = decode_message(bytes.fromhex('a0a40804067f107f206f079000')) + self.assertEqual(r['body']['note'], 'DF_TELECOM/DF_GSM/EF_IMSI') + + def test_path_current_df(self): + # P1 = 0x09 → 'Path from current DF' (no 7FFF/3F00 prefix). + r = decode_message(bytes.fromhex('a0a40904046f076f049000')) + self.assertEqual(r['p1']['name'], 'Path from current DF') + self.assertEqual(r['body']['note'], 'EF_IMSI/EF_IMPU') + + def test_path_isim_adf_alias(self): + # 7FFF 6F04 → EF_IMPU (ISIM). + r = decode_message(bytes.fromhex('a0a40804047fff6f049000')) + self.assertEqual(r['body']['note'], 'current ADF/EF_IMPU') + + def test_path_df_child(self): + # 7FFF 5F3B 4F20 → current ADF / DF_GSM_ACCESS / EF_Kc. + r = decode_message(bytes.fromhex('a0a40804067fff5f3b4f209000')) + self.assertEqual(r['body']['note'], 'current ADF/DF_GSM_ACCESS/EF_Kc') + + def test_path_df_child_disambiguates_fid(self): + # 7F10 5F3B 4F47 → DF_TELECOM / DF_MULTIMEDIA / EF_MML (5F3B is + # DF_MULTIMEDIA here, not DF_GSM_ACCESS). + r = decode_message(bytes.fromhex('a0a40804067f105f3b4f479000')) + self.assertEqual(r['body']['note'], 'DF_TELECOM/DF_MULTIMEDIA/EF_MML') + + def test_path_phonebook(self): + r = decode_message(bytes.fromhex('a0a40804067f105f3a4f229000')) + self.assertEqual(r['body']['note'], 'DF_TELECOM/DF_PHONEBOOK/EF_PSC') + + def test_path_5gs(self): + r = decode_message(bytes.fromhex('a0a40804067fff5fc04f019000')) + self.assertEqual(r['body']['note'], 'current ADF/DF_5GS/EF_5GS3GPPLOCI') + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 67b6ffa..8ccadb2 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.2.0') + self.assertEqual(__version__, '1.3.0') def test_status_includes_version(self): import os
Raw APDU${fmt_data(msg.data, 9999)}
CLA0x${(cla.hex || '').toUpperCase()} — ${claInfo}
INS0x${(decoded.ins_hex || '').toUpperCase()} — ${decoded.ins_name}