Files
simtrace-analyser/simtrace2_pysniff/server/decode.py
T
catarrh 143e1854e2 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
2026-08-15 21:24:50 +03:00

2288 lines
76 KiB
Python

"""APDU/TPDU decoding — CLA, INS, P1/P2, body, and status word.
Pure Python, no dependencies. Decodes raw TPDU bytes captured by
the SIMtrace2 sniffer into structured dicts for the PWA to display.
"""
# ──────────────────── Status Word names ────────────────────
SW_NAMES = {
(0x90, 0x00): 'Normal ending',
(0x91, 0x00): 'Normal ending, proactive command pending',
(0x92, 0x00): 'Normal ending after x bytes',
(0x61, 0x00): 'Response data available',
(0x62, 0x00): 'Warning — no information',
(0x62, 0x81): 'Warning — part of data may be corrupted',
(0x62, 0x82): 'Warning — EOF reached before reading Le bytes',
(0x62, 0x83): 'Warning — selected file deactivated',
(0x62, 0x84): 'Warning — FCI not formatted per ISO',
(0x63, 0x00): 'Warning — no information',
(0x63, 0xc1): 'Warning — 1 retry remaining',
(0x63, 0xc2): 'Warning — 2 retries remaining',
(0x63, 0xc3): 'Warning — 3 retries remaining',
(0x64, 0x00): 'Execution error — no information',
(0x65, 0x00): 'Execution error — memory failure',
(0x65, 0x81): 'Execution error — memory failure',
(0x66, 0x00): 'Security error — no information',
(0x66, 0x81): 'Reserved for security-related issues',
(0x67, 0x00): 'Checking error — wrong length',
(0x68, 0x00): 'Checking error — no information',
(0x68, 0x81): 'Logical channel not supported',
(0x68, 0x82): 'Secure messaging not supported',
(0x68, 0x83): 'Last command of chain expected',
(0x68, 0x84): 'Command chaining not supported',
(0x69, 0x00): 'Checking error — no information',
(0x69, 0x81): 'Command incompatible with file structure',
(0x69, 0x82): 'Security status not satisfied',
(0x69, 0x83): 'Authentication method blocked',
(0x69, 0x84): 'Referenced data invalidated',
(0x69, 0x85): 'Conditions of use not satisfied',
(0x69, 0x86): 'Command not allowed (no current EF)',
(0x69, 0x87): 'Expected SM data objects missing',
(0x69, 0x88): 'SM data objects incorrect',
(0x6a, 0x00): 'Checking error — no information',
(0x6a, 0x80): 'Incorrect data in command data',
(0x6a, 0x81): 'Function not supported',
(0x6a, 0x82): 'File not found',
(0x6a, 0x83): 'Record not found',
(0x6a, 0x84): 'Not enough memory space in file',
(0x6a, 0x85): 'Nc inconsistent with TLV structure',
(0x6a, 0x86): 'Incorrect P1-P2',
(0x6a, 0x87): 'Nc inconsistent with P1-P2',
(0x6a, 0x88): 'Referenced data not found',
(0x6a, 0x89): 'File already exists',
(0x6a, 0x8a): 'DF name already exists',
(0x6b, 0x00): 'Wrong parameters P1-P2',
(0x6d, 0x00): 'Instruction not supported or invalid',
(0x6e, 0x00): 'Class not supported',
(0x6f, 0x00): 'No precise diagnosis',
}
def _sw_name(sw1, sw2):
key = (sw1, sw2)
return SW_NAMES.get(key)
# pattern match: 63cx, 62xx, etc.
_SW_PATTERNS = {
(0x63,): lambda s1, s2: ('PIN verification failed, %d retries remaining' % (s2 & 0x0f)
if s2 & 0xc0 == 0xc0 else None),
(0x62, 0x00): lambda s1, s2: 'Response: %d more bytes available' % s2,
(0x61,): lambda s1, s2: 'Response: %d bytes available (use GET RESPONSE)' % s2,
(0x9e,): lambda s1, s2: 'Normal processing, %d bytes of response' % s2,
(0x9f,): lambda s1, s2: 'Normal processing, %d bytes of response' % s2,
}
def decode_sw(raw_sw):
if len(raw_sw) < 2:
return None
sw1, sw2 = raw_sw[-2], raw_sw[-1]
name = _sw_name(sw1, sw2)
if name is None:
for keys, fn in _SW_PATTERNS.items():
if len(keys) == 1:
if sw1 == keys[0]:
name = fn(sw1, sw2)
if name:
break
elif len(keys) == 2:
if sw1 == keys[0]:
name = fn(sw1, sw2)
if name:
break
return {
'sw1': f'{sw1:02x}',
'sw2': f'{sw2:02x}',
'name': name,
}
# ──────────────────── CLA byte ────────────────────
def decode_cla(cla):
chain_bits = (cla >> 4) & 0x03
chain_names = {0: 'last or only', 1: 'first in chain', 2: 'not last in chain', 3: 'not last'}
if cla == 0x80:
interclass = 'ETSI-defined (UICC/USIM)'
elif cla == 0xa0:
interclass = 'ETSI-defined (SIM/GSM)'
elif cla == 0x00:
interclass = 'inter-industry (ISO 7816-4)'
else:
interclass = 'inter-industry' if (cla & 0x80) == 0 else 'proprietary'
result = {
'hex': f'{cla:02x}',
'interclass': interclass,
'channel': cla & 0x03,
'secure_messaging': 'none' if (cla & 0x0c) == 0 else 'SM present',
'chain': chain_names.get(chain_bits, f'unknown ({chain_bits})'),
}
return result
# ──────────────────── 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',
'2f05': 'EF_PL',
'2f06': 'EF_ARR',
'2f08': 'EF_UMPC',
'2fe2': 'EF_ICCID',
# Dedicated files (MF/ADF level)
'7f10': 'DF_TELECOM',
'7f20': 'DF_GSM',
'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_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',
'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',
'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 = {
0xA4: {
'name': 'SELECT',
'p1': {
0x00: 'DF/EF/MF by file ID',
0x01: 'Child DF',
0x03: 'Parent DF',
0x04: 'Application DF by name',
0x08: 'Path from MF',
0x09: 'Path from current DF',
},
'p2': {
0x00: 'No indication',
0x04: 'Return FCI template',
0x08: 'Return FCP template',
0x0c: 'No data returned',
},
'body': {'label': 'FID/AID', 'fids': KNOWN_FIDS},
},
0xB0: {
'name': 'READ BINARY',
'p1p2': {'fmt': 'uint16be', 'label': 'Offset'},
'body': None,
},
0xB2: {
'name': 'READ RECORD',
'p1': {'fmt': 'uint8', 'label': 'Record number'},
'p2': {
'label': 'Mode',
'bits': {
0x07: 'currently selected EF',
0x04: 'use record number from P1',
0x02: 'previous record',
0x03: 'next record',
0x05: 'first record',
0x06: 'last record',
},
},
'body': None,
},
0xB1: {
'name': 'READ RECORD (B1)',
'p1': {'fmt': 'uint8', 'label': 'Record number'},
'p2': {
'label': 'Mode',
'bits': {
0x07: 'currently selected EF',
0x04: 'use record number from P1',
0x02: 'previous record',
0x03: 'next record',
0x05: 'first record',
0x06: 'last record',
},
},
'body': None,
},
0xD6: {
'name': 'UPDATE BINARY',
'p1p2': {'fmt': 'uint16be', 'label': 'Offset'},
'body': {'label': 'Data'},
},
0xDC: {
'name': 'UPDATE RECORD',
'p1': {'fmt': 'uint8', 'label': 'Record number'},
'p2': {
'label': 'Mode',
'bits': {
0x07: 'currently selected EF',
0x04: 'use record number from P1',
0x02: 'previous record',
0x03: 'next record',
0x05: 'first record',
0x06: 'last record',
},
},
'body': {'label': 'Data'},
},
0x20: {
'name': 'VERIFY PIN',
'p1': {0x00: 'Verify PIN1', 0x01: 'Verify PIN2', 0x02: 'Verify ADM2',
0x03: 'Verify ADM3', 0x04: 'Verify ADM4', 0x80: 'Verify PUK',
0x81: 'Verify ADM5'},
'p2': {0x00: 'No indication', 0x04: 'Reset PIN'},
'body': {'label': 'PIN value'},
},
0x21: {
'name': 'VERIFY',
'body': {'label': 'Data'},
},
0x24: {
'name': 'CHANGE PIN',
'p1': {0x00: 'Change PIN1', 0x01: 'Change PIN2', 0x02: 'Change ADM2',
0x03: 'Change ADM3', 0x04: 'Change ADM4', 0x80: 'Change PUK',
0x81: 'Change ADM5'},
'body': {'label': 'Old+new PIN'},
},
0x26: {
'name': 'DISABLE PIN',
'body': {'label': 'PIN value'},
},
0x28: {
'name': 'ENABLE PIN',
'body': {'label': 'PIN value'},
},
0x2C: {
'name': 'UNBLOCK PIN',
'p1': {0x00: 'Unblock PIN1', 0x01: 'Unblock PIN2', 0x02: 'Unblock ADM2',
0x03: 'Unblock ADM3', 0x04: 'Unblock ADM4', 0x80: 'Unblock PUK',
0x81: 'Unblock ADM5'},
'body': {'label': 'PUK + new PIN'},
},
0x88: {
'name': 'AUTHENTICATE',
'p1': {0x00: 'Run GSM algorithm', 0x80: 'Run 3G algo (resynchronisation)'},
'body': {'label': 'Challenge/session key'},
},
0x89: {
'name': 'AUTHENTICATE',
'body': {'label': 'Response/resynchronisation data'},
},
0x84: {
'name': 'GET CHALLENGE',
'body': None,
},
0x70: {
'name': 'MANAGE CHANNEL',
'p1': {0x00: 'Open channel', 0x80: 'Close channel'},
'body': None,
},
0xC0: {
'name': 'GET RESPONSE',
'body': None,
},
0xC2: {
'name': 'ENVELOPE',
'body': {'label': 'TLV data'},
},
0x12: {
'name': 'FETCH',
'body': None,
},
0x14: {
'name': 'TERMINAL RESPONSE',
'body': {'label': 'TLV data'},
},
0x32: {
'name': 'INCREASE',
'p1p2': {'fmt': 'uint16be', 'label': 'Value'},
'body': {'label': 'Data'},
},
0x04: {
'name': 'DEACTIVATE FILE',
'p1p2': {'fmt': 'uint16be', 'label': 'File ID'},
'body': None,
},
0x44: {
'name': 'ACTIVATE FILE',
'p1p2': {'fmt': 'uint16be', 'label': 'File ID'},
'body': None,
},
0xF2: {
'name': 'STATUS',
'p1': {0x00: 'No indication', 0x01: 'Current DF', 0x02: 'EF under current DF',
0x04: 'DF name', 0x0d: 'Applet status'},
'body': None,
},
0xE0: {
'name': 'CREATE FILE',
'body': {'label': 'TLV data'},
},
0xE4: {
'name': 'DELETE FILE',
'p1': {0x00: 'Delete EF/DF', 0x0c: 'Delete EF', 0x0d: 'Delete DF'},
'body': None,
},
0xAA: {
'name': 'TERMINAL CAPABILITY',
'body': {'label': 'TLV data'},
},
0x73: {
'name': 'MANAGE SECURE CHANNEL',
'body': {'label': 'TLV data'},
},
0x75: {
'name': 'TRANSACT DATA',
'body': {'label': 'TLV data'},
},
0x78: {
'name': 'GET IDENTITY',
'body': {'label': 'TLV data'},
},
0xA2: {
'name': 'SEARCH RECORD',
'p1': {'fmt': 'uint8', 'label': 'Record number'},
'p2': {
'label': 'Mode',
'bits': {
0x04: 'Simple search (forward)',
0x02: 'Backward search',
0x01: 'Enhanced search',
},
},
'body': {'label': 'Search pattern'},
},
0xCB: {
'name': 'RETRIEVE DATA',
'body': {'label': 'TLV data'},
},
0xDB: {
'name': 'SET DATA',
'body': {'label': 'TLV data'},
},
0x10: {
'name': 'TERMINAL PROFILE',
'body': {'label': 'TLV data'},
},
0x76: {
'name': 'SUSPEND UICC',
'body': None,
},
0xCA: {
'name': 'GET DATA',
'body': {'label': 'TLV data'},
},
0xDA: {
'name': 'PUT DATA',
'body': {'label': 'TLV data'},
},
0xE2: {
'name': 'STORE DATA',
'body': {'label': 'TLV data'},
},
}
# ──────────────────── CAT (TS 102 223) command types ────────────────────
CAT_COMMAND_TYPES = {
0x01: 'REFRESH',
0x02: 'MORE TIME',
0x03: 'POLL INTERVAL',
0x04: 'POLLING OFF',
0x05: 'SET UP EVENT LIST',
0x10: 'SET UP CALL',
0x11: 'SEND SS',
0x12: 'SEND USSD',
0x13: 'SEND SHORT MESSAGE',
0x14: 'SEND DTMF',
0x15: 'LAUNCH BROWSER',
0x16: 'GEOGRAPHICAL LOCATION REQUEST',
0x20: 'PLAY TONE',
0x21: 'DISPLAY TEXT',
0x22: 'GET INKEY',
0x23: 'GET INPUT',
0x24: 'SELECT ITEM',
0x25: 'SET UP MENU',
0x26: 'PROVIDE LOCAL INFORMATION',
0x27: 'TIMER MANAGEMENT',
0x28: 'SET UP IDLE MODE TEXT',
0x30: 'PERFORM CARD APDU',
0x31: 'POWER ON CARD',
0x32: 'POWER OFF CARD',
0x33: 'GET READER STATUS',
0x34: 'RUN AT COMMAND',
0x35: 'LANGUAGE NOTIFICATION',
0x40: 'OPEN CHANNEL',
0x41: 'CLOSE CHANNEL',
0x42: 'RECEIVE DATA',
0x43: 'SEND DATA',
0x44: 'GET CHANNEL STATUS',
0x45: 'SERVICE SEARCH',
0x46: 'GET SERVICE INFORMATION',
0x47: 'DECLARE SERVICE',
0x50: 'SET FRAMES',
0x51: 'GET FRAMES STATUS',
0x60: 'RETRIEVE MULTIMEDIA MESSAGE',
0x61: 'SUBMIT MULTIMEDIA MESSAGE',
0x62: 'DISPLAY MULTIMEDIA MESSAGE',
0x70: 'ACTIVATE',
0x71: 'CONTACTLESS STATE CHANGED',
0x72: 'COMMAND CONTAINER',
0x73: 'ENCAPSULATED SESSION CONTROL',
0x81: 'END OF PROACTIVE SESSION',
}
ENVELOPE_TYPES = {
0xD1: 'SMS-PP DOWNLOAD',
0xD2: 'CELL BROADCAST DOWNLOAD',
0xD3: 'MENU SELECTION',
0xD4: 'CALL CONTROL',
0xD5: 'MO SHORT MESSAGE CONTROL',
0xD6: 'EVENT DOWNLOAD',
0xD7: 'TIMER EXPIRATION',
}
def parse_tlv(data):
"""Parse a BER-TLV structure into a list of (tag, length, value) tuples.
Handles single-byte tags and both short-form (< 128) and long-form
BER-TLV lengths (0x81 NN, 0x82 NN NN). Indefinite lengths (0x80)
and truncated data cause parsing to stop at that point.
"""
tlvs = []
i = 0
n = len(data)
while i < n:
if i + 2 > n:
break
tag = data[i]
i += 1
length = data[i]
i += 1
if length & 0x80:
num_len = length & 0x7F
if num_len == 0: # indefinite length — not supported
break
if i + num_len > n:
break
length = int.from_bytes(data[i:i + num_len], 'big')
i += num_len
if i + length > n:
break
value = data[i:i + length]
i += length
tlvs.append((tag, length, value))
return tlvs
# TS 102 223 — PROVIDE LOCAL INFORMATION command qualifier values
PLI_QUALIFIERS = {
0x00: 'Location Info (MCC, MNC, LAC/TAC, Cell ID)',
0x01: 'IMEI',
0x02: 'Network Measurement results',
0x03: 'Date, time and time zone',
0x04: 'Language setting',
0x05: 'Timing Advance',
0x06: 'Access Technology (single)',
0x08: 'IMEISV',
0x09: 'Search Mode',
0x0A: 'Battery charge state',
0x0C: 'Current WSID',
0x0D: 'Broadcast Network info',
0x0E: 'Multiple Access Technologies',
0x0F: 'Location Info (multi-RAT)',
0x10: 'NMR (multi-RAT)',
0x11: 'CSG ID list + HNB name',
0x12: 'H(e)NB IP address',
0x13: 'H(e)NB surrounding macrocells',
0x14: 'Current WLAN identifier',
0x15: 'Slices information',
0x16: 'CAG information list',
0x17: 'Rejected slices information',
}
def _command_details_name(value):
"""Decode a Command Details TLV value (number, type, qualifier).
Returns the command type name, with the PLI qualifier description
appended for PROVIDE LOCAL INFORMATION (type 0x26).
"""
if len(value) < 2:
return None
name = CAT_COMMAND_TYPES.get(value[1])
if not name:
return None
if value[1] == 0x26 and len(value) >= 3: # PROVIDE LOCAL INFORMATION
q = PLI_QUALIFIERS.get(value[2])
if q:
name = f'{name} — {q}'
return name
def decode_cat(ins, body):
"""Decode a CAT (TS 102 223) payload, returning the command/event name.
For FETCH (0x12) the body is a proactive UICC command (D0 TLV) whose
Command Details (tag 81) second byte is the Type of Command.
For ENVELOPE (0xC2) the first TLV tag is the envelope type.
Returns a string name or None.
"""
if not body:
return None
if ins == 0x12: # FETCH → proactive command
for tag, _length, value in parse_tlv(body):
if tag != 0xD0:
continue
for t2, _l2, v2 in parse_tlv(value):
if t2 == 0x81:
return _command_details_name(v2)
elif ins == 0xC2: # ENVELOPE → envelope type
return ENVELOPE_TYPES.get(body[0])
return None
def decode_tr_command(body):
"""Extract the proactive command type echoed in a TERMINAL RESPONSE body.
Returns the CAT command name (context the TR is responding to), or None.
"""
if not body:
return None
for tag, _length, value in parse_tlv(body):
if tag == 0x81:
return _command_details_name(value)
return None
# ──────────────────── Response decoders ────────────────────
# TS 102 223 Result (TERMINAL RESPONSE) — general result codes
TR_RESULTS = {
0x00: 'Command performed successfully',
0x01: 'Command performed with partial comprehension',
0x02: 'Command performed, with missing information',
0x03: 'REFRESH performed with additional EFs read',
0x04: 'Command performed successfully, but requested icon could not be displayed',
0x05: 'Command performed, but modified by call control by NAA',
0x06: 'Command performed successfully, limited service',
0x07: 'Command performed with modification',
0x08: 'REFRESH performed but indicated NAA was not active',
0x09: 'Command performed successfully, tone not played',
0x0A: 'Proactive UICC session terminated by the user',
0x0B: 'Backward move in the proactive UICC session requested by the user',
0x0C: 'No response from user',
0x0D: 'Help information required by the user',
0x0E: 'USSD/SS transaction terminated by the user',
0x10: 'Terminal currently unable to process command (screen busy)',
0x11: 'Terminal currently unable to process command (busy on call)',
0x12: 'Terminal currently unable to process command (USSD/SS ongoing)',
0x13: 'Terminal currently unable to process command (no service)',
0x14: 'Terminal currently unable to process command (access control class bar)',
0x15: 'Terminal currently unable to process command (radio resource unavailable)',
0x20: 'Network currently unable to process command',
0x30: 'Beyond terminal capability',
0x31: 'Command type not understood by terminal',
0x32: 'Command data not understood by terminal',
0x33: 'Command number not known by terminal',
0x34: 'SS Return Error',
0x35: 'SMS RP-ERROR',
0x36: 'Error, required values are missing',
0x37: 'USSD Return Error',
0x38: 'MultipleCard commands error',
0x39: 'Interaction with CC by NAA, wrong procedure',
0x3C: 'Access Technology unable to process command',
0x3D: 'Frames error',
0x3E: 'MMS Error',
}
def _swap_nibbles(h):
"""Swap the nibbles of each hex byte in a hex string."""
return ''.join(b[1] + b[0] for b in (h[i:i + 2] for i in range(0, len(h), 2)))
def _decode_plmn(b):
"""Decode a 3-byte PLMN (TS 24.008) into {'mcc', 'mnc'}."""
if len(b) < 3:
return None
h = b.hex()
mcc = (h[1] + h[0] + h[3]).upper().strip('F') or '0'
mnc = (h[5] + h[4] + h[2]).upper().strip('F') or '0'
return {'mcc': mcc, 'mnc': mnc}
def _decode_imei(val):
"""Decode an 8-byte BCD IMEI (TS 24.008 Mobile Identity) into a string."""
swapped = _swap_nibbles(val.hex())
if not swapped:
return None
# First nibble is the odd/even indicator (0x8|…) with digit 1 following;
# drop it and any trailing 'F' padding.
digits = swapped[1:].rstrip('fF')
return digits or None
# TS 102 223 §6.8.7 Local information — COMPREHENSION-TLV tags (base form)
PLI_LOCATION_INFO = 0x13
PLI_IMEI = 0x14
PLI_NMR = 0x16
PLI_DATETIME = 0x26
PLI_LANGUAGE = 0x2D
PLI_ACCESS_TECH = 0x3F
PLI_TIMING_ADVANCE = 0x46
PLI_BATTERY = 0x63
PLI_SEARCH_MODE = 0x65
_ACCESS_TECH_NAMES = {
0x00: 'GSM', 0x01: 'ANSI-136 (TIA/EIA-553)', 0x02: 'IS-136 (TIA/EIA-136)',
0x03: 'UTRAN', 0x04: 'TETRA', 0x05: 'cdma2000 1xRTT',
0x06: 'cdma2000 HRPD', 0x07: 'E-UTRAN', 0x08: 'eHRPD', 0x0A: 'NR',
}
_BATTERY_NAMES = {
0x00: 'very low', 0x01: 'low', 0x02: 'average', 0x03: 'good', 0x05: 'full',
}
_ME_STATUS_NAMES = {0x00: 'idle', 0x01: 'not idle'}
_SEARCH_MODE_NAMES = {0x00: 'manual', 0x01: 'automatic'}
def _decode_datetime(val):
"""Decode TS 23.040 Service-Centre-Time-Stamp (7 bytes) into a string."""
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'
else:
tz_neg = bool(tz_raw & 0x08)
tz_q = (tz_raw & 0x07) * 10 + (tz_raw >> 4)
tz_str = f"{'-' if tz_neg else '+'}{tz_q // 4:02d}:{(tz_q % 4) * 15:02d}"
return f'20{yy:02d}-{mm:02d}-{dd:02d} {hh:02d}:{mi:02d}:{ss:02d} (UTC{tz_str})'
def _decode_local_info(tag, value):
"""Decode a PLI local-information TLV value into a display dict."""
if tag == PLI_LOCATION_INFO:
if len(value) >= 7:
plmn = _decode_plmn(value[:3]) or {}
lac = value[3:5].hex().upper()
cell = value[5:].hex().upper()
loc = f"MCC {plmn.get('mcc', '?')} MNC {plmn.get('mnc', '?')} · LAC 0x{lac} · Cell 0x{cell}"
return {'label': 'Location', 'value': loc}
elif tag == PLI_IMEI:
imei = _decode_imei(value)
if imei:
return {'label': 'IMEI', 'value': imei}
elif tag == PLI_DATETIME:
dt = _decode_datetime(value)
if dt:
return {'label': 'Date/time', 'value': dt}
elif tag == PLI_LANGUAGE:
lang = value.decode('ascii', 'replace').strip() or value.hex().upper()
return {'label': 'Language', 'value': lang}
elif tag == PLI_ACCESS_TECH:
names = [_ACCESS_TECH_NAMES.get(b, f'0x{b:02X}') for b in value]
return {'label': 'Access technology', 'value': ', '.join(names)}
elif tag == PLI_TIMING_ADVANCE:
if len(value) >= 2:
status = _ME_STATUS_NAMES.get(value[0], f'0x{value[0]:02X}')
return {'label': 'Timing advance', 'value': f'{status}, TA {value[1]}'}
elif tag == PLI_BATTERY:
if value:
return {'label': 'Battery', 'value': _BATTERY_NAMES.get(value[0], f'0x{value[0]:02X}')}
elif tag == PLI_SEARCH_MODE:
if value:
return {'label': 'Search mode', 'value': _SEARCH_MODE_NAMES.get(value[0], f'0x{value[0]:02X}')}
elif tag == PLI_NMR:
return {'label': 'Network measurement results', 'value': value.hex().upper()}
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.
Returns a dict with the Result (code/name), and any additional
information: Duration (0x04), Item Identifier (0x05) and the
PROVIDE LOCAL INFORMATION data objects.
"""
result = {}
local = {}
for tag, _length, value in parse_tlv(body):
if tag in (0x03, 0x83) and value:
code = value[0]
result['code'] = f'0x{code:02X}'
result['name'] = TR_RESULTS.get(code)
result['raw'] = value.hex().upper()
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:
info = _decode_local_info(tag, value)
if info:
local[info['label']] = info['value']
if local:
result['local_info'] = local
return result or None
def _decode_tr_result(body):
"""Back-compat wrapper returning the Result TLV of a TERMINAL RESPONSE."""
return _decode_tr_response(body)
# TS 102 221 Table 11.5 — file descriptor byte
_FILE_TYPES = {
0b000: 'Working EF',
0b001: 'Internal EF',
0b111: 'DF or ADF',
}
_EF_STRUCTURES = {
0b000: 'no information',
0b001: 'transparent',
0b010: 'linear fixed',
0b110: 'cyclic',
}
_LIFE_CYCLE = {
0x00: 'no information given',
0x01: 'creation state',
0x03: 'initialization state',
0x05: 'operational state (activated)',
0x07: 'operational state (deactivated)',
0x0C: 'termination state',
0x0D: 'termination state (permanently)',
}
def _decode_file_descriptor(value):
"""Decode a file descriptor byte string (TS 102 221 Table 11.5)."""
if not value:
return {}
b = value[0]
ft = (b >> 3) & 0x07
result = {
'shareable': 'shareable' if (b & 0x40) else 'not shareable',
'file_type': _FILE_TYPES.get(ft, 'RFU'),
}
if ft in (0b000, 0b001): # EF
result['structure'] = _EF_STRUCTURES.get(b & 0x07, 'RFU')
if len(value) >= 2:
result['data_coding'] = f'0x{value[1]:02X}'
if len(value) >= 4:
result['record_length'] = int.from_bytes(value[2:4], 'big')
if len(value) >= 5:
result['num_records'] = value[4]
elif len(value) >= 2:
result['num_dfs_efs'] = value[1]
return result
def _decode_fcp(data):
"""Decode an FCP/FCI/FMD template (TS 102 221 §11.1.1.3)."""
result = {}
for tag, _length, value in parse_tlv(data):
if tag in (0x62, 0x64): # outer template — recurse
inner = _decode_fcp(value)
inner['template'] = 'FCP' if tag == 0x62 else 'FMD'
result.update(inner)
elif tag == 0x82:
result['file_descriptor'] = _decode_file_descriptor(value)
elif tag == 0x83:
fid = value.hex().upper()
result['file_id'] = fid
if fid.lower() in KNOWN_FIDS:
result['file_id_name'] = KNOWN_FIDS[fid.lower()]
elif tag == 0x84:
result['df_name'] = value.hex().upper()
elif tag == 0x88:
result['sfi'] = f'0x{value[0]:02X}' if value else None
elif tag == 0x8A:
result['life_cycle'] = _LIFE_CYCLE.get(value[0] if value else 0, f'0x{(value[0] if value else 0):02X}')
elif tag == 0xAB:
result['short_ef_id'] = value.hex().upper()
return result
def _decode_auth_3g(data):
"""Decode a 3G AUTHENTICATE response (tag DB success / DC sync-fail)."""
tag = data[0]
if tag == 0xDC: # synchronisation failure → length byte + AUTS
if len(data) >= 2:
auts_len = data[1]
return {'type': '3G', 'status': 'sync fail',
'auts': data[2:2 + auts_len].hex().upper()}
return {'type': '3G', 'status': 'sync fail', 'auts': data[1:].hex().upper()}
# tag 0xDB: success → length-prefixed RES, CK, IK, (KC)
result = {'type': '3G', 'status': 'success'}
i = 1
for name in ('res', 'ck', 'ik', 'kc'):
if i >= len(data):
break
ln = data[i]
i += 1
result[name] = data[i:i + ln].hex().upper()
i += ln
return result
def _decode_auth(data):
"""Decode an AUTHENTICATE response (GSM SRES+Kc, or 3G tag DB/DC)."""
if not data:
return {}
if data[0] in (0xDB, 0xDC):
return _decode_auth_3g(data)
# GSM: SRES (4 bytes) + Kc (8 bytes)
if len(data) >= 12:
return {
'type': 'GSM',
'sres': data[:4].hex().upper(),
'kc': data[4:12].hex().upper(),
}
return {'type': 'GSM', 'raw': data.hex().upper()}
def _decode_response_for(ins, data):
"""Decode response data using the command identified by INS."""
if ins == 0xA4: # SELECT → FCP/FCI template
return _decode_fcp(data)
if ins in (0x88, 0x89): # AUTHENTICATE → SRES/Kc or RES/CK/IK
return _decode_auth(data)
return None
# ──────────────────── Command-body decoders ────────────────────
AUTH_CONTEXTS = {
0: 'GSM',
1: '3G (UMTS)',
2: 'VGC/VBS',
4: 'GBA',
}
def _decode_auth_cmd(data, p2):
"""Decode an AUTHENTICATE command body (RAND / RAND+AUTN)."""
ctx = p2 & 0x07
result = {'context': AUTH_CONTEXTS.get(ctx, f'unknown ({ctx})')}
if ctx in (0, 1) and data:
i = 0
if i < len(data):
rlen = data[i]
i += 1
result['rand'] = data[i:i + rlen].hex().upper()
i += rlen
if ctx == 1 and i < len(data): # 3G → AUTN
alen = data[i]
i += 1
result['autn'] = data[i:i + alen].hex().upper()
return result
# TS 102 223 §8.25 — Event list values
EVENT_TYPES = {
0x00: 'MT call', 0x01: 'Call connected', 0x02: 'Call disconnected',
0x03: 'Location status', 0x04: 'User activity', 0x05: 'Idle screen available',
0x06: 'Card reader status', 0x07: 'Language selection',
0x08: 'Browser termination', 0x09: 'Data available',
0x0A: 'Channel status', 0x0B: 'Access Technology Change',
0x0C: 'Display parameters changed', 0x0D: 'Local connection',
0x0E: 'Network Search Mode Change', 0x0F: 'Browsing status',
0x10: 'Frames Information Change', 0x11: 'I-WLAN Access Status',
0x12: 'Network Rejection', 0x13: 'HCI Connectivity',
0x14: 'Change of UICC Access', 0x15: 'CSG Cell Change',
0x16: 'Contactless state request', 0x17: 'Profile Container',
0x18: 'LTE D2D Discovery Monitoring', 0x19: 'LTE D2D Communication Monitoring',
0x1A: 'LTE D2D Announcement Response', 0x1B: 'LTE D2D Revocation',
0x1C: 'LTE D2D Application Port', 0x1D: 'LTE D2D Security Recovery',
0x1E: 'Off-net Emergency Call', 0x1F: 'ECall Over IMS',
0x20: 'EARFCN Update', 0x21: 'SCEF Channel Status',
}
LOCATION_STATUS = {
0x00: 'Normal service',
0x01: 'Limited service',
0x02: 'No service',
}
def _decode_bcd_address(data):
"""Decode a [TON/NPI + BCD digits] address into a string."""
if not data:
return ''
ton = (data[0] >> 4) & 0x07 # bits 6-4 (bit 7 is the extension bit)
digits = []
for b in data[1:]:
lo, hi = b & 0x0F, (b >> 4) & 0x0F
if lo <= 9:
digits.append(str(lo))
elif lo == 0x0F:
break
if hi <= 9:
digits.append(str(hi))
elif hi == 0x0F:
break
num = ''.join(digits)
if ton == 1: # international
num = '+' + num
return num
# TS 23.038 — GSM 7-bit default alphabet
GSM7_ALPHABET = (
'@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\n\u00d8\u00f8\r\u00c5\u00e5'
'\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u001b\u00c6\u00e6\u00df\u00c9'
' !"#\u00a4%&\'()*+,-./'
'0123456789:;<=>?'
'\u00a1ABCDEFGHIJKLMNO'
'PQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7'
'\u00bfabcdefghijklmno'
'pqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0'
)
GSM7_EXTENSION = {
0x0A: '\n', 0x14: '^', 0x28: '{', 0x29: '}', 0x2F: '\\',
0x3C: '[', 0x3D: '~', 0x3E: ']', 0x40: '|', 0x65: '\u20ac',
}
def _decode_gsm7(data, num_chars):
"""Unpack GSM 7-bit packed septets and decode to text."""
septets = []
bitbuf = 0
bitcount = 0
for byte in data:
bitbuf |= byte << bitcount
bitcount += 8
while bitcount >= 7:
septets.append(bitbuf & 0x7F)
bitbuf >>= 7
bitcount -= 7
if num_chars is not None:
septets = septets[:num_chars]
text = []
i = 0
while i < len(septets):
s = septets[i]
if s == 0x1B and i + 1 < len(septets):
text.append(GSM7_EXTENSION.get(septets[i + 1], ' '))
i += 2
else:
text.append(GSM7_ALPHABET[s] if s < len(GSM7_ALPHABET) else '?')
i += 1
return ''.join(text)
def _decode_dcs(dcs):
"""Decode TP-DCS into (encoding, message_class).
Alphabet is selected by DCS bits 3-2: 0=GSM 7-bit, 1=8-bit data, 2=UCS2.
"""
group = dcs & 0xC0
if group in (0x00, 0xC0):
alpha = (dcs >> 2) & 0x03
encoding = {0: 'GSM 7-bit', 1: '8-bit data', 2: 'UCS2'}.get(alpha, 'reserved')
else:
encoding = '8-bit data'
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 = []
i = 0
while i < len(data):
b = data[i]
if b == 0x1B and i + 1 < len(data):
out.append(GSM7_EXTENSION.get(data[i + 1], ' '))
i += 2
else:
out.append(GSM7_ALPHABET[b] if b < len(GSM7_ALPHABET) else '?')
i += 1
return ''.join(out)
def _decode_annex_a(raw):
"""Decode a TS 102 221 Annex A text string (GsmOrUcs2).
Magic prefix 0x80/0x81/0x82 → UCS-2 variants; otherwise GSM 7-bit
default alphabet coded one octet per character.
"""
if not raw:
return ''
if raw == b'\xff' * len(raw):
return ''
if raw[0] == 0x80:
return raw[1:].decode('utf_16_be', 'replace')
if raw[0] == 0x81 and len(raw) >= 3:
num_chars = raw[1]
base_ptr = raw[2] << 7
out = []
for ch in raw[3:3 + num_chars]:
if ch & 0x80:
out.append(chr((ch & 0x7F) + base_ptr))
else:
out.append(GSM7_ALPHABET[ch] if ch < len(GSM7_ALPHABET) else '?')
return ''.join(out)
if raw[0] == 0x82 and len(raw) >= 4:
num_chars = raw[1]
base_ptr = (raw[2] << 8) | raw[3]
out = []
for ch in raw[4:4 + num_chars]:
if ch & 0x80:
out.append(chr((ch & 0x7F) + base_ptr))
else:
out.append(GSM7_ALPHABET[ch] if ch < len(GSM7_ALPHABET) else '?')
return ''.join(out)
return _decode_gsm7_octets(raw)
def _decode_dcs_text(raw):
"""Decode a Text String (TS 102 223 §8.15): DCS byte + text."""
if not raw or len(raw) < 2:
return raw.hex() if raw else ''
dcs = raw[0]
data = raw[1:]
if (dcs & 0x0C) == 0x08:
return data.decode('utf_16_be', 'replace')
if (dcs & 0x0C) == 0x04:
return data.decode('latin-1', 'replace')
return _decode_gsm7_octets(data)
def _decode_sm_tpdu(data):
"""Decode a GSM 03.40 (TS 23.040) SM TPDU (SMS-DELIVER / SMS-SUBMIT)."""
if not data:
return {}
mti = data[0] & 0x03
mti_names = {0: 'SMS-DELIVER', 1: 'SMS-SUBMIT', 2: 'SMS-STATUS-REPORT', 3: 'SMS-COMMAND'}
udhi = bool(data[0] & 0x40)
result = {'mti': mti_names.get(mti, f'unknown ({mti})'), 'udhi': udhi}
if mti == 0: # SMS-DELIVER
i = 1
if i < len(data):
oa_len = data[i]
i += 1
oa_bytes = 1 + (oa_len + 1) // 2 # TON/NPI + BCD digits
result['oa'] = _decode_bcd_address(data[i:i + oa_bytes])
i += oa_bytes
if i + 1 < len(data):
result['pid'] = data[i]
result['dcs'] = data[i + 1]
i += 2
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
result = _decode_ud(data[i:], udl, result.get('dcs', 0), udhi, result)
elif mti == 1: # SMS-SUBMIT
vpf = (data[0] >> 3) & 0x03
i = 1
if i < len(data):
result['mr'] = data[i] # TP-MR
i += 1
if i < len(data):
da_len = data[i]
i += 1
da_bytes = 1 + (da_len + 1) // 2
result['da'] = _decode_bcd_address(data[i:i + da_bytes])
i += da_bytes
if i + 1 < len(data):
result['pid'] = data[i]
result['dcs'] = data[i + 1]
i += 2
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: # 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]
i += 1
result = _decode_ud(data[i:], udl, result.get('dcs', 0), udhi, result)
return result
def _decode_ud(ud, udl, dcs, udhi, result):
encoding, msg_class = _decode_dcs(dcs)
result['encoding'] = encoding
result['msg_class'] = msg_class
pid = result.get('pid')
if pid == 0x7F: # SIM data download → secured packet (show hex)
result['pid_name'] = 'SIM data download (secured packet)'
result['payload'] = ud[:udl].hex().upper()
return result
if udhi and ud:
udhl = ud[0]
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
n = udl - ((fill_bits + 6) // 7)
result['text'] = _decode_gsm7(body, n)
elif encoding == 'UCS2':
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)
elif encoding == 'UCS2':
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
def _decode_cb_page(value):
"""Decode a CELL BROADCAST DOWNLOAD CB page (TS 31.111 §8.5 / TS 23.041).
Layout: serial number (2), message identifier (2), DCS (1), page
parameter (1), content (up to 82 octets). Content is returned as raw
hex (no text decode).
"""
if len(value) < 6:
return {'content': value.hex().upper()}
serial = value[0:2].hex().upper()
message_id = value[2:4].hex().upper()
dcs = value[4]
page_byte = value[5]
total_pages = (page_byte >> 4) & 0x0F
page_num = page_byte & 0x0F
encoding, msg_class = _decode_dcs(dcs)
dcs_str = f'0x{dcs:02X} — {encoding}' + (f' (class {msg_class})' if msg_class else '')
return {
'serial': serial,
'message_id': '0x' + message_id,
'dcs': dcs_str,
'page': f'{page_num}/{total_pages}',
'content': value[6:].hex().upper(),
}
def _decode_envelope(body):
"""Decode an ENVELOPE command body (TS 102 223 / TS 31.111)."""
tlvs = parse_tlv(body)
if not tlvs:
return {}
tag, _length, value = tlvs[0]
result = {'type': ENVELOPE_TYPES.get(tag, f'0x{tag:02X}')}
inner = parse_tlv(value)
if tag == 0xD6: # EVENT DOWNLOAD
for t, _l, v in inner:
if t == 0x19: # Event list
result['events'] = [EVENT_TYPES.get(e, f'0x{e:02X}') for e in v]
elif t == 0x1B: # Location status
if v:
result['location_status'] = LOCATION_STATUS.get(v[0], f'0x{v[0]:02X}')
elif t == 0x13: # Location information
li = _decode_local_info(PLI_LOCATION_INFO, v)
result['location_info'] = li['value'] if li else v.hex().upper()
elif t in (0x06, 0x86): # Address (caller's number, MT call)
result['caller'] = _decode_bcd_address(v)
elif t == 0x1C: # Transaction identifier (MT call)
if v:
result['transaction_id'] = v[0]
elif t in (0x08, 0x88): # Subaddress
result['subaddress'] = v.hex().upper()
elif t == 0x02: # Device identities
result['device_ids'] = v.hex().upper()
elif tag == 0xD5: # MO SHORT MESSAGE CONTROL
for t, _l, v in inner:
if t == 0x06: # Address objects
if 'smsc' not in result:
result['smsc'] = _decode_bcd_address(v)
else:
result['tp_da'] = _decode_bcd_address(v)
elif t == 0x13:
result['location_info'] = v.hex().upper()
elif t == 0x02:
result['device_ids'] = v.hex().upper()
elif tag == 0xD1: # SMS-PP DOWNLOAD
for t, _l, v in inner:
if t == 0x06:
result['smsc'] = _decode_bcd_address(v)
elif t in (0x0B, 0x8B): # SMS TPDU (SMS-DELIVER)
result['tpdu'] = _decode_sm_tpdu(v)
elif t == 0x02:
result['device_ids'] = v.hex().upper()
elif tag == 0xD2: # CELL BROADCAST DOWNLOAD
for t, _l, v in inner:
if t == 0x02:
result['device_ids'] = v.hex().upper()
elif t in (0x0C, 0x8C): # CB page
result['cb_page'] = _decode_cb_page(v)
else: # D3 / D4 / D7 — device identities only
for t, _l, v in inner:
if t == 0x02:
result['device_ids'] = v.hex().upper()
return result
# ──────────────────── Proactive command-body decoder ────────────────────
# TS 102 223 §6.6 data-object tags (base value; comprehension bit masked off)
_P_CMD_DETAILS = 0x01
_P_DEVICE_IDS = 0x02
_P_DURATION = 0x04
_P_ALPHA_ID = 0x05
_P_ADDRESS = 0x06
_P_SMS_TPDU = 0x0B
_P_TEXT_STRING = 0x0D
_P_ITEM = 0x0F
_P_RESPONSE_LEN = 0x11
_P_EVENT_LIST = 0x19
def _decode_proactive(body):
"""Decode a FETCH proactive command body (D0 TLV) into a dict.
Unwraps the D0 BER-TLV and decodes the command's data objects,
dispatching on the Type of Command from the Command Details (0x81).
"""
if not body:
return None
inner = None
for tag, _length, value in parse_tlv(body):
if tag == 0xD0:
inner = parse_tlv(value)
break
if inner is None:
return None
cmd_type = None
result = {}
items = []
for tag, _length, value in inner:
base = tag & 0x7F
if base == _P_CMD_DETAILS and len(value) >= 2:
cmd_type = value[1]
elif base == _P_ALPHA_ID and value:
result['title'] = _decode_annex_a(value)
elif base == _P_TEXT_STRING and value:
result['text'] = _decode_dcs_text(value)
elif base == _P_ITEM and len(value) >= 2:
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:
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:
result['events'] = [EVENT_TYPES.get(e, f'0x{e:02X}') for e in value]
elif base == _P_ADDRESS and value:
result['address'] = _decode_bcd_address(value)
if cmd_type is None:
return None
result['type'] = CAT_COMMAND_TYPES.get(cmd_type, f'0x{cmd_type:02X}')
if items:
result['items'] = items
return result
# ──────────────────── 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.
Returns None for non-TPDU messages (ATR, PPS, CHANGE, FIDI)
or un-parseable data. Returns a dict with ins_name, cla, p1, p2,
p3, body, and sw keys for valid TPDU messages.
*prev* optionally carries the previous TPDU's decoded context
(``{'ins_name': str, 'sw1': str}``) for resolving GET RESPONSE.
"""
if not raw_data or len(raw_data) < 5:
return None
cla = raw_data[0]
ins = raw_data[1]
p1 = raw_data[2]
p2 = raw_data[3]
p3 = raw_data[4]
spec = APDU_SPEC.get(ins)
ins_name = spec['name'] if spec else f'Unknown (0x{ins:02x})'
result = {
'ins_hex': f'{ins:02x}',
'ins_name': ins_name,
'cla': decode_cla(cla),
'p3': p3,
}
# P1 / P2 decode
if spec:
if 'p1p2' in spec:
offset = (p1 << 8) | p2
result['p1p2'] = {'label': spec['p1p2']['label'], 'value': offset}
else:
if 'p1' in spec:
result['p1'] = _decode_field(spec['p1'], p1)
if 'p2' in spec:
result['p2'] = _decode_field(spec['p2'], p2)
# Body: at least P3 bytes from byte 5; extra bytes may be SW
remaining = raw_data[5:]
extra_total = len(remaining) - p3
sw_bytes = None
if extra_total >= 2:
sw_candidate = remaining[-2:]
if sw_candidate[0] in (0x60, 0x61, 0x62, 0x63, 0x64, 0x65,
0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b,
0x6c, 0x6d, 0x6e, 0x6f, 0x90, 0x91,
0x92, 0x93, 0x94, 0x95, 0x96, 0x97,
0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d,
0x9e, 0x9f):
sw_bytes = sw_candidate
cmd_body_len = len(remaining) - 2 if sw_bytes else len(remaining)
if cmd_body_len > 0:
body = remaining[:cmd_body_len]
result['body'] = {'hex': body.hex(), 'size': cmd_body_len}
if spec and spec.get('body') and spec['body'].get('label'):
result['body']['label'] = spec['body']['label']
if spec and spec.get('body') and spec['body'].get('fids'):
fids = spec['body']['fids']
h = body.hex()
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:
result['response_to'] = response_to
result['response'] = _decode_tr_result(body)
else:
cat_command = decode_cat(ins, body)
if cat_command:
result['cat_command'] = cat_command
if ins in (0x88, 0x89): # AUTHENTICATE command body
result['cmd'] = _decode_auth_cmd(body, p2)
elif ins == 0xC2: # ENVELOPE command body
result['cmd'] = _decode_envelope(body)
elif ins == 0x12: # FETCH → proactive command body
result['cmd'] = _decode_proactive(body)
# SW
if sw_bytes:
result['sw'] = decode_sw(sw_bytes)
# GET RESPONSE context: the response belongs to the previous command
# if that command ended with SW1 '61' (response data available).
if ins == 0xC0:
if prev and prev.get('sw1') == '61':
result['response_for'] = prev.get('ins_name')
prev_ins = prev.get('ins')
if prev_ins is not None and cmd_body_len > 0:
response = _decode_response_for(prev_ins, remaining[:cmd_body_len])
if response:
result['response'] = response
else:
result['response_for'] = None
result['summary'] = _build_summary(result)
return result
# ──────────────────── Sniff-level message types ────────────────────
CHANGE_FLAGS = {
1 << 0: 'Card inserted',
1 << 1: 'Card ejected',
1 << 2: 'Reset asserted',
1 << 3: 'Reset de-asserted',
1 << 4: 'Waiting time timeout',
}
def decode_change(flags):
"""Decode SIMtrace2 sniff_change flags into a list of human-readable names."""
bits = []
for mask, name in sorted(CHANGE_FLAGS.items()):
if flags & mask:
bits.append(name)
return {
'type': 'change',
'flags_hex': f'{flags:08x}',
'flags': bits if bits else ['no changes'],
}
def decode_fidi(raw_data):
"""Decode a SIMtrace2 sniff_fidi payload (1-byte Fi/Di)."""
if len(raw_data) < 1:
return None
fidi = raw_data[0]
fi = fidi >> 4
di = fidi & 0x0f
fi_table = {0: 372, 1: 372, 2: 558, 3: 744, 4: 1116, 5: 1488, 6: 1860, 7: 0,
8: 0, 9: 512, 10: 768, 11: 1024, 12: 1536, 13: 2048, 14: 0, 15: 0}
di_table = {0: 0, 1: 1, 2: 2, 3: 4, 4: 8, 5: 16, 6: 32, 7: 64,
8: 12, 9: 20, 10: 2, 11: 4, 12: 8, 13: 16, 14: 32, 15: 64}
return {
'type': 'fidi',
'fidi': f'{fidi:02x}',
'fi': fi,
'di': di,
'fi_val': fi_table.get(fi, fi),
'di_val': di_table.get(di, di),
}
# ──────────────────── 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.
Returns a structured dict with at least a 'type' key, or None if
the message type has no structured decode.
*prev* optionally carries the previous TPDU's decoded context for
resolving GET RESPONSE.
"""
if msg_type == 'tpdu' and raw_data:
return decode_message(raw_data, prev=prev)
if msg_type == 'change':
return decode_change(flags)
if msg_type == 'fidi' and raw_data:
return decode_fidi(raw_data)
if msg_type == 'atr':
return _decode_atr(raw_data)
if msg_type == 'pps':
return _decode_pps(raw_data)
return None
def _decode_field(spec, value):
"""Decode a single P1 or P2 field from its byte value."""
result = {'raw': f'{value:02x}'}
if isinstance(spec, dict):
if value in spec and isinstance(spec[value], str):
result['name'] = spec[value]
elif 'label' in spec and 'bits' in spec:
result['label'] = spec['label']
matches = []
for mask_val, desc in sorted(spec['bits'].items()):
if (value & 0x07) == mask_val:
matches.append(desc)
elif (value & mask_val) == mask_val and mask_val > 0x07:
matches.append(desc)
result['bits'] = matches if matches else None
elif 'fmt' in spec:
result['label'] = spec.get('label', '')
result['value'] = value
return result