Add ATR/PPS + SMS TPDU decoding, command summaries, SW column
- Decode ATR (convention, interface bytes, historical, TCK) and PPS (Fi/Di, PCK) - Decode ENVELOPE SMS-PP DOWNLOAD + SEND SHORT MESSAGE TPDU: SCTS, VP, DCS/PID, UDH IEIs, best-effort 8-bit text - Fix Duration data object ([time unit][interval], not a 16-bit int) - List view: command summary instead of raw hex, SW column, raw APDU first line in detail view - Expand FID tables (full pySim USIM/ISIM/SIM set) + context-aware SELECT-path resolution (7FFF = current ADF) - Fix SELECT P1 names (0x09 = path from current DF) - Bump version to 1.3.0
This commit is contained in:
1 parent
ef62956922
commit
143e1854e2
7 files changed
+1242
-41
No files matched your search
+115
-14
@@ -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]) => `<tr><td class="label">${l}</td><td class="value">${v}</td></tr>`).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 = `<div class="detail-header"><strong>${decoded.type.toUpperCase()}</strong><span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</span></div>
|
||||
<div class="detail-body">
|
||||
<table>
|
||||
<tr><td class="label">Data</td><td class="hex">${(decoded.hex || '').toUpperCase()}</td></tr>
|
||||
${rows.map(([l, v]) => `<tr><td class="label">${l}</td><td class="hex">${v}</td></tr>`).join('')}
|
||||
</table>
|
||||
</div>`;
|
||||
return;
|
||||
@@ -687,6 +786,7 @@ function showDetail(msg, entry) {
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<table>
|
||||
<tr><td class="label">Raw APDU</td><td class="hex">${fmt_data(msg.data, 9999)}</td></tr>
|
||||
<tr><td class="label">CLA</td><td class="value">0x${(cla.hex || '').toUpperCase()} — ${claInfo}</td></tr>
|
||||
<tr><td class="label">INS</td><td class="name">0x${(decoded.ins_hex || '').toUpperCase()} — ${decoded.ins_name}</td></tr>
|
||||
${catRow}${respRow}${trRow}
|
||||
@@ -821,7 +921,8 @@ function renderMessages() {
|
||||
return `<div class="apdu-entry" data-idx="${idx}">
|
||||
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
||||
<span class="apdu-type ${typeCls}">${label}</span>
|
||||
<span class="apdu-data">${data_text(m)}</span>
|
||||
<span class="apdu-sw">${sw_text(m)}</span>
|
||||
<span class="apdu-data">${cmd_description(m)}</span>
|
||||
</div>`;
|
||||
}).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) {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simtrace-analyser-v4';
|
||||
const CACHE = 'simtrace-analyser-v5';
|
||||
|
||||
const PRECACHE = [
|
||||
'index.html',
|
||||
|
||||
+1
-1
@@ -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"}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+316
-4
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user