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:
catarrh committed 2026-08-15 21:24:50 +03:00
1 parent ef62956922
commit 143e1854e2
7 files changed
+1242 -41

No files matched your search

+115 -14
View File
@@ -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
View File
@@ -1,4 +1,4 @@
const CACHE = 'simtrace-analyser-v4';
const CACHE = 'simtrace-analyser-v5';
const PRECACHE = [
'index.html',
+1 -1
View File
@@ -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"}
+1 -1
View File
@@ -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',
+807 -19
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -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