- terminal power supply (tag 80): voltage class per Table 6.1, maximum
current in mA, clock in 0.1 MHz steps ('FF' = not indicated)
- extended logical channels (tag 81) — zero length per §11.1.19.2.2, a
nonzero length is preserved as raw
- additional interfaces (tag 82 b1 = UICC-CLF)
- eUICC device capabilities: tag 83 per GSMA SGP.22 v2.6 §3.4.2 Table 7
(LUId/LPDd/LDSd/LUIe-SCWS); tag 84 preserved raw (no pinned SGP.32 spec)
- unknown/private TLVs preserved via raw_tlv
- concise APDU-list summary; decoded rows in the PWA detail view
- v2.1.0; SW cache simtrace-analyser-v55
- tests: 6 real samples from the local trace sessions (49/55/58/61/206/208)
4781 lines
170 KiB
Python
4781 lines
170 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.
|
||
"""
|
||
|
||
from simtrace2_pysniff.gsmtap import GSMTAP_FLAG_BAD_FCS
|
||
|
||
# ──────────────────── Status Word names ────────────────────
|
||
|
||
SW_NAMES = {
|
||
(0x90, 0x00): 'Normal ending',
|
||
(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',
|
||
(0x62, 0x85): 'Warning — selected file in termination state',
|
||
(0x62, 0xf1): 'More data available',
|
||
(0x62, 0xf2): 'More data available and proactive command pending',
|
||
(0x62, 0xf3): 'Response data available',
|
||
(0x63, 0x00): 'Warning — no information',
|
||
(0x63, 0xf1): 'More data expected',
|
||
(0x63, 0xf2): 'More data expected and proactive command pending',
|
||
(0x64, 0x00): 'Execution error — no information',
|
||
(0x64, 0x01): 'Execution error — immediate response required',
|
||
(0x65, 0x00): 'Execution error — no information, NV memory changed',
|
||
(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',
|
||
(0x69, 0x89): 'Command not allowed — secure channel security not satisfied',
|
||
(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',
|
||
(0x93, 0x00): 'SIM Application Toolkit busy',
|
||
(0x98, 0x50): 'INCREASE cannot be performed, max value reached',
|
||
(0x98, 0x62): 'Authentication error, application specific',
|
||
(0x98, 0x63): 'Security session or association expired',
|
||
(0x98, 0x64): 'Minimum UICC suspension time too long',
|
||
}
|
||
|
||
|
||
def _sw_name(sw1, sw2):
|
||
key = (sw1, sw2)
|
||
return SW_NAMES.get(key)
|
||
|
||
# pattern match: 91xx, 63cx, etc.
|
||
_SW_PATTERNS = [
|
||
((0x91,), lambda s1, s2: 'Proactive command pending (%d bytes)' % s2),
|
||
((0x92,), lambda s1, s2: 'Normal ending, TRANSACT DATA info (0x%02X)' % s2),
|
||
((0x61,), lambda s1, s2: 'Response: %d bytes available (use GET RESPONSE)' % s2),
|
||
((0x63,), lambda s1, s2: ('Verification failed, %d retries remaining' % (s2 & 0x0f)
|
||
if s2 & 0xc0 == 0xc0 else None)),
|
||
((0x6c,), lambda s1, s2: 'Wrong length (Le): correct length is 0x%02X (%d bytes)' % (s2, 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:
|
||
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):
|
||
if cla == 0x80:
|
||
interclass = 'ETSI-defined (UICC/USIM)'
|
||
elif cla == 0xa0:
|
||
interclass = 'ETSI-defined (SIM/GSM)'
|
||
elif cla & 0x80:
|
||
interclass = 'proprietary'
|
||
elif (cla >> 6) & 0x03 == 0x00:
|
||
interclass = 'inter-industry (ISO 7816-4)'
|
||
elif (cla >> 6) & 0x03 == 0x01:
|
||
interclass = 'inter-industry (further format)'
|
||
else:
|
||
interclass = 'reserved'
|
||
if cla & 0x80:
|
||
# proprietary / ETSI-defined: no standard SM/chaining/channel coding
|
||
result = {
|
||
'hex': f'{cla:02x}',
|
||
'interclass': interclass,
|
||
'channel': cla & 0x03,
|
||
'secure_messaging': 'none',
|
||
'chain': 'last or only',
|
||
}
|
||
else:
|
||
# ISO 7816-4 §5.1.1 first/further interindustry
|
||
chain_bit = (cla >> 5) & 0x01
|
||
chain_names = {0: 'last or only', 1: 'first or continuing'}
|
||
sm_names = {0: 'none', 1: 'proprietary', 2: 'SM header not authenticated',
|
||
3: 'SM header authenticated'}
|
||
sm_ind = (cla >> 3) & 0x03
|
||
channel = cla & 0x03
|
||
if (cla >> 6) & 0x03 == 0x01:
|
||
channel += 4 # further format: b4-b1 = channel + 4
|
||
result = {
|
||
'hex': f'{cla:02x}',
|
||
'interclass': interclass,
|
||
'channel': channel,
|
||
'secure_messaging': sm_names.get(sm_ind, 'none'),
|
||
'chain': chain_names.get(chain_bit, 'last or only'),
|
||
}
|
||
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',
|
||
'2f07': 'EF_ENV-CLASSES',
|
||
'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)
|
||
|
||
|
||
def select_target_fid(d):
|
||
"""Return the FID targeted by a decoded SELECT message, or None.
|
||
|
||
Used by the server's selection tracking to know which file is
|
||
currently selected (so READ/UPDATE bodies can be decoded).
|
||
"""
|
||
p1 = (d.get('p1') or {}).get('raw')
|
||
h = (d.get('body') or {}).get('hex') or ''
|
||
if p1 in ('00', '01'): # select by FID / child DF
|
||
return h if len(h) == 4 else None
|
||
if p1 in ('08', '09'): # select by path
|
||
if len(h) >= 4:
|
||
last = h[-4:]
|
||
return None if last == '7fff' else last
|
||
return None
|
||
return None
|
||
|
||
|
||
# Short File Identifier (SFI) → FID, scoped to the containing DF.
|
||
# SFI is only unique within a DF (TS 102 221 §8.3). Sourced from the
|
||
# normative SFI lists: TS 31.102 Annex H (USIM ADF, DF GSM-ACCESS) and
|
||
# TS 102 221 Annex H (MF level).
|
||
_SFI_BY_DF = {
|
||
'3f00': { # MF (TS 102 221 Annex H)
|
||
0x02: '2fe2', # EF_ICCID
|
||
0x05: '2f05', # EF_PL
|
||
0x06: '2f06', # EF_ARR
|
||
0x07: '2f07', # EF_ENV-CLASSES (TS 102 671)
|
||
0x08: '2f08', # EF_UMPC
|
||
0x1e: '2f00', # EF_DIR
|
||
},
|
||
'7fff': { # ADF_USIM (TS 31.102 Annex H H.1)
|
||
0x01: '6fb7', # EF_ECC
|
||
0x02: '6f05', # EF_LI
|
||
0x03: '6fad', # EF_AD
|
||
0x04: '6f38', # EF_UST
|
||
0x05: '6f56', # EF_EST
|
||
0x06: '6f78', # EF_ACC
|
||
0x07: '6f07', # EF_IMSI
|
||
0x08: '6f08', # EF_Keys
|
||
0x09: '6f09', # EF_KeysPS
|
||
0x0a: '6f60', # EF_PLMNwAcT
|
||
0x0b: '6f7e', # EF_LOCI
|
||
0x0c: '6f73', # EF_PSLOCI
|
||
0x0d: '6f7b', # EF_FPLMN
|
||
0x0e: '6f48', # EF_CBMID
|
||
0x0f: '6f5b', # EF_START-HFN
|
||
0x10: '6f5c', # EF_THRESHOLD
|
||
0x11: '6f61', # EF_OPLMNwAcT
|
||
0x12: '6f31', # EF_HPPLMN
|
||
0x13: '6f62', # EF_HPLMNwAcT
|
||
0x14: '6f80', # EF_ICI
|
||
0x15: '6f81', # EF_OCI
|
||
0x16: '6f4f', # EF_CCP2
|
||
0x17: '6f06', # EF_ARR
|
||
0x18: '6fe4', # EF_EPSNSC
|
||
0x19: '6fc5', # EF_PNN
|
||
0x1a: '6fc6', # EF_OPL
|
||
0x1b: '6fcd', # EF_SPDI
|
||
0x1c: '6f39', # EF_ACM
|
||
0x1d: '6fd9', # EF_EHPLMN
|
||
0x1e: '6fe3', # EF_EPSLOCI
|
||
},
|
||
'5f3b': { # DF_GSM_ACCESS (TS 31.102 Annex H H.2)
|
||
0x01: '4f20', # EF_Kc
|
||
0x02: '4f52', # EF_KcGPRS
|
||
},
|
||
}
|
||
|
||
|
||
def _is_df_fid(fid):
|
||
"""True if *fid* (2-hex-byte string) denotes a DF/ADF (MF, 7Fxx, 5Fxx)."""
|
||
return fid == '3f00' or fid.startswith('7f') or fid.startswith('5f')
|
||
|
||
|
||
def selected_df_fid(d):
|
||
"""Return the current DF after a SELECT, or None if the SELECT does not
|
||
(re)enter a DF. Handles SELECT by FID (P1 00/01), by path (P1 08/09),
|
||
and by DF name/AID (P1 04)."""
|
||
if d.get('ins_hex') != 'a4':
|
||
return None
|
||
p1 = (d.get('p1') or {}).get('raw')
|
||
h = (d.get('body') or {}).get('hex') or ''
|
||
if p1 in ('00', '01'):
|
||
return h if len(h) == 4 and _is_df_fid(h) else None
|
||
if p1 in ('08', '09'):
|
||
comps = [h[i:i + 4] for i in range(0, len(h) - 3, 4)]
|
||
for comp in reversed(comps):
|
||
if _is_df_fid(comp):
|
||
return comp
|
||
return None
|
||
if p1 == '04': # select by DF name (AID) → map known application AIDs
|
||
if h.startswith('a0000000871002'): # USIM AID
|
||
return '7fff'
|
||
return None
|
||
return None
|
||
|
||
|
||
def sfi_table(df):
|
||
"""Return the SFI→FID table for a DF FID, or an empty dict."""
|
||
return _SFI_BY_DF.get(df or '', {})
|
||
|
||
|
||
# ──────────────────── Per-INS specifications ────────────────────
|
||
|
||
# TS 102 221 Table 9.3 — PIN mapping into key references (P2 of the PIN commands)
|
||
PIN_KEY_REFS = {
|
||
0x01: 'PIN Appl 1', 0x02: 'PIN Appl 2', 0x03: 'PIN Appl 3', 0x04: 'PIN Appl 4',
|
||
0x05: 'PIN Appl 5', 0x06: 'PIN Appl 6', 0x07: 'PIN Appl 7', 0x08: 'PIN Appl 8',
|
||
0x0A: 'ADM1', 0x0B: 'ADM2', 0x0C: 'ADM3', 0x0D: 'ADM4', 0x0E: 'ADM5',
|
||
0x11: 'Universal PIN',
|
||
0x81: 'Second PIN Appl 1', 0x82: 'Second PIN Appl 2', 0x83: 'Second PIN Appl 3',
|
||
0x84: 'Second PIN Appl 4', 0x85: 'Second PIN Appl 5', 0x86: 'Second PIN Appl 6',
|
||
0x87: 'Second PIN Appl 7', 0x88: 'Second PIN Appl 8',
|
||
}
|
||
|
||
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',
|
||
'le': True,
|
||
'p1p2': {'fmt': 'uint16be', 'label': 'Offset'},
|
||
'body': None,
|
||
},
|
||
0xB2: {
|
||
'name': 'READ RECORD',
|
||
'le': True,
|
||
'p1': {'fmt': 'uint8', 'label': 'Record number'},
|
||
'p2': {
|
||
'label': 'Mode',
|
||
'bits': {
|
||
0x02: 'next record',
|
||
0x03: 'previous record',
|
||
0x04: 'absolute mode (record number in P1)',
|
||
},
|
||
},
|
||
'body': None,
|
||
},
|
||
0xB1: {
|
||
'name': 'READ RECORD (B1)',
|
||
'le': True,
|
||
'p1': {'fmt': 'uint8', 'label': 'Record number'},
|
||
'p2': {
|
||
'label': 'Mode',
|
||
'bits': {
|
||
0x02: 'next record',
|
||
0x03: 'previous record',
|
||
0x04: 'absolute mode (record number in P1)',
|
||
},
|
||
},
|
||
'body': None,
|
||
},
|
||
0xD6: {
|
||
'name': 'UPDATE BINARY',
|
||
'p1p2': {'fmt': 'uint16be', 'label': 'Offset'},
|
||
'body': {'label': 'Data'},
|
||
},
|
||
0xD7: {
|
||
'name': 'UPDATE BINARY (odd INS)',
|
||
'p1p2': {'fmt': 'uint16be', 'label': 'Offset'},
|
||
'body': {'label': 'BER-TLV data'},
|
||
},
|
||
0xDD: {
|
||
'name': 'UPDATE RECORD (odd INS)',
|
||
'p1': {'fmt': 'uint8', 'label': 'Record number'},
|
||
'p2': {
|
||
'label': 'Mode',
|
||
'bits': {
|
||
0x02: 'next record',
|
||
0x03: 'previous record',
|
||
0x04: 'absolute mode (record number in P1)',
|
||
},
|
||
},
|
||
'body': {'label': 'BER-TLV data'},
|
||
},
|
||
0xD2: {
|
||
'name': 'WRITE RECORD',
|
||
'p1': {'fmt': 'uint8', 'label': 'Record number'},
|
||
'p2': {
|
||
'label': 'Mode',
|
||
'bits': {
|
||
0x02: 'next record',
|
||
0x03: 'previous record',
|
||
0x04: 'absolute mode (record number in P1)',
|
||
},
|
||
},
|
||
'body': {'label': 'Data'},
|
||
},
|
||
0xDC: {
|
||
'name': 'UPDATE RECORD',
|
||
'p1': {'fmt': 'uint8', 'label': 'Record number'},
|
||
'p2': {
|
||
'label': 'Mode',
|
||
'bits': {
|
||
0x02: 'next record',
|
||
0x03: 'previous record',
|
||
0x04: 'absolute mode (record number in P1)',
|
||
},
|
||
},
|
||
'body': {'label': 'Data'},
|
||
},
|
||
0x20: {
|
||
'name': 'VERIFY PIN',
|
||
'p1': {0x00: 'No indication'},
|
||
'p2': PIN_KEY_REFS,
|
||
'body': {'label': 'PIN value'},
|
||
},
|
||
0x21: {
|
||
'name': 'VERIFY',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'Data'},
|
||
},
|
||
0x24: {
|
||
'name': 'CHANGE PIN',
|
||
'p1': {0x00: 'No indication'},
|
||
'p2': PIN_KEY_REFS,
|
||
'body': {'label': 'Old+new PIN'},
|
||
},
|
||
0x26: {
|
||
'name': 'DISABLE PIN',
|
||
'p1': {0x00: 'No indication'},
|
||
'p2': PIN_KEY_REFS,
|
||
'body': {'label': 'PIN value'},
|
||
},
|
||
0x28: {
|
||
'name': 'ENABLE PIN',
|
||
'p1': {0x00: 'No indication'},
|
||
'p2': PIN_KEY_REFS,
|
||
'body': {'label': 'PIN value'},
|
||
},
|
||
0x2C: {
|
||
'name': 'UNBLOCK PIN',
|
||
'p1': {0x00: 'No indication'},
|
||
'p2': PIN_KEY_REFS,
|
||
'body': {'label': 'PUK + new PIN'},
|
||
},
|
||
0x88: {
|
||
'name': 'AUTHENTICATE',
|
||
'p1': {0x00: 'No indication'},
|
||
'p2': {'label': 'Auth context', 'bits': {
|
||
0x00: 'GSM context',
|
||
0x01: '3G/EPS/5G context',
|
||
0x02: 'VGCS/VBS context',
|
||
0x04: 'GBA context',
|
||
0x80: 'Specific reference data',
|
||
}},
|
||
'body': {'label': 'Challenge/session key'},
|
||
},
|
||
0x89: {
|
||
'name': 'AUTHENTICATE',
|
||
'body': {'label': 'Response/resynchronisation data'},
|
||
},
|
||
0x82: {
|
||
'name': 'EXTERNAL AUTHENTICATE',
|
||
'p1': {'label': 'Security level'},
|
||
'p2': {0x00: 'No indication'},
|
||
'body': {'label': 'Cryptogram'},
|
||
},
|
||
0x86: {
|
||
'name': 'GENERAL AUTHENTICATE',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'Auth data'},
|
||
},
|
||
0x87: {
|
||
'name': 'GENERAL AUTHENTICATE (odd INS)',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'BER-TLV auth data'},
|
||
},
|
||
0x22: {
|
||
'name': 'MANAGE SECURITY ENVIRONMENT',
|
||
'p1': {0x00: 'No indication', 0x01: 'Set', 0x02: 'Verify', 0x03: 'Restore', 0xF3: 'Erase'},
|
||
'p2': {'label': 'SE reference'},
|
||
'body': {'label': 'SE parameters'},
|
||
},
|
||
0x84: {
|
||
'name': 'GET CHALLENGE',
|
||
'le': True,
|
||
'p1p2': {'unused': True},
|
||
'body': None,
|
||
},
|
||
0x70: {
|
||
'name': 'MANAGE CHANNEL',
|
||
'p1': {0x00: 'Open channel', 0x80: 'Close channel'},
|
||
'p2': {0x00: 'Auto-assign channel', **{n: f'Channel {n}' for n in range(1, 20)}},
|
||
'body': None,
|
||
},
|
||
0xC0: {
|
||
'name': 'GET RESPONSE',
|
||
'le': True,
|
||
'p1p2': {'unused': True},
|
||
'body': None,
|
||
},
|
||
0xC2: {
|
||
'name': 'ENVELOPE',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'TLV data'},
|
||
},
|
||
0x12: {
|
||
'name': 'FETCH',
|
||
'le': True,
|
||
'p1p2': {'unused': True},
|
||
'body': None,
|
||
},
|
||
0x14: {
|
||
'name': 'TERMINAL RESPONSE',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'TLV data'},
|
||
},
|
||
0x32: {
|
||
'name': 'INCREASE',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'Data'},
|
||
},
|
||
0x04: {
|
||
'name': 'DEACTIVATE FILE',
|
||
'p1': {0x00: 'EF by file ID', 0x08: 'Path from MF', 0x09: 'Path from current DF'},
|
||
'p2': {0x00: 'No indication'},
|
||
'body': {'label': 'File ID/path'},
|
||
},
|
||
0x44: {
|
||
'name': 'ACTIVATE FILE',
|
||
'p1': {0x00: 'EF by file ID', 0x08: 'Path from MF', 0x09: 'Path from current DF'},
|
||
'p2': {0x00: 'No indication'},
|
||
'body': {'label': 'File ID/path'},
|
||
},
|
||
0x0E: {
|
||
'name': 'ERASE BINARY',
|
||
'p1p2': {'fmt': 'uint16be', 'label': 'Offset'},
|
||
'body': {'label': 'Erase data'},
|
||
},
|
||
0x0C: {
|
||
'name': 'ERASE RECORDS',
|
||
'p1': {'fmt': 'uint8', 'label': 'Record number'},
|
||
'p2': {
|
||
'label': 'Mode',
|
||
'bits': {
|
||
0x02: 'next record',
|
||
0x03: 'previous record',
|
||
0x04: 'absolute mode (record number in P1)',
|
||
},
|
||
},
|
||
'body': None,
|
||
},
|
||
0x7C: {
|
||
'name': 'MANAGE LSI',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'LSI parameters'},
|
||
},
|
||
0xF2: {
|
||
'name': 'STATUS',
|
||
'le': True,
|
||
'p1': {0x00: 'No indication', 0x01: 'Current DF', 0x02: 'EF under current DF',
|
||
0x04: 'DF name', 0x0d: 'Applet status'},
|
||
'p2': {0x00: 'No indication'},
|
||
'body': None,
|
||
},
|
||
0xE0: {
|
||
'name': 'CREATE FILE',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'TLV data'},
|
||
},
|
||
0xE4: {
|
||
'name': 'DELETE FILE',
|
||
'p1': {0x00: 'Delete EF/DF', 0x0c: 'Delete EF', 0x0d: 'Delete DF'},
|
||
'p2': {0x00: 'No indication'},
|
||
'body': None,
|
||
},
|
||
0xAA: {
|
||
'name': 'TERMINAL CAPABILITY',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'Terminal capability template (A9)'},
|
||
},
|
||
0x73: {
|
||
'name': 'MANAGE SECURE CHANNEL',
|
||
'body': {'label': 'TLV data'},
|
||
},
|
||
0x75: {
|
||
'name': 'TRANSACT DATA',
|
||
'body': {'label': 'TLV data'},
|
||
},
|
||
0x78: {
|
||
'name': 'GET IDENTITY',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'TLV data'},
|
||
},
|
||
0xA2: {
|
||
'name': 'SEARCH RECORD',
|
||
'p1': {'fmt': 'uint8', 'label': 'Record number'},
|
||
'p2': {
|
||
'label': 'Mode',
|
||
'bits': {
|
||
0x04: 'Simple search (forward)',
|
||
0x05: 'Simple search (backward)',
|
||
0x06: 'Enhanced search',
|
||
0x07: 'Proprietary search',
|
||
},
|
||
},
|
||
'body': {'label': 'Search pattern'},
|
||
},
|
||
0xCB: {
|
||
'name': 'RETRIEVE DATA',
|
||
'le': True,
|
||
'p1': {0x00: 'No indication'},
|
||
'p2': {'label': 'Mode', 'bits': {
|
||
0x80: 'First block',
|
||
0x40: 'Retransmit previous block',
|
||
}},
|
||
'body': {'label': 'TLV data'},
|
||
},
|
||
0xDB: {
|
||
'name': 'SET DATA',
|
||
'p1': {0x00: 'No indication'},
|
||
'p2': {'label': 'Mode', 'bits': {
|
||
0x80: 'First block',
|
||
0x40: 'Retransmit previous block',
|
||
}},
|
||
'body': {'label': 'TLV data'},
|
||
},
|
||
0x10: {
|
||
'name': 'TERMINAL PROFILE',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'Profile'},
|
||
},
|
||
0x76: {
|
||
'name': 'SUSPEND UICC',
|
||
'p1p2': {'unused': True},
|
||
'body': None,
|
||
},
|
||
0xCA: {
|
||
'name': 'GET DATA',
|
||
'le': True,
|
||
'p1p2': {'fmt': 'uint16be', 'label': 'Tag'},
|
||
'body': {'label': 'TLV data'},
|
||
},
|
||
0xDA: {
|
||
'name': 'PUT DATA',
|
||
'p1p2': {'fmt': 'uint16be', 'label': 'Tag'},
|
||
'body': {'label': 'TLV data'},
|
||
},
|
||
0xE2: {
|
||
'name': 'STORE DATA',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'TLV data'},
|
||
},
|
||
0xD0: {
|
||
'name': 'WRITE BINARY',
|
||
'p1p2': {'fmt': 'uint16be', 'label': 'Offset'},
|
||
'body': {'label': 'Data'},
|
||
},
|
||
0xD1: {
|
||
'name': 'WRITE BINARY (odd INS)',
|
||
'p1p2': {'fmt': 'uint16be', 'label': 'Offset'},
|
||
'body': {'label': 'BER-TLV data'},
|
||
},
|
||
0xA0: {
|
||
'name': 'SEARCH BINARY',
|
||
'p1p2': {'fmt': 'uint16be', 'label': 'Offset'},
|
||
'body': {'label': 'Search parameters'},
|
||
},
|
||
0xB3: {
|
||
'name': 'READ RECORD (odd INS)',
|
||
'le': True,
|
||
'p1': {'fmt': 'uint8', 'label': 'Record number'},
|
||
'p2': {
|
||
'label': 'Mode',
|
||
'bits': {
|
||
0x02: 'next record',
|
||
0x03: 'previous record',
|
||
0x04: 'absolute mode (record number in P1)',
|
||
},
|
||
},
|
||
},
|
||
0xC3: {
|
||
'name': 'ENVELOPE (odd INS)',
|
||
'body': {'label': 'BER-TLV data'},
|
||
},
|
||
0x7A: {
|
||
'name': 'EXCHANGE CAPABILITIES',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'Capability list'},
|
||
},
|
||
0xD4: {
|
||
'name': 'RESIZE FILE',
|
||
'p1p2': {'unused': True},
|
||
'body': {'label': 'BER-TLV data'},
|
||
},
|
||
0x0F: {
|
||
'name': 'ERASE BINARY (odd INS)',
|
||
'p1p2': {'fmt': 'uint16be', 'label': 'Offset'},
|
||
'body': {'label': 'BER-TLV data'},
|
||
},
|
||
0x2A: {
|
||
'name': 'PERFORM SECURITY OPERATION',
|
||
'p1': {'label': 'Operation'},
|
||
'p2': {'label': 'Operation'},
|
||
'body': {'label': 'Command data'},
|
||
},
|
||
0x46: {
|
||
'name': 'GENERATE ASYMMETRIC KEY PAIR',
|
||
'p1': {0x00: 'new key', 0x80: 'generate/derive', 0x81: 'read public key'},
|
||
'p2': {0x00: 'No indication'},
|
||
'body': {'label': 'BER-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',
|
||
0x79: 'LSI COMMAND',
|
||
0x81: 'END OF PROACTIVE SESSION',
|
||
}
|
||
|
||
# TS 102 223 §8.6 — REFRESH command qualifier (mode)
|
||
REFRESH_MODES = {
|
||
0x00: 'NAA Initialization and Full File Change Notification',
|
||
0x01: 'File Change Notification',
|
||
0x02: 'NAA Initialization and File Change Notification',
|
||
0x03: 'NAA Initialization',
|
||
0x04: 'UICC Reset',
|
||
0x05: 'NAA Application Reset',
|
||
0x06: 'NAA Session Reset',
|
||
0x07: 'Reserved (Steering of Roaming)',
|
||
0x08: 'Reserved (Steering of Roaming for I-WLAN)',
|
||
0x09: 'eUICC Profile State Change',
|
||
0x0A: 'Application Update',
|
||
}
|
||
|
||
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',
|
||
# TS 101 220 table 7.17 + TS 31.111 §9.1
|
||
0xD9: 'USSD DOWNLOAD',
|
||
0xDA: 'MMS TRANSFER STATUS',
|
||
0xDB: 'MMS NOTIFICATION',
|
||
0xDC: 'TERMINAL APPLICATIONS',
|
||
0xDD: 'GEOGRAPHICAL LOCATION REPORTING',
|
||
0xDE: 'ENVELOPE CONTAINER',
|
||
0xDF: 'PROSE REPORT',
|
||
0xE0: '5G PROSE REPORT',
|
||
0xE1: 'Reserved for 3GPP',
|
||
0xE2: 'Reserved for 3GPP',
|
||
0xE3: 'Reserved for 3GPP',
|
||
0xE4: 'GSMA',
|
||
}
|
||
|
||
|
||
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)',
|
||
0x07: 'ESN of the terminal',
|
||
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',
|
||
0x1A: 'Supported Radio Access Technologies',
|
||
}
|
||
|
||
|
||
# TS 102 223 §8.6 — Command Qualifier tables per Type of Command.
|
||
# Only commands with a known qualifier coding are listed; others fall back
|
||
# to a raw '0xXX' in _command_qualifier. An entry may also be a callable
|
||
# for bitmask-style qualifiers (returns the decoded string or None).
|
||
COMMAND_QUALIFIERS = {
|
||
0x01: REFRESH_MODES, # REFRESH
|
||
0x26: PLI_QUALIFIERS, # PROVIDE LOCAL INFORMATION
|
||
}
|
||
# NOTE: SET UP IDLE MODE TEXT ('28') has no entry — its Command Qualifier
|
||
# is RFU per TS 102 223 V18.3.0 §8.6 ("this byte is RFU"); the generic
|
||
# fallback (hidden at '00', raw hex otherwise) applies.
|
||
|
||
|
||
# ──────────────────── §8.6 bitmask qualifier decoders ────────────────────
|
||
# Each decoder lists the *deviations from default*; an all-default (0x00)
|
||
# qualifier stays hidden, a non-zero qualifier with only RFU bits set
|
||
# falls back to raw hex.
|
||
|
||
def _bitmask(fn):
|
||
def decoder(q):
|
||
val = fn(q)
|
||
parts = [val] if isinstance(val, str) else [p for p in val if p]
|
||
if parts:
|
||
return ', '.join(parts)
|
||
return None if q == 0 else f'0x{q:02X}'
|
||
return decoder
|
||
|
||
|
||
def _alphabet_bit(q):
|
||
return 'UCS2 alphabet requested' if q & 0x02 else None
|
||
|
||
|
||
def _help_bit(q):
|
||
return 'help information available' if q & 0x80 else None
|
||
|
||
|
||
@_bitmask
|
||
def _send_sm_qualifier(q):
|
||
return ['SMS packing by terminal required'] if q & 0x01 else []
|
||
|
||
|
||
@_bitmask
|
||
def _play_tone_qualifier(q):
|
||
return ['vibrate alert if available'] if q & 0x01 else []
|
||
|
||
|
||
@_bitmask
|
||
def _display_text_qualifier(q):
|
||
return [('high priority' if q & 0x01 else None),
|
||
('wait for user to clear message' if q & 0x80 else None)]
|
||
|
||
|
||
@_bitmask
|
||
def _get_inkey_qualifier(q):
|
||
if q & 0x04: # Yes/No mode disables the b1/b2 character-set coding
|
||
return ['Yes/No response requested',
|
||
'immediate digit response requested' if q & 0x08 else None,
|
||
_help_bit(q)]
|
||
return [
|
||
'alphabet set requested' if q & 0x01 else None,
|
||
_alphabet_bit(q),
|
||
'Yes/No response requested' if q & 0x04 else None,
|
||
'immediate digit response requested' if q & 0x08 else None,
|
||
_help_bit(q),
|
||
]
|
||
|
||
|
||
@_bitmask
|
||
def _get_input_qualifier(q):
|
||
return [
|
||
'alphabet set requested' if q & 0x01 else None,
|
||
_alphabet_bit(q),
|
||
'input shall not be revealed' if q & 0x04 else None,
|
||
'SMS packed format requested' if q & 0x08 else None,
|
||
_help_bit(q),
|
||
]
|
||
|
||
|
||
@_bitmask
|
||
def _select_item_qualifier(q):
|
||
pres = []
|
||
if q & 0x01:
|
||
pres.append('navigation options presentation'
|
||
if q & 0x02 else 'data values presentation')
|
||
return pres + ['selection using soft key preferred' if q & 0x04 else None,
|
||
_help_bit(q)]
|
||
|
||
|
||
@_bitmask
|
||
def _set_up_menu_qualifier(q):
|
||
return ['selection using soft key preferred' if q & 0x01 else None,
|
||
_help_bit(q)]
|
||
|
||
|
||
@_bitmask
|
||
def _timer_mgmt_qualifier(q):
|
||
# TS 102 223 §8.6: bits 1 to 2 — '00' start, '01' deactivate,
|
||
# '10' get current value, '11' RFU.
|
||
op = q & 0x03
|
||
return {1: 'deactivate timer', 2: 'get current timer value'}.get(op, [])
|
||
|
||
|
||
@_bitmask
|
||
def _language_notification_qualifier(q):
|
||
return ['specific language notification'] if q & 0x01 else []
|
||
|
||
|
||
@_bitmask
|
||
def _open_channel_qualifier(q):
|
||
# Coding depends on the bearer; bits decoded per the packet-data /
|
||
# local bearer definition (TS 102 223 §8.6).
|
||
parts = [p for p in [
|
||
'immediate link establishment' if q & 0x01 else None,
|
||
'automatic reconnection' if q & 0x02 else None,
|
||
'background mode link establishment' if q & 0x04 else None,
|
||
'DNS server address(es) requested' if q & 0x08 else None] if p]
|
||
return [p + ' (bearer-dependent)' for p in parts]
|
||
|
||
|
||
@_bitmask
|
||
def _close_channel_qualifier(q):
|
||
# Packet data service: b1 = reuse Network Access Name indication;
|
||
# other bearers code b1 differently — hence the note.
|
||
if q & 0x01:
|
||
return ['next OPEN CHANNEL reuses same NAA/bearer (bearer-dependent)']
|
||
return []
|
||
|
||
|
||
@_bitmask
|
||
def _send_data_qualifier(q):
|
||
return ['send data immediately'] if q & 0x01 else []
|
||
|
||
|
||
@_bitmask
|
||
def _declare_service_qualifier(q):
|
||
return ['delete service from terminal database'] if q & 0x01 else []
|
||
|
||
|
||
@_bitmask
|
||
def _display_mms_qualifier(q):
|
||
return [('high priority' if q & 0x01 else None),
|
||
('wait for user to clear message' if q & 0x80 else None)]
|
||
|
||
|
||
COMMAND_QUALIFIERS.update({
|
||
0x10: {0x00: 'set up call, but only if not currently busy',
|
||
0x01: 'set up call, not busy, with redial',
|
||
0x02: 'set up call, holding all other calls',
|
||
0x03: 'set up call, holding others, with redial',
|
||
0x04: 'set up call, disconnecting all other calls (if any)',
|
||
0x05: 'set up call, disconnecting others, with redial'},
|
||
0x13: _send_sm_qualifier, # SEND SHORT MESSAGE
|
||
0x15: {0x00: 'launch browser if not already launched',
|
||
0x02: 'use existing browser (no secured session)',
|
||
0x03: 'close browser session and launch new'}, # LAUNCH BROWSER
|
||
0x20: _play_tone_qualifier, # PLAY TONE
|
||
0x21: _display_text_qualifier, # DISPLAY TEXT
|
||
0x22: _get_inkey_qualifier, # GET INKEY
|
||
0x23: _get_input_qualifier, # GET INPUT
|
||
0x24: _select_item_qualifier, # SELECT ITEM
|
||
0x25: _set_up_menu_qualifier, # SET UP MENU
|
||
0x27: _timer_mgmt_qualifier, # TIMER MANAGEMENT
|
||
0x33: {0x00: 'Card reader status',
|
||
0x01: 'Card reader identifier'}, # GET READER STATUS
|
||
0x35: _language_notification_qualifier, # LANGUAGE NOTIFICATION
|
||
0x40: _open_channel_qualifier, # OPEN CHANNEL
|
||
0x41: _close_channel_qualifier, # CLOSE CHANNEL
|
||
0x43: _send_data_qualifier, # SEND DATA
|
||
0x47: _declare_service_qualifier, # DECLARE SERVICE
|
||
0x50: {0x00: 'draw separator between adjoining frames',
|
||
0x01: 'no separator between frames'}, # SET FRAMES
|
||
0x62: _display_mms_qualifier, # DISPLAY MULTIMEDIA MESSAGE
|
||
0x73: {0x00: 'end encapsulated command session',
|
||
0x01: 'request Master SA setup',
|
||
0x02: 'request Connection SA setup',
|
||
0x03: 'request Secure Channel Start',
|
||
0x04: 'close Master/Connection SA, keep session'}, # eCAT
|
||
0x79: {0x00: 'Proactive Session Request',
|
||
0x01: 'UICC Platform Reset'}, # LSI COMMAND
|
||
})
|
||
|
||
|
||
def _command_qualifier(cmd_type, qualifier):
|
||
"""Decode a Command Qualifier (Command Details byte 3) into a string.
|
||
|
||
Returns None when no meaningful qualifier is present, the raw '0xXX'
|
||
for a non-zero qualifier without a table, or '0xXX <name>' when a
|
||
table entry exists.
|
||
"""
|
||
if qualifier is None:
|
||
return None
|
||
table = COMMAND_QUALIFIERS.get(cmd_type)
|
||
if callable(table):
|
||
return table(qualifier)
|
||
if table:
|
||
name = table.get(qualifier)
|
||
if name is None:
|
||
return f'0x{qualifier:02X}'
|
||
return f'0x{qualifier:02X} {name}'
|
||
if qualifier == 0x00:
|
||
return None
|
||
return f'0x{qualifier:02X}'
|
||
|
||
|
||
def _command_details_name(value):
|
||
"""Decode a Command Details TLV value into the command type name."""
|
||
if len(value) < 2:
|
||
return None
|
||
if 0xF0 <= value[1] <= 0xFE: # TS 102 223 §9.4 — reserved for proprietary use
|
||
return f'Proprietary (0x{value[1]:02X})'
|
||
return CAT_COMMAND_TYPES.get(value[1])
|
||
|
||
|
||
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
|
||
|
||
|
||
def decode_tr_qualifier(body):
|
||
"""Extract the Command Qualifier echoed in a TERMINAL RESPONSE body."""
|
||
if not body:
|
||
return None
|
||
for tag, _length, value in parse_tlv(body):
|
||
if tag == 0x81 and len(value) >= 3:
|
||
return _command_qualifier(value[1], value[2])
|
||
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',
|
||
0x10: 'Proactive UICC session terminated by the user',
|
||
0x11: 'Backward move in the proactive UICC session requested by the user',
|
||
0x12: 'No response from user',
|
||
0x13: 'Help information required by the user',
|
||
0x14: 'USSD or SS transaction terminated by the user',
|
||
# TS 102 223 §8.12.1 / TS 31.111: '15'-'16' reserved for 3GPP;
|
||
# per-cause "terminal unable" details are Additional information
|
||
# bytes of result '20', not separate general results.
|
||
0x20: 'ME currently unable to process command',
|
||
0x21: 'Network currently unable to process command',
|
||
0x22: 'User did not accept the proactive command',
|
||
0x23: 'User cleared down call before connection or network release',
|
||
0x24: 'Action in contradiction with the current timer state',
|
||
0x25: 'Interaction with call control by NAA, temporary problem',
|
||
0x26: 'Launch browser generic error',
|
||
0x27: 'MMS temporary problem',
|
||
# '28'-'29' reserved for 3GPP
|
||
0x30: 'Command beyond ME capabilities',
|
||
0x31: 'Command type not understood by ME',
|
||
0x32: 'Command data not understood by ME',
|
||
0x33: 'Command number not known by ME',
|
||
0x34: 'SS Return Error',
|
||
0x35: 'SMS RP-ERROR',
|
||
0x36: 'Error, required values are missing',
|
||
0x37: 'USSD return error',
|
||
0x38: 'Multiple Card command error',
|
||
0x39: 'Interaction with call/SM control by USIM, permanent problem',
|
||
0x3A: 'Bearer Independent Protocol error',
|
||
0x3B: 'Access Technology unable to process command',
|
||
0x3C: 'Frames error',
|
||
0x3D: 'MMS error',
|
||
# '3E'-'3F' reserved for 3GPP
|
||
}
|
||
|
||
|
||
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 & 0x0F) * 10 + (x >> 4)
|
||
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 = {
|
||
# TS 102 221 Table 11.5 (overrides ISO 7816-4 Table 14, where 110 =
|
||
# cyclic and 100 = linear variable): 100 = cyclic, 110 = BER-TLV.
|
||
0b000: 'no information',
|
||
0b001: 'transparent',
|
||
0b010: 'linear fixed',
|
||
0b100: 'cyclic',
|
||
0b110: 'BER-TLV',
|
||
}
|
||
_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 # bits b6-b4 = file type (1-based; 0-based 5..3)
|
||
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).
|
||
|
||
SFI (tag 0x88) is resolved per TS 102 221 §11.1.2:
|
||
- length 1 → SFI = value >> 3 (bits b8..b4);
|
||
- length 0 → file does not support SFI;
|
||
- absent → SFI = 5 LSBs of the FID (only if in range 1..30).
|
||
"""
|
||
result = {}
|
||
fid = None
|
||
sfi_seen = False
|
||
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 == 0x80:
|
||
result['file_size'] = int.from_bytes(value, 'big')
|
||
elif tag == 0x81:
|
||
result['total_file_size'] = int.from_bytes(value, 'big')
|
||
elif tag == 0x88:
|
||
sfi_seen = True
|
||
result['sfi'] = value[0] >> 3 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 == 0x86:
|
||
result['security_attr_proprietary'] = value.hex().upper()
|
||
elif tag == 0x87:
|
||
result['extending_ef_id'] = value.hex().upper()
|
||
elif tag == 0x8B:
|
||
result['security_attr_expanded'] = value.hex().upper()
|
||
elif tag == 0x8C:
|
||
result['security_attr_compact'] = value.hex().upper()
|
||
elif tag == 0xAB:
|
||
result['security_attr_template'] = value.hex().upper()
|
||
if not sfi_seen and fid:
|
||
sfi = int(fid, 16) & 0x1F
|
||
result['sfi'] = sfi if 1 <= sfi <= 30 else None
|
||
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]
|
||
auts = data[2:2 + auts_len]
|
||
else:
|
||
auts = data[1:]
|
||
result = {'type': '3G/EPS/5G', 'status': 'sync fail', 'auts': auts.hex().upper()}
|
||
# AUTS = SQN_MS⊕AK (6) || MAC-S (8) (TS 33.102)
|
||
if len(auts) >= 14:
|
||
result['sqn_ak'] = auts[0:6].hex().upper()
|
||
result['mac_s'] = auts[6:14].hex().upper()
|
||
return result
|
||
# tag 0xDB: success → length-prefixed RES, CK, IK, (KC)
|
||
result = {'type': '3G/EPS/5G', '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 ────────────────────
|
||
|
||
def _decode_auth_cmd(data, p2):
|
||
"""Decode an AUTHENTICATE command body (RAND / RAND+AUTN)."""
|
||
ctx = p2 & 0x07
|
||
result = {}
|
||
if p2 & 0x80:
|
||
result['specific_key'] = True
|
||
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/EPS/5G → AUTN
|
||
alen = data[i]
|
||
i += 1
|
||
autn = data[i:i + alen]
|
||
result['autn'] = autn.hex().upper()
|
||
# AUTN = SQN⊕AK (6) || AMF (2) || MAC-A (8) (TS 33.102)
|
||
if len(autn) >= 16:
|
||
result['sqn_ak'] = autn[0:6].hex().upper()
|
||
result['amf'] = autn[6:8].hex().upper()
|
||
result['mac'] = autn[8:16].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 (single)',
|
||
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 event',
|
||
0x14: 'Access Technology Change (multiple)', 0x15: 'CSG cell selection',
|
||
0x16: 'Contactless state request', 0x17: 'IMS Registration',
|
||
0x18: 'IMS Incoming data', 0x19: 'Profile Container',
|
||
0x1A: 'Void', 0x1B: 'Secured Profile Container',
|
||
0x1C: 'Poll Interval Negotiation', 0x1D: 'Data Connection Status Change',
|
||
0x1E: 'CAG cell selection', 0x1F: 'Slices Status Change',
|
||
# TS 102 223 §8.25: '20'-'22' reserved for 3GPP (future usage)
|
||
}
|
||
|
||
LOCATION_STATUS = {
|
||
0x00: 'Normal service',
|
||
0x01: 'Limited service',
|
||
0x02: 'No service',
|
||
}
|
||
|
||
|
||
# TS 102 223 §8.7 device identity coding (summary per UICC_SPECS.md §6.2)
|
||
_DEVICE_ID_RANGES = [
|
||
(0x10, 0x17, lambda v: f'Card reader {v - 0x10}'),
|
||
(0x21, 0x27, lambda v: f'Channel {v - 0x21}'),
|
||
(0x31, 0x3F, lambda v: f'eCAT client {v - 0x30}'),
|
||
]
|
||
_DEVICE_IDS = {
|
||
0x01: 'Keypad', 0x02: 'Display', 0x03: 'Earpiece',
|
||
0x81: 'UICC', 0x82: 'Terminal', 0x83: 'Network',
|
||
}
|
||
|
||
|
||
def _device_id_name(value):
|
||
"""Decode one Device Identity byte (TS 102 223 §8.7)."""
|
||
if value in _DEVICE_IDS:
|
||
return _DEVICE_IDS[value]
|
||
for lo, hi, fmt in _DEVICE_ID_RANGES:
|
||
if lo <= value <= hi:
|
||
return fmt(value)
|
||
return f'0x{value:02X}'
|
||
|
||
|
||
def _decode_device_ids(value):
|
||
"""Decode a Device Identities TLV value into {src, dst} names."""
|
||
if len(value) < 2:
|
||
return None
|
||
return {'src': _device_id_name(value[0]), 'dst': _device_id_name(value[1])}
|
||
|
||
|
||
def _decode_timer_value(b):
|
||
"""Decode a 3-byte Timer Value (TS 102 223 §8.38): TP-SCTS-style
|
||
semi-octets (first digit in the low nibble) for hour/minute/second."""
|
||
if len(b) != 3:
|
||
return None
|
||
def so(x):
|
||
# TS 23.040 semi-octet representation: digit 1 in bits 1-4
|
||
return '%d%d' % (x & 0x0F, (x >> 4) & 0x0F)
|
||
return f'{so(b[0])}:{so(b[1])}:{so(b[2])}'
|
||
|
||
|
||
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',
|
||
0x70: 'Command Packet Identifier (CPI)',
|
||
0x71: 'Response Packet Identifier (RPI)',
|
||
}
|
||
|
||
_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]
|
||
if len(data) < length:
|
||
break # truncated UDH
|
||
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 / TS 24.008 text string (GsmOrUcs2).
|
||
|
||
Magic prefix 0x80/0x81/0x82 → UCS-2 variants (Annex A); 0x90 → UCS-2;
|
||
0x83-0x87 → GSM 7-bit packed; 0x88-0x8F → UCS-2 with the high bit of
|
||
the first octet cleared; otherwise GSM 7-bit default alphabet, packed
|
||
when any octet has the high bit set, else 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] == 0x90:
|
||
data = raw[1:]
|
||
null = data.find(b'\x00\x00')
|
||
if null != -1:
|
||
data = data[:null]
|
||
return data.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)
|
||
if 0x83 <= raw[0] <= 0x87:
|
||
data = raw[1:].rstrip(b'\xff')
|
||
return _decode_gsm7(data, None) if data else ''
|
||
if raw[0] & 0x80:
|
||
ucs2 = bytearray(raw)
|
||
ucs2[0] &= 0x7F
|
||
while len(ucs2) >= 2 and ucs2[-1] == 0xFF and ucs2[-2] == 0xFF:
|
||
ucs2 = ucs2[:-2]
|
||
null = ucs2.find(b'\x00\x00')
|
||
if null != -1:
|
||
ucs2 = ucs2[:null]
|
||
return bytes(ucs2).decode('utf-16-be', 'replace')
|
||
data = raw.rstrip(b'\xff')
|
||
if all(b < 0x80 for b in data):
|
||
return _decode_gsm7_octets(data)
|
||
return _decode_gsm7(data, None)
|
||
|
||
|
||
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
|
||
|
||
|
||
# TS 102 225 §5.1.1 — SPI bit-field names
|
||
_OTA_COUNTER = {0: 'no counter', 1: 'counter (no replay/seq check)',
|
||
2: 'counter must be higher', 3: 'counter must be one higher'}
|
||
_OTA_RC_CC_DS = {0: 'none', 1: 'RC', 2: 'CC', 3: 'DS'}
|
||
_OTA_POR = {0: 'no PoR', 1: 'PoR required', 2: 'PoR only on error'}
|
||
|
||
# TS 102 225 §5.1.2 / §5.1.3 — KIc / KID algorithms (low nibble)
|
||
_KIC_ALGO = {0: 'implicit', 1: 'DES', 2: 'AES-CBC', 5: '3DES-CBC (2 keys)', 9: '3DES-CBC (3 keys)'}
|
||
_KID_CC_ALGO = {0: 'implicit', 1: 'DES', 5: '3DES-CBC (2 keys)', 9: '3DES-CBC (3 keys)', 2: 'AES-CMAC'}
|
||
_KID_RC_ALGO = {0: 'implicit', 1: 'CRC16', 5: 'CRC32', 3: 'proprietary'}
|
||
|
||
# TS 102 225 §5.2 Table 5 + TS 31.115 §7 — Response status codes
|
||
RESPONSE_STATUS = {
|
||
0x00: 'PoR OK',
|
||
0x01: 'RC/CC/DS failed',
|
||
0x02: 'CNTR low',
|
||
0x03: 'CNTR high',
|
||
0x04: 'CNTR blocked',
|
||
0x05: 'Ciphering error',
|
||
0x06: 'Unidentified security error',
|
||
0x07: 'Insufficient memory',
|
||
0x08: 'More time needed',
|
||
0x09: 'TAR unknown',
|
||
0x0A: 'Insufficient security level',
|
||
0x0B: 'Actual response data to be sent using SMS-SUBMIT',
|
||
0x0C: 'Actual response data to be sent using USSD',
|
||
}
|
||
|
||
|
||
def _udh_ieis(udh):
|
||
"""Extract the numeric IEI values from a raw UDH byte string."""
|
||
ieis = set()
|
||
i = 0
|
||
while i + 2 <= len(udh):
|
||
ieis.add(udh[i])
|
||
i += 2 + udh[i + 1]
|
||
return ieis
|
||
|
||
|
||
def _decode_secured_packet(body):
|
||
"""Decode a secured Command Packet (TS 102 225 §5.1 / TS 31.115 §4.2).
|
||
|
||
*body* is the TP-UD remainder after the UDH (CPI IEI 0x70). Returns a
|
||
dict of the decoded header fields; ``{'raw': hex}`` when unparseable.
|
||
|
||
When SPI indicates ciphering, the on-wire CNTR, PCNTR and RC/CC/DS are
|
||
CIPHERTEXT (TS 102 225 Table 2, note 1 — the whole block from CNTR
|
||
through the secured data is encrypted); ``'ciphered'`` is set so
|
||
callers do not present those bytes as the real values.
|
||
"""
|
||
if not body or len(body) < 16:
|
||
return {'raw': body.hex().upper()} if body else {}
|
||
cpl = int.from_bytes(body[:2], 'big')
|
||
chl = body[2]
|
||
spi = body[3:5]
|
||
kic = body[5]
|
||
kid = body[6]
|
||
tar = body[7:10]
|
||
cntr = body[10:15]
|
||
pcntr = body[15]
|
||
spi1, spi2 = spi[0], spi[1]
|
||
ciphered = bool(spi1 & 0x04)
|
||
rc_cc_ds_kind = spi1 & 0x03
|
||
rc_cc_ds_len = max(0, chl - 13) # 13 = SPI+KIc+KID+TAR+CNTR+PCNTR
|
||
rc_cc_ds = body[16:16 + rc_cc_ds_len]
|
||
data = body[16 + rc_cc_ds_len:]
|
||
|
||
result = {
|
||
'cpl': cpl,
|
||
'chl': chl,
|
||
'ciphered': ciphered,
|
||
'spi': {
|
||
'hex': spi.hex().upper(),
|
||
'counter': _OTA_COUNTER.get((spi1 >> 4) & 0x03, 'reserved'),
|
||
'ciphering': bool(spi1 & 0x04),
|
||
'rc_cc_ds': _OTA_RC_CC_DS.get(rc_cc_ds_kind, 'reserved'),
|
||
'por': _OTA_POR.get(spi2 & 0x03, 'reserved'),
|
||
'por_rc_cc_ds': _OTA_RC_CC_DS.get((spi2 >> 2) & 0x03, 'reserved'),
|
||
'por_ciphered': bool(spi2 & 0x10),
|
||
'por_transport': 'SMS-DELIVER-REPORT' if not (spi2 & 0x20) else 'SMS-SUBMIT',
|
||
},
|
||
'kic': {'key': kic >> 4, 'algo': _KIC_ALGO.get(kic & 0x0F, 'reserved')},
|
||
'tar': tar.hex().upper(),
|
||
'cntr': cntr.hex().upper(),
|
||
'pcntr': pcntr,
|
||
'data': data.hex().upper(),
|
||
# Contiguous ciphered on-wire octets (CNTR ‖ PCNTR ‖ RC/CC/DS ‖
|
||
# secured data + padding) — the block a client-side decryptor
|
||
# feeds to 3DES/AES-CBC with a zero IV (TS 102 225 Table 2 n.1).
|
||
'cipher_block': body[10:].hex().upper(),
|
||
'kic_raw': kic,
|
||
'kid_raw': kid,
|
||
}
|
||
kic_algo = _KIC_ALGO.get(kic & 0x0F, 'reserved')
|
||
if not (spi1 & 0x04): # SPI says no ciphering → KIc unused
|
||
kic_algo += ' (unused)'
|
||
result['kic'] = {'key': kic >> 4, 'algo': kic_algo}
|
||
if rc_cc_ds_kind == 2: # CC
|
||
kid_algo = _KID_CC_ALGO.get(kid & 0x0F, 'reserved')
|
||
elif rc_cc_ds_kind == 1: # RC
|
||
kid_algo = _KID_RC_ALGO.get(kid & 0x0F, 'reserved')
|
||
elif rc_cc_ds_kind == 3: # DS — no simple nibble table
|
||
kid_algo = 'DS'
|
||
else: # none — decode the nibble anyway (CC interpretation)
|
||
kid_algo = _KID_CC_ALGO.get(kid & 0x0F, 'reserved')
|
||
if rc_cc_ds_kind == 0:
|
||
kid_algo += ' (unused)'
|
||
result['kid'] = {'key': kid >> 4, 'algo': kid_algo}
|
||
if rc_cc_ds:
|
||
result['rc_cc_ds'] = rc_cc_ds.hex().upper()
|
||
return result
|
||
|
||
|
||
def _decode_response_packet(body, ciphered=False):
|
||
"""Decode a Response Packet (TS 102 225 §5.2 / TS 31.115 §4.4).
|
||
|
||
*body* is the TP-UD remainder after the UDH (RPI IEI 0x71). Returns a
|
||
dict of the decoded header fields; ``{'raw': hex}`` when unparseable.
|
||
|
||
*ciphered* reflects the command's SPI2.b5 (PoR ciphering): when set,
|
||
the on-wire CNTR, PCNTR and RC/CC/DS are ciphertext (Table 4, note 1)
|
||
and ``'ciphered'`` is set in the result. A standalone Response Packet
|
||
(e.g. inside an SMS-SUBMIT) carries no SPI, so the state is unknowable
|
||
there and defaults to False.
|
||
"""
|
||
if not body or len(body) < 13:
|
||
return {'raw': body.hex().upper()} if body else {}
|
||
rpl = int.from_bytes(body[:2], 'big')
|
||
rhl = body[2]
|
||
tar = body[3:6]
|
||
cntr = body[6:11]
|
||
pcntr = body[11]
|
||
status = body[12]
|
||
rc_cc_ds_len = max(0, rhl - 10) # 10 = TAR+CNTR+PCNTR+Status
|
||
rc_cc_ds = body[13:13 + rc_cc_ds_len]
|
||
data = body[13 + rc_cc_ds_len:]
|
||
|
||
result = {
|
||
'rpl': rpl,
|
||
'rhl': rhl,
|
||
'ciphered': bool(ciphered),
|
||
'tar': tar.hex().upper(),
|
||
'cntr': cntr.hex().upper(),
|
||
'pcntr': pcntr,
|
||
'status': {'code': f'{status:02X}', 'name': RESPONSE_STATUS.get(status)},
|
||
'data': data.hex().upper(),
|
||
}
|
||
if rc_cc_ds:
|
||
result['rc_cc_ds'] = rc_cc_ds.hex().upper()
|
||
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')
|
||
ud_data = ud[:udl]
|
||
|
||
# Parse UDH first — the presence of a CPI/RPI IEI identifies a secured
|
||
# command/response packet (TS 31.115) regardless of the PID.
|
||
udh_ieis = set()
|
||
body = ud_data
|
||
if udhi and ud_data:
|
||
udhl = ud_data[0]
|
||
result['udhl'] = udhl
|
||
result['udh'] = _decode_udh(ud_data[1:1 + udhl])
|
||
udh_ieis = _udh_ieis(ud_data[1:1 + udhl])
|
||
body = ud_data[1 + udhl:]
|
||
|
||
if pid == 0x7F or 0x70 in udh_ieis: # secured Command Packet
|
||
if pid == 0x7F:
|
||
result['pid_name'] = 'SIM data download (secured packet)'
|
||
secured = _decode_secured_packet(body)
|
||
result['secured'] = secured
|
||
if 'raw' in secured:
|
||
result['payload'] = secured['raw']
|
||
return result
|
||
|
||
if 0x71 in udh_ieis: # secured Response Packet (PoR)
|
||
rp = _decode_response_packet(body)
|
||
result['response_packet'] = rp
|
||
if 'raw' in rp:
|
||
result['payload'] = rp['raw']
|
||
return result
|
||
|
||
if udhi and ud_data:
|
||
if encoding == 'GSM 7-bit':
|
||
fill_bits = (udhl + 1) * 8
|
||
n = udl - ((fill_bits + 6) // 7)
|
||
result['text'] = _decode_gsm7(body, n)
|
||
result['payload'] = body.hex().upper()
|
||
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_data, udl)
|
||
result['payload'] = ud_data.hex().upper()
|
||
elif encoding == 'UCS2':
|
||
result['text'] = ud_data[:udl].decode('utf-16-be', errors='replace')
|
||
else:
|
||
result['payload'] = ud_data[:udl].hex().upper()
|
||
text = _decode_8bit_text(ud_data[: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}')}
|
||
|
||
if tag == 0xDE: # ENVELOPE CONTAINER — wraps another complete envelope
|
||
for t, _l, v in parse_tlv(value):
|
||
if t in ENVELOPE_TYPES:
|
||
sub = _decode_envelope(bytes([t, len(v)]) + v)
|
||
sub['encapsulated'] = True
|
||
result['encapsulated'] = sub
|
||
elif (t & 0x7F) == _P_DEVICE_IDS:
|
||
d = _decode_device_ids(v)
|
||
if d:
|
||
result['device_ids'] = d
|
||
return result
|
||
|
||
inner = parse_tlv(value)
|
||
raw_tlv = []
|
||
for t, _l, v in inner:
|
||
base = t & 0x7F
|
||
if base == _P_DEVICE_IDS: # common to every envelope type
|
||
d = _decode_device_ids(v)
|
||
if d:
|
||
result['device_ids'] = d
|
||
continue
|
||
if tag == 0xD6: # EVENT DOWNLOAD
|
||
if base == _P_EVENT_LIST and v:
|
||
result['events'] = [EVENT_TYPES.get(e, f'0x{e:02X}') for e in v]
|
||
elif base == _P_LOCATION_STATUS:
|
||
if v:
|
||
result['location_status'] = LOCATION_STATUS.get(v[0], f'0x{v[0]:02X}')
|
||
elif base == _P_LOCAL_INFO:
|
||
li = _decode_local_info(PLI_LOCATION_INFO, v)
|
||
result['location_info'] = li['value'] if li else v.hex().upper()
|
||
elif base == _P_ADDRESS and v: # caller's number (MT call)
|
||
result['caller'] = _decode_bcd_address(v)
|
||
elif base == _P_TRANSACTION_ID:
|
||
if v:
|
||
result['transaction_id'] = v[0]
|
||
elif base == _P_SUBADDRESS:
|
||
result['subaddress'] = v.hex().upper()
|
||
else:
|
||
raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()})
|
||
elif tag == 0xD5: # MO SHORT MESSAGE CONTROL
|
||
if base == _P_ADDRESS and v:
|
||
if 'smsc' not in result:
|
||
result['smsc'] = _decode_bcd_address(v)
|
||
else:
|
||
result['tp_da'] = _decode_bcd_address(v)
|
||
elif base == _P_LOCAL_INFO:
|
||
result['location_info'] = v.hex().upper()
|
||
else:
|
||
raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()})
|
||
elif tag == 0xD1: # SMS-PP DOWNLOAD
|
||
if base == _P_ADDRESS and v:
|
||
result['smsc'] = _decode_bcd_address(v)
|
||
elif base == _P_SMS_TPDU and v:
|
||
result['tpdu'] = _decode_sm_tpdu(v)
|
||
else:
|
||
raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()})
|
||
elif tag == 0xD2: # CELL BROADCAST DOWNLOAD
|
||
if base == _P_CB_PAGE and v:
|
||
result['cb_page'] = _decode_cb_page(v)
|
||
else:
|
||
raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()})
|
||
elif tag == 0xD3: # MENU SELECTION
|
||
if base == _P_ITEM_ID and v:
|
||
result['item_id'] = v[0]
|
||
elif base == _P_HELP_REQUEST: # TS 101 220: '15'/'95', length 00
|
||
result['help'] = True
|
||
else:
|
||
raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()})
|
||
elif tag == 0xD4: # CALL CONTROL
|
||
if base == _P_ADDRESS and v:
|
||
result['address'] = _decode_bcd_address(v)
|
||
elif base == _P_CCP and v:
|
||
result['ccp'] = v.hex().upper()
|
||
elif base == _P_SUBADDRESS and v:
|
||
result['subaddress'] = v.hex().upper()
|
||
else:
|
||
raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()})
|
||
elif tag == 0xD7: # TIMER EXPIRATION
|
||
if base == _P_TIMER_ID and v:
|
||
result['timer_id'] = v[0] if len(v) == 1 else v.hex().upper()
|
||
elif base == _P_TIMER_VALUE:
|
||
tv = _decode_timer_value(v)
|
||
if tv:
|
||
result['timer_value'] = tv
|
||
else:
|
||
raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()})
|
||
elif tag == 0xD9: # USSD DOWNLOAD
|
||
if base == _P_USSD_STRING and v:
|
||
result['ussd'] = _decode_dcs_text(v)
|
||
else:
|
||
raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()})
|
||
elif tag == 0xDD: # GEOGRAPHICAL LOCATION REPORTING
|
||
if base == _P_LOCAL_INFO and v:
|
||
li = _decode_local_info(PLI_LOCATION_INFO, v)
|
||
result['location_info'] = li['value'] if li else v.hex().upper()
|
||
else:
|
||
raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()})
|
||
else: # DA/DB/DC/DF/E0/… — keep objects visible as hex
|
||
raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()})
|
||
|
||
if raw_tlv:
|
||
result['raw_tlv'] = raw_tlv
|
||
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_FILE_LIST = 0x12
|
||
_P_RESPONSE_LEN = 0x11
|
||
_P_EVENT_LIST = 0x19
|
||
_P_ICON_ID = 0x1E
|
||
_P_AID = 0x2F
|
||
# TS 101 220 table (COMPREHENSION-TLV tags, base values; CR form = +0x80)
|
||
_P_CCP = 0x07
|
||
_P_SUBADDRESS = 0x08
|
||
_P_USSD_STRING = 0x0A
|
||
_P_CB_PAGE = 0x0C
|
||
_P_LOCAL_INFO = 0x13
|
||
_P_HELP_REQUEST = 0x15
|
||
_P_ITEM_ID = 0x10
|
||
_P_LOCATION_STATUS = 0x1B
|
||
_P_TRANSACTION_ID = 0x1C
|
||
_P_TIMER_ID = 0x24
|
||
_P_TIMER_VALUE = 0x25
|
||
|
||
# Commands whose payload is defined as a Text String (GSM 11.14 / TS 102 223):
|
||
# DISPLAY TEXT, GET INKEY, GET INPUT, SET UP IDLE MODE TEXT. Used for a
|
||
# guarded fallback against non-compliant cards that send the Text String
|
||
# with the next object's tag ('10', Item identifier).
|
||
_TEXT_STRING_COMMANDS = {0x21, 0x22, 0x23, 0x28}
|
||
|
||
|
||
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
|
||
qualifier = None
|
||
result = {}
|
||
items = []
|
||
file_list = []
|
||
aid = None
|
||
raw_tlv = []
|
||
text_seen = False
|
||
for tag, _length, value in inner:
|
||
base = tag & 0x7F
|
||
if base == _P_CMD_DETAILS and len(value) >= 2:
|
||
cmd_type = value[1]
|
||
if len(value) >= 3:
|
||
qualifier = value[2]
|
||
elif base == _P_ALPHA_ID and value:
|
||
result['title'] = _decode_annex_a(value)
|
||
elif base == _P_TEXT_STRING:
|
||
result['text'] = _decode_dcs_text(value)
|
||
result.pop('text_note', None) # compliant TLV wins over quirk note
|
||
text_seen = True
|
||
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)
|
||
elif base == _P_ICON_ID and value:
|
||
result['icon_id'] = value[0]
|
||
elif base == _P_ITEM_ID and len(value) == 1:
|
||
result['item_id'] = value[0]
|
||
elif base == _P_ITEM_ID and len(value) >= 2 and cmd_type in _TEXT_STRING_COMMANDS \
|
||
and not text_seen:
|
||
# Non-compliant card quirk (observed on a real SAT SIM): the
|
||
# Text String carries the Item identifier tag '10' instead of
|
||
# '8D'. A genuine Item identifier is one byte, so length plus
|
||
# command context disambiguate the two.
|
||
result['text'] = _decode_dcs_text(value)
|
||
result['text_note'] = "text string with non-standard tag '10'"
|
||
text_seen = True
|
||
elif base == _P_FILE_LIST and value:
|
||
file_list = [value[i:i + 2].hex().upper() for i in range(0, len(value), 2)]
|
||
elif base == _P_AID and value:
|
||
aid = value.hex().upper()
|
||
elif base == _P_DEVICE_IDS and len(value) >= 2:
|
||
result['device_ids'] = _decode_device_ids(value)
|
||
else:
|
||
raw_tlv.append({'tag': f'{base:02X}', 'value': value.hex().upper()})
|
||
|
||
if cmd_type is None:
|
||
return None
|
||
if 0xF0 <= cmd_type <= 0xFE: # TS 102 223 §9.4 — reserved for proprietary use
|
||
result['type'] = f'Proprietary (0x{cmd_type:02X})'
|
||
else:
|
||
result['type'] = CAT_COMMAND_TYPES.get(cmd_type, f'0x{cmd_type:02X}')
|
||
qualifier_desc = _command_qualifier(cmd_type, qualifier)
|
||
if qualifier_desc:
|
||
result['qualifier'] = qualifier_desc
|
||
if file_list:
|
||
result['file_list'] = file_list
|
||
if aid:
|
||
result['aid'] = aid
|
||
if items:
|
||
result['items'] = items
|
||
if raw_tlv:
|
||
result['raw_tlv'] = raw_tlv
|
||
return result
|
||
|
||
|
||
# ──────────────────── TERMINAL PROFILE device characteristics ────────────────────
|
||
|
||
def _decode_terminal_profile(data):
|
||
"""Decode a TERMINAL PROFILE (TS 102 223 §5.2) into device facts.
|
||
|
||
Bit numbering per the spec tables: b1 = LSB = first row, so a facility
|
||
at bit b(n) has mask ``1 << (n - 1)``. Only *declared* capabilities are
|
||
reported — a clear bit is never proof of absence (real 3G devices such
|
||
as the Huawei E173 and Nokia E52 leave the HSDPA bit clear), so no
|
||
"2G" claim is derived from a zero byte 17.
|
||
|
||
Inference rules and their caveats: projects/docs/TERMINAL_PROFILE.md.
|
||
"""
|
||
if not data:
|
||
return None
|
||
n = len(data)
|
||
|
||
def byte(num):
|
||
return data[num - 1] if n >= num else None
|
||
|
||
b4, b11, b12, b13 = byte(4), byte(11), byte(12), byte(13)
|
||
b14, b15, b17, b18 = byte(14), byte(15), byte(17), byte(18)
|
||
b21, b22 = byte(21), byte(22)
|
||
|
||
result = {'length': n}
|
||
|
||
# --- BIP: byte 12 b1 OPEN CHANNEL; byte 13 bearers + channel count ---
|
||
if b12 is not None:
|
||
result['bip'] = bool(b12 & 0x01)
|
||
if b13 is not None:
|
||
result['bip_channels'] = (b13 >> 5) & 0x07
|
||
result['bip_bearers'] = [name for mask, name in (
|
||
(0x01, 'CSD'), (0x02, 'GPRS'), (0x04, 'Bluetooth'),
|
||
(0x08, 'IrDA'), (0x10, 'RS232')) if b13 & mask]
|
||
|
||
# --- Radio generation: byte 17 b8 HSDPA (3G), b7 E-UTRAN (4G) ---
|
||
if b17 is not None:
|
||
hsdpa = bool(b17 & 0x80)
|
||
eutran = bool(b17 & 0x40)
|
||
result['hsdpa'] = hsdpa
|
||
result['eutran'] = eutran
|
||
if hsdpa and eutran:
|
||
result['generation'] = '3G/4G'
|
||
elif eutran:
|
||
result['generation'] = '4G'
|
||
elif hsdpa:
|
||
result['generation'] = '3G'
|
||
result['bip_transports'] = [name for mask, name in (
|
||
(0x01, 'TCP client, remote'), (0x02, 'UDP client, remote'),
|
||
(0x04, 'TCP server'), (0x08, 'TCP client, local'),
|
||
(0x10, 'UDP client, local'), (0x20, 'direct channel'))
|
||
if b17 & mask]
|
||
if b22 is not None:
|
||
result['utran_ps'] = bool(b22 & 0x01)
|
||
|
||
# --- Standards family: byte 18 b7 IMEISV (3GPP), b5 ESN (CDMA) ---
|
||
if b18 is not None:
|
||
imeisv = bool(b18 & 0x40)
|
||
esn = bool(b18 & 0x10)
|
||
result['imeisv'] = imeisv
|
||
result['esn'] = esn
|
||
if imeisv and esn:
|
||
result['standards'] = '3GPP + CDMA'
|
||
elif imeisv:
|
||
result['standards'] = '3GPP'
|
||
elif esn:
|
||
result['standards'] = 'CDMA (3GPP2)'
|
||
|
||
# --- UI facts: byte 11 soft keys; bytes 14/15 screen; byte 21 markup ---
|
||
if b11 is not None:
|
||
result['soft_keys'] = b11
|
||
if b14 is not None:
|
||
result['screen_h'] = b14 & 0x1F
|
||
if b15 is not None:
|
||
result['screen_w'] = b15 & 0x7F
|
||
if b21 is not None:
|
||
result['markup'] = [name for mask, name in (
|
||
(0x01, 'WML'), (0x02, 'XHTML'), (0x04, 'HTML'), (0x08, 'CHTML'))
|
||
if b21 & mask]
|
||
|
||
# --- Device class (strict rules, TERMINAL_PROFILE.md §4) ---
|
||
no_display = bool(b14 & 0x20) if b14 is not None else False # byte 14 b6
|
||
no_keypad = bool(b14 & 0x40) if b14 is not None else False # byte 14 b7
|
||
if no_display or no_keypad:
|
||
result['device_class'] = 'modem'
|
||
reasons = [name for name, set_ in (
|
||
('no display', no_display), ('no keypad', no_keypad)) if set_]
|
||
result['class_reason'] = 'declared: ' + ', '.join(reasons)
|
||
else:
|
||
screen_present = bool((result.get('screen_h') or 0)
|
||
or (result.get('screen_w') or 0))
|
||
soft_keys = result.get('soft_keys')
|
||
menus = True
|
||
if b4 is not None:
|
||
menus = bool(b4 & 0x01) or bool(b4 & 0x20) # SELECT ITEM / SET UP MENU
|
||
if soft_keys == 0 and not screen_present and not menus:
|
||
result['device_class'] = 'modem'
|
||
result['class_reason'] = 'likely: no soft keys, no screen size, no menus'
|
||
elif screen_present and soft_keys not in (None, 0):
|
||
result['device_class'] = 'handset'
|
||
result['class_reason'] = (f"screen {result.get('screen_h') or 0}"
|
||
f"\u00d7{result.get('screen_w') or 0}"
|
||
f", soft keys {soft_keys}")
|
||
|
||
# --- Era from the profile length (soft signal, TERMINAL_PROFILE.md §3) ---
|
||
if n <= 9:
|
||
result['era'] = 'early/minimal profile'
|
||
elif n <= 20:
|
||
result['era'] = 'classic 2G/3G-era profile'
|
||
elif n <= 28:
|
||
result['era'] = 'transitional-era profile'
|
||
elif n <= 32:
|
||
result['era'] = 'smartphone/BIP-era profile'
|
||
else:
|
||
result['era'] = 'modern LTE/5G-era profile'
|
||
|
||
# --- Concise APDU-list description (replaces the raw hex) ---
|
||
parts = []
|
||
if result.get('device_class'):
|
||
parts.append(result['device_class'])
|
||
if result.get('generation'):
|
||
parts.append(result['generation'])
|
||
if result.get('bip') is True:
|
||
parts.append('BIP')
|
||
elif result.get('bip') is False:
|
||
parts.append('no BIP')
|
||
result['summary'] = ' \u00b7 '.join(parts) if parts else f'{n}-byte profile'
|
||
return result
|
||
|
||
|
||
# ──────────────────── TERMINAL CAPABILITY (TS 102 221 §11.1.19) ────────────────────
|
||
|
||
# The command data is the constructed terminal capability template (tag 'A9').
|
||
# Supply voltage class coding per Table 6.1 (same as the ATR class indication);
|
||
# the eUICC tag '83' bit meanings per GSMA SGP.22 v2.6 §3.4.2 Table 7.
|
||
_TC_SUPPLY_CLASSES = (
|
||
(0x01, 'A'),
|
||
(0x02, 'B'),
|
||
(0x04, 'C'),
|
||
(0x08, 'D'),
|
||
(0x10, 'E'),
|
||
)
|
||
|
||
_TC_EUICC_SGP22_BITS = (
|
||
(0x01, 'LUId'),
|
||
(0x02, 'LPDd'),
|
||
(0x04, 'LDSd'),
|
||
(0x08, 'LUIe (SCWS)'),
|
||
)
|
||
|
||
|
||
def _decode_terminal_capability(data):
|
||
"""Decode a TERMINAL CAPABILITY command body (TS 102 221 §11.1.19).
|
||
|
||
TLV objects inside the template (tag 'A9'):
|
||
'80' terminal power supply, '81' extended logical channels terminal
|
||
support, '82' additional interfaces support, '83'/'84' eUICC-related
|
||
device capabilities (GSMA SGP.22 / SGP.32). Unknown and private TLV
|
||
objects are preserved as ``raw_tlv``.
|
||
"""
|
||
inner = None
|
||
for tag, _length, value in parse_tlv(data):
|
||
if tag == 0xA9:
|
||
inner = value
|
||
break
|
||
if inner is None:
|
||
return None
|
||
|
||
result = {}
|
||
raw_tlv = []
|
||
for tag, _length, value in parse_tlv(inner):
|
||
if tag == 0x80: # terminal power supply (§11.1.19.2.1)
|
||
supply = {
|
||
'class_raw': value[0] if value else None,
|
||
'classes': [name for bit, name in _TC_SUPPLY_CLASSES
|
||
if value and value[0] & bit],
|
||
}
|
||
if len(value) >= 2:
|
||
supply['max_current_ma'] = value[1]
|
||
if len(value) >= 3:
|
||
supply['clock_raw'] = value[2]
|
||
supply['clock_mhz'] = (None if value[2] == 0xFF
|
||
else round(value[2] / 10, 1))
|
||
result['power_supply'] = supply
|
||
elif tag == 0x81: # extended logical channels (§11.1.19.2.2)
|
||
# Only zero length is defined; a UICC treats any length as zero.
|
||
result['extended_channels'] = True
|
||
if value:
|
||
result['extended_channels_raw'] = value.hex().upper()
|
||
elif tag == 0x82: # additional interfaces support (§11.1.19.2.3)
|
||
if value:
|
||
result['interfaces'] = ['UICC-CLF'] if value[0] & 0x01 else []
|
||
if len(value) > 1: # extra bytes are ignored by the UICC
|
||
result['interfaces_raw'] = value.hex().upper()
|
||
elif tag == 0x83: # eUICC indications — GSMA SGP.22 (§11.1.19.2.4)
|
||
caps = {'raw': value.hex().upper() if value else ''}
|
||
if value:
|
||
caps['functions'] = [name for bit, name in _TC_EUICC_SGP22_BITS
|
||
if value[0] & bit]
|
||
result['euicc_sgp22'] = caps
|
||
elif tag == 0x84: # eUICC indications — GSMA SGP.32 (raw, no pinned spec)
|
||
result['euicc_sgp32'] = {'raw': value.hex().upper() if value else ''}
|
||
else:
|
||
raw_tlv.append({'tag': f'{tag:02X}', 'value': value.hex().upper()})
|
||
|
||
if raw_tlv:
|
||
result['raw_tlv'] = raw_tlv
|
||
return result or None
|
||
|
||
|
||
# ──────────────────── Decode entry point ────────────────────
|
||
|
||
# ──────────────────── File data decoders (READ/UPDATE) ────────────────────
|
||
# These decode the *body* of READ BINARY/RECORD and UPDATE BINARY/RECORD
|
||
# commands using the currently selected file (see select_target_fid and the
|
||
# server's selection tracking). Sourced from pySim (TS 51.011 SIM, TS 31.102
|
||
# USIM, TS 31.103 ISIM).
|
||
|
||
# USIM Service Table (TS 31.102) — service n ↔ bit (n-1)
|
||
UST_SERVICES = {
|
||
1: 'Local Phone Book', 2: 'Fixed Dialling Numbers (FDN)', 3: 'Extension 2',
|
||
4: 'Service Dialling Numbers (SDN)', 5: 'Extension3', 6: 'Barred Dialling Numbers (BDN)',
|
||
7: 'Extension4', 8: 'Outgoing Call Information (OCI and OCT)',
|
||
9: 'Incoming Call Information (ICI and ICT)', 10: 'Short Message Storage (SMS)',
|
||
11: 'Short Message Status Reports (SMSR)', 12: 'Short Message Service Parameters (SMSP)',
|
||
13: 'Advice of Charge (AoC)', 14: 'Capability Configuration Parameters 2 (CCP2)',
|
||
15: 'Cell Broadcast Message Identifier', 16: 'Cell Broadcast Message Identifier Ranges',
|
||
17: 'Group Identifier Level 1', 18: 'Group Identifier Level 2', 19: 'Service Provider Name',
|
||
20: 'User controlled PLMN selector with Access Technology', 21: 'MSISDN', 22: 'Image (IMG)',
|
||
23: 'Support of Localised Service Areas (SoLSA)', 24: 'Enhanced Multi-Level Precedence and Pre-emption Service',
|
||
25: 'Automatic Answer for eMLPP', 26: 'RFU', 27: 'GSM Access', 28: 'Data download via SMS-PP',
|
||
29: 'Data download via SMS-CB', 30: 'Call Control by USIM', 31: 'MO-SMS Control by USIM',
|
||
32: 'RUN AT COMMAND command', 33: 'shall be set to 1', 34: 'Enabled Services Table',
|
||
35: 'APN Control List (ACL)', 36: 'Depersonalisation Control Keys', 37: 'Co-operative Network List',
|
||
38: 'GSM security context', 39: 'CPBCCH Information', 40: 'Investigation Scan', 41: 'MexE',
|
||
42: 'Operator controlled PLMN selector with Access Technology', 43: 'HPLMN selector with Access Technology',
|
||
44: 'Extension 5', 45: 'PLMN Network Name', 46: 'Operator PLMN List', 47: 'Mailbox Dialling Numbers',
|
||
48: 'Message Waiting Indication Status', 49: 'Call Forwarding Indication Status', 50: 'Reserved and shall be ignored',
|
||
51: 'Service Provider Display Information', 52: 'Multimedia Messaging Service (MMS)', 53: 'Extension 8',
|
||
54: 'Call control on GPRS by USIM', 55: 'MMS User Connectivity Parameters',
|
||
56: "Network's indication of alerting in the MS (NIA)", 57: 'VGCS Group Identifier List (EFVGCS and EFVGCSS)',
|
||
58: 'VBS Group Identifier List (EFVBS and EFVBSS)', 59: 'Pseudonym',
|
||
60: 'User Controlled PLMN selector for I-WLAN access', 61: 'Operator Controlled PLMN selector for I-WLAN access',
|
||
62: 'User controlled WSID list', 63: 'Operator controlled WSID list', 64: 'VGCS security', 65: 'VBS security',
|
||
66: 'WLAN Reauthentication Identity', 67: 'Multimedia Messages Storage',
|
||
68: 'Generic Bootstrapping Architecture (GBA)', 69: 'MBMS security',
|
||
70: 'Data download via USSD and USSD application mode', 71: 'Equivalent HPLMN',
|
||
72: 'Additional TERMINAL PROFILE after UICC activation', 73: 'Equivalent HPLMN Presentation Indication',
|
||
74: 'Last RPLMN Selection Indication', 75: 'OMA BCAST Smart Card Profile',
|
||
76: 'GBA-based Local Key Establishment Mechanism', 77: 'Terminal Applications', 78: 'Service Provider Name Icon',
|
||
79: 'PLMN Network Name Icon', 80: 'Connectivity Parameters for USIM IP connections',
|
||
81: 'Home I-WLAN Specific Identifier List', 82: 'I-WLAN Equivalent HPLMN Presentation Indication',
|
||
83: 'I-WLAN HPLMN Priority Indication', 84: 'I-WLAN Last Registered PLMN', 85: 'EPS Mobility Management Information',
|
||
86: 'Allowed CSG Lists and corresponding indications', 87: 'Call control on EPS PDN connection by USIM',
|
||
88: 'HPLMN Direct Access', 89: 'eCall Data', 90: 'Operator CSG Lists and corresponding indications',
|
||
91: 'Support for SM-over-IP', 92: 'Support of CSG Display Control', 93: 'Communication Control for IMS by USIM',
|
||
94: 'Extended Terminal Applications', 95: 'Support of UICC access to IMS',
|
||
96: 'Non-Access Stratum configuration by USIM', 97: 'PWS configuration by USIM', 98: 'RFU',
|
||
99: 'URI support by UICC', 100: 'Extended EARFCN support', 101: 'ProSe', 102: 'USAT Application Pairing',
|
||
103: 'Media Type support', 104: 'IMS call disconnection cause', 105: 'URI support for MO SHORT MESSAGE CONTROL',
|
||
106: 'ePDG configuration Information support', 107: 'ePDG configuration Information configured', 108: 'ACDC support',
|
||
109: 'MCPTT', 110: 'ePDG configuration Information for Emergency Service support',
|
||
111: 'ePDG configuration Information for Emergency Service configured', 112: 'eCall Data over IMS',
|
||
113: 'URI support for SMS-PP DOWNLOAD as defined in 3GPP TS 31.111 [12]', 114: 'From Preferred',
|
||
115: 'IMS configuration data', 116: 'TV configuration', 117: '3GPP PS Data Off',
|
||
118: '3GPP PS Data Off Service List', 119: 'V2X', 120: 'XCAP Configuration Data',
|
||
121: 'EARFCN list for MTC/NB-IOT UEs', 122: '5GS Mobility Management Information', 123: '5G Security Parameters',
|
||
124: 'Subscription identifier privacy support', 125: 'SUCI calculation by the USIM',
|
||
126: 'UAC Access Identities support',
|
||
127: 'Expect control plane-based Steering of Roaming information during initial registration in VPLMN',
|
||
128: 'Call control on PDU Session by USIM', 129: '5GS Operator PLMN List',
|
||
130: 'Support for SUPI of type NSI or GLI or GCI', 131: '3GPP PS Data Off separate Home and Roaming lists',
|
||
132: 'Support for URSP by USIM', 133: '5G Security Parameters extended', 134: 'MuD and MiD configuration data',
|
||
135: 'Support for Trusted non-3GPP access networks by USIM',
|
||
136: 'Support for multiple records of NAS security context storage for multiple registration',
|
||
137: 'Pre-configured CAG information list', 138: 'SOR-CMCI storage in USIM', 139: '5G ProSe',
|
||
140: 'Storage of disaster roaming information in USIM', 141: 'Pre-configured eDRX parameters',
|
||
142: '5G NSWO support', 143: 'PWS configuration for SNPN in USIM',
|
||
144: 'Multiplier Coefficient for Higher Priority PLMN search via NG-RAN satellite access',
|
||
145: 'K_AUSF derivation configuration', 146: 'Network Identifier for SNPN (NID)',
|
||
}
|
||
|
||
# SIM Service Table (TS 51.011)
|
||
SST_SERVICES = {
|
||
1: 'CHV1 disable function', 2: 'Abbreviated Dialling Numbers (ADN)', 3: 'Fixed Dialling Numbers (FDN)',
|
||
4: 'Short Message Storage (SMS)', 5: 'Advice of Charge (AoC)', 6: 'Capability Configuration Parameters (CCP)',
|
||
7: 'PLMN selector', 8: 'RFU', 9: 'MSISDN', 10: 'Extension1', 11: 'Extension2', 12: 'SMS Parameters',
|
||
13: 'Last Number Dialled (LND)', 14: 'Cell Broadcast Message Identifier', 15: 'Group Identifier Level 1',
|
||
16: 'Group Identifier Level 2', 17: 'Service Provider Name', 18: 'Service Dialling Numbers (SDN)',
|
||
19: 'Extension3', 20: 'RFU', 21: 'VGCS Group Identifier List (EFVGCS and EFVGCSS)',
|
||
22: 'VBS Group Identifier List (EFVBS and EFVBSS)', 23: 'enhanced Multi-Level Precedence and Pre-emption Service',
|
||
24: 'Automatic Answer for eMLPP', 25: 'Data download via SMS-CB', 26: 'Data download via SMS-PP',
|
||
27: 'Menu selection', 28: 'Call control', 29: 'Proactive SIM', 30: 'Cell Broadcast Message Identifier Ranges',
|
||
31: 'Barred Dialling Numbers (BDN)', 32: 'Extension4', 33: 'De-personalization Control Keys',
|
||
34: 'Co-operative Network List', 35: 'Short Message Status Reports', 36: "Network's indication of alerting in the MS",
|
||
37: 'Mobile Originated Short Message control by SIM', 38: 'GPRS', 39: 'Image (IMG)',
|
||
40: 'SoLSA (Support of Local Service Area)', 41: 'USSD string data object supported in Call Control',
|
||
42: 'RUN AT COMMAND command', 43: 'User controlled PLMN Selector with Access Technology',
|
||
44: 'Operator controlled PLMN Selector with Access Technology', 45: 'HPLMN Selector with Access Technology',
|
||
46: 'CPBCCH Information', 47: 'Investigation Scan', 48: 'Extended Capability Configuration Parameters',
|
||
49: 'MExE', 50: 'Reserved and shall be ignored', 51: 'PLMN Network Name', 52: 'Operator PLMN List',
|
||
53: 'Mailbox Dialling Numbers', 54: 'Message Waiting Indication Status', 55: 'Call Forwarding Indication Status',
|
||
56: 'Service Provider Display Information', 57: 'Multimedia Messaging Service (MMS)', 58: 'Extension 8',
|
||
59: 'MMS User Connectivity Parameters',
|
||
}
|
||
|
||
# ISIM Service Table (TS 31.103)
|
||
IST_SERVICES = {
|
||
1: 'P-CSCF address', 2: 'Generic Bootstrapping Architecture (GBA)', 3: 'HTTP Digest',
|
||
4: 'GBA-based Local Key Establishment Mechanism', 5: 'Support of P-CSCF discovery for IMS Local Break Out',
|
||
6: 'Short Message Storage (SMS)', 7: 'Short Message Status Reports (SMSR)',
|
||
8: 'Support for SM-over-IP including data download via SMS-PP as defined in TS 31.111 [31]',
|
||
9: 'Communication Control for IMS by ISIM', 10: 'Support of UICC access to IMS', 11: 'URI support by UICC',
|
||
12: 'Media Type support', 13: 'IMS call disconnection cause', 14: 'URI support for MO SHORT MESSAGE CONTROL',
|
||
15: 'MCPTT', 16: 'URI support for SMS-PP DOWNLOAD as defined in 3GPP TS 31.111 [31]', 17: 'From Preferred',
|
||
18: 'IMS configuration data', 19: 'XCAP Configuration Data', 20: 'WebRTC URI',
|
||
21: 'MuD and MiD configuration data', 22: 'IMS Data Channel indication',
|
||
}
|
||
|
||
_PHASE_NAMES = {0x00: 'phase 1', 0x02: 'phase 2', 0x03: 'phase 2 and higher'}
|
||
|
||
|
||
def _decode_service_table(raw, table):
|
||
flags = []
|
||
for i, b in enumerate(raw):
|
||
for bit in range(8):
|
||
if b & (1 << bit):
|
||
n = i * 8 + bit + 1
|
||
flags.append({'n': n, 'name': table.get(n)})
|
||
return {'services': flags}
|
||
|
||
|
||
def _decode_imsi(raw, p1=None):
|
||
if not raw or all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
if len(raw) <= 2: # 6F07 is also the ISIM Service Table (IST)
|
||
return _decode_service_table(raw, IST_SERVICES)
|
||
digits = _swap_nibbles(raw[1:].hex()).rstrip('fF')
|
||
# First nibble is the mobile-identity type (001) + parity indicator
|
||
# (TS 24.008), not an IMSI digit.
|
||
if digits:
|
||
digits = digits[1:]
|
||
return {'imsi': digits or None}
|
||
|
||
|
||
def _decode_iccid(raw, p1=None):
|
||
if not raw or all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
digits = _swap_nibbles(raw.hex()).rstrip('fF')
|
||
return {'iccid': digits or None}
|
||
|
||
|
||
def _decode_li(raw, p1=None):
|
||
"""EF_LI / EF_PL — 2-letter ISO 639 language codes."""
|
||
codes = []
|
||
for i in range(0, len(raw) - 1, 2):
|
||
pair = raw[i:i + 2]
|
||
if pair == b'\xff\xff':
|
||
continue
|
||
try:
|
||
c = pair.decode('ascii')
|
||
except UnicodeDecodeError:
|
||
continue
|
||
if c.isalpha():
|
||
codes.append(c)
|
||
return {'languages': codes}
|
||
|
||
|
||
def _looks_like_ber_tlv(data):
|
||
"""Heuristic: does *data* look like a BER-TLV rather than Annex-A text?
|
||
|
||
Annex-A UCS2 prefixes are 0x80/0x81/0x82, so those are excluded; other
|
||
0x83-0xBF leading bytes are treated as BER-TLV tags if their length
|
||
field is consistent with the data length.
|
||
"""
|
||
if len(data) < 2:
|
||
return False
|
||
if data[0] in (0x80, 0x81, 0x82):
|
||
return False
|
||
if not (0x80 <= data[0] <= 0xBF):
|
||
return False
|
||
length = data[1]
|
||
if length & 0x80:
|
||
num = length & 0x7F
|
||
if num == 0 or 2 + num > len(data):
|
||
return False
|
||
length = int.from_bytes(data[2:2 + num], 'big')
|
||
header = 2 + num
|
||
else:
|
||
header = 2
|
||
return header + length >= len(data) - 1
|
||
|
||
|
||
def _decode_adn(raw, p1=None):
|
||
"""ADN-format record: alpha + len_bcd + TON/NPI + number + CCP + ext1."""
|
||
if not raw or all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
if len(raw) < 14:
|
||
return {'raw': raw.hex().upper()}
|
||
alpha = raw[:-14].rstrip(b'\xff')
|
||
# If the alpha is not text (e.g. BER-TLV), show raw hex instead.
|
||
if alpha and _looks_like_ber_tlv(alpha):
|
||
return {'raw': raw.hex().upper()}
|
||
name = _decode_annex_a(alpha) if alpha else ''
|
||
number = _decode_bcd_address(raw[-13:-2])
|
||
ccp = raw[-2]
|
||
ext1 = raw[-1]
|
||
out = {'name': name, 'number': number}
|
||
if ccp != 0xff:
|
||
out['ccp'] = ccp
|
||
if ext1 != 0xff:
|
||
out['ext1'] = ext1
|
||
return out
|
||
|
||
|
||
def _decode_sms_rec(raw, p1=None):
|
||
"""EF_SMS record (TS 51.011 §10.5.3): status byte + SMSC address + TPDU."""
|
||
if not raw or all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
s = raw[0]
|
||
if s & 0x01 == 0x00:
|
||
direction, status = None, 'free space'
|
||
elif s & 0x07 == 0x01:
|
||
direction, status = 'MT', 'message read'
|
||
elif s & 0x07 == 0x03:
|
||
direction, status = 'MT', 'message to be read'
|
||
elif s & 0x07 == 0x07:
|
||
direction, status = 'MO', 'message to be sent'
|
||
elif s & 0x1f == 0x05:
|
||
direction, status = 'MO', 'sent (status not requested)'
|
||
elif s & 0x1f == 0x0d:
|
||
direction, status = 'MO', 'sent (status requested, not received)'
|
||
elif s & 0x1f == 0x15:
|
||
direction, status = 'MO', 'sent (status received, not stored)'
|
||
elif s & 0x1f == 0x1d:
|
||
direction, status = 'MO', 'sent (status received, stored)'
|
||
else:
|
||
direction, status = None, 'RFU'
|
||
out = {'direction': direction, 'status': status}
|
||
remainder = raw[1:]
|
||
# Skip the TS-Service-Centre-Address: 1 length octet + that many octets.
|
||
if remainder:
|
||
sc_len = remainder[0]
|
||
i = 1 + sc_len
|
||
tpdu_bytes = remainder[i:].rstrip(b'\xff')
|
||
if tpdu_bytes:
|
||
out['tpdu'] = _decode_sm_tpdu(tpdu_bytes)
|
||
return out
|
||
|
||
|
||
def _decode_plmn_list(raw, p1=None):
|
||
entries = []
|
||
for i in range(0, len(raw), 3):
|
||
chunk = raw[i:i + 3]
|
||
if len(chunk) < 3 or all(b == 0xff for b in chunk):
|
||
break
|
||
plmn = _decode_plmn(chunk)
|
||
if plmn:
|
||
entries.append(plmn)
|
||
return {'plmns': entries}
|
||
|
||
|
||
def _decode_plmn_wact(raw, p1=None):
|
||
"""PLMN selector with Access Technology (TS 51.011 §10.3.35): 5-byte entries.
|
||
|
||
Each entry: 3-byte PLMN + 2-byte access technology bitmask.
|
||
"""
|
||
entries = []
|
||
for i in range(0, len(raw) - 4, 5):
|
||
chunk = raw[i:i + 5]
|
||
if len(chunk) < 5:
|
||
break
|
||
plmn_bytes = chunk[:3]
|
||
if all(b == 0xff for b in plmn_bytes):
|
||
break
|
||
plmn = _decode_plmn(plmn_bytes)
|
||
if not plmn:
|
||
continue
|
||
act = (chunk[3] << 8) | chunk[4]
|
||
techs = _decode_plmn_act(act)
|
||
plmn['access_tech'] = ', '.join(techs) if techs else f'0x{act:04X}'
|
||
entries.append(plmn)
|
||
return {'plmns': entries}
|
||
|
||
|
||
def _decode_plmn_act(u16):
|
||
"""Decode the 2-byte access-technology field (TS 31.102 §4.2.5)."""
|
||
techs = set()
|
||
if u16 & 0x8000:
|
||
techs.add('UTRAN')
|
||
if u16 & 0x0800:
|
||
techs.add('NG-RAN')
|
||
if u16 & 0x0040:
|
||
techs.add('GSM COMPACT')
|
||
if u16 & 0x0020:
|
||
techs.add('cdma2000 HRPD')
|
||
if u16 & 0x0010:
|
||
techs.add('cdma2000 1xRTT')
|
||
e = u16 & 0x7000
|
||
if e in (0x4000, 0x7000):
|
||
techs.add('E-UTRAN WB-S1')
|
||
techs.add('E-UTRAN NB-S1')
|
||
elif e == 0x5000:
|
||
techs.add('E-UTRAN NB-S1')
|
||
elif e == 0x6000:
|
||
techs.add('E-UTRAN WB-S1')
|
||
g = u16 & 0x008C
|
||
if g in (0x0080, 0x008C):
|
||
techs.add('GSM')
|
||
techs.add('EC-GSM-IoT')
|
||
elif g == 0x0084:
|
||
techs.add('GSM')
|
||
elif g == 0x0086:
|
||
techs.add('EC-GSM-IoT')
|
||
return sorted(techs)
|
||
|
||
|
||
def _decode_dir(raw, p1=None):
|
||
"""EF_DIR record: application template(s) — AID (4F) + label (50)
|
||
+ optional EAP discretionary template (73, TS 102 310)."""
|
||
apps = []
|
||
for tag, _length, value in parse_tlv(raw):
|
||
if tag != 0x61:
|
||
continue
|
||
app = {}
|
||
for t2, _l2, v2 in parse_tlv(value):
|
||
if t2 == 0x4F:
|
||
app['aid'] = v2.hex().upper()
|
||
elif t2 == 0x50:
|
||
label = _decode_annex_a(v2)
|
||
if label:
|
||
app['label'] = label
|
||
elif t2 == 0x73:
|
||
eap = _decode_eap_template(v2)
|
||
if eap:
|
||
app['eap'] = eap
|
||
if app:
|
||
apps.append(app)
|
||
return {'applications': apps} if apps else {'raw': raw.hex().upper()}
|
||
|
||
|
||
# IANA EAP method types seen in TS 102 310 EF_DIR entries.
|
||
_EAP_TYPES = {
|
||
18: 'EAP-SIM',
|
||
23: 'EAP-AKA',
|
||
50: 'EAP-AKA\'',
|
||
}
|
||
|
||
|
||
def _decode_eap_template(value):
|
||
"""TS 102 310 §5.2: 73 → A0 { 80 EAP types, 81 DF FIDs, 82 label }."""
|
||
for t2, _l2, v2 in parse_tlv(value):
|
||
if t2 != 0xA0:
|
||
continue
|
||
eap = {}
|
||
for t3, _l3, v3 in parse_tlv(v2):
|
||
if t3 == 0x80:
|
||
eap['eap_types'] = [_EAP_TYPES.get(b, b) for b in v3]
|
||
elif t3 == 0x81:
|
||
eap['dfs'] = [v3[i:i + 2].hex() for i in range(0, len(v3), 2)]
|
||
elif t3 == 0x82:
|
||
eap['label'] = v3.decode('ascii', 'replace')
|
||
return eap
|
||
return {}
|
||
|
||
|
||
def _decode_arr(raw, p1=None):
|
||
entries = []
|
||
for tag, _length, value in parse_tlv(raw):
|
||
entries.append({'tag': f'0x{tag:02X}', 'hex': value.hex().upper()})
|
||
return {'rules': entries} if entries else {'raw': raw.hex().upper()}
|
||
|
||
|
||
def _decode_pnn_text(value):
|
||
"""Decode a TS 24.008 §10.5.3.5a Network Name (DCS byte + text)."""
|
||
if len(value) < 2:
|
||
return ''
|
||
dcs = value[0]
|
||
coding = (dcs >> 4) & 0x07
|
||
data = value[1:].rstrip(b'\xff')
|
||
if coding == 1: # UCS2
|
||
null = data.find(b'\x00\x00')
|
||
if null != -1:
|
||
data = data[:null]
|
||
return data.decode('utf-16-be', 'replace')
|
||
if coding == 0: # GSM 7-bit default alphabet
|
||
if all(b < 0x80 for b in data):
|
||
return _decode_gsm7_octets(data)
|
||
return _decode_gsm7(data, None)
|
||
return ''
|
||
|
||
|
||
def _decode_pnn(raw, p1=None):
|
||
if not raw or all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
names = {}
|
||
for tag, _length, value in parse_tlv(raw):
|
||
if tag == 0x43:
|
||
names['full'] = _decode_pnn_text(value)
|
||
elif tag == 0x45:
|
||
names['short'] = _decode_pnn_text(value)
|
||
return names or {'raw': raw.hex().upper()}
|
||
|
||
|
||
def _decode_cbmi(raw, p1=None):
|
||
ids = []
|
||
for i in range(0, len(raw) - 1, 2):
|
||
v = (raw[i] << 8) | raw[i + 1]
|
||
if v == 0xffff:
|
||
continue
|
||
ids.append(v)
|
||
return {'message_ids': ids}
|
||
|
||
|
||
def _decode_cbmir(raw, p1=None):
|
||
"""EF_CBMIR (§10.3.28): 4-byte ranges [msg-id low(2) + high(2)], 4n B."""
|
||
ranges = []
|
||
for i in range(0, len(raw) - 3, 4):
|
||
rec = raw[i:i + 4]
|
||
if all(b == 0xff for b in rec):
|
||
continue
|
||
low = (rec[0] << 8) | rec[1]
|
||
high = (rec[2] << 8) | rec[3]
|
||
ranges.append([low, high])
|
||
if not ranges:
|
||
return {'raw': raw.hex().upper()}
|
||
return {'ranges': ranges if len(ranges) > 1 else ranges[0]}
|
||
|
||
|
||
def _decode_ecc(raw, p1=None):
|
||
"""EF_ECC records (UICC_FILES §10.3.27): ECC digits BCD(2) + ESC(1)."""
|
||
if all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
recs = []
|
||
for i in range(0, len(raw) - 2, 3):
|
||
rec = raw[i:i + 3]
|
||
if all(b == 0xff for b in rec):
|
||
continue
|
||
digits = _swap_nibbles(rec[:2].hex()).rstrip('fF')
|
||
recs.append({'number': digits or None, 'esc': f'0x{rec[2]:02X}'})
|
||
if not recs:
|
||
return {'raw': raw.hex().upper()}
|
||
return recs[0] if len(recs) == 1 else {'numbers': recs}
|
||
|
||
|
||
def _decode_kc(raw, p1=None):
|
||
"""EF_Kc / EF_KcGPRS (UICC_FILES §10.3.3): Kc(8) + Kc/KSI byte
|
||
(b8-b5 ciphering key sequence number, b4-b1 RFU)."""
|
||
if all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
out = {'kc': raw[:8].hex().upper()} if len(raw) >= 8 else {}
|
||
if len(raw) >= 9:
|
||
out['ksi'] = (raw[8] >> 4) & 0x0F
|
||
return out or {'raw': raw.hex().upper()}
|
||
|
||
|
||
def _decode_opl(raw, p1=None):
|
||
"""EF_OPL (§10.3.42): PLMN(3, 'D' = wild) + LAC range(2+2) + PNN rec(1)."""
|
||
if all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
recs = []
|
||
for i in range(0, len(raw) - 7, 8):
|
||
rec = raw[i:i + 8]
|
||
if all(b == 0xff for b in rec):
|
||
continue
|
||
plmn = _decode_plmn(rec[:3]) or {}
|
||
lac_lo = rec[3:5].hex().upper()
|
||
lac_hi = rec[5:7].hex().upper()
|
||
recs.append({
|
||
'mcc': plmn.get('mcc'), 'mnc': plmn.get('mnc'),
|
||
'lac_range': [lac_lo, lac_hi],
|
||
'pnn_record': rec[7],
|
||
})
|
||
if not recs:
|
||
return {'raw': raw.hex().upper()}
|
||
return recs[0] if len(recs) == 1 else {'records': recs}
|
||
|
||
|
||
def _decode_ext1(raw, p1=None):
|
||
"""EXT records (§10.5.10): len(1) + BCD(10) + CCP(1) + EXT(1)."""
|
||
if all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
n = raw[0]
|
||
digits = _swap_nibbles(raw[1:11].hex())[:n * 2].rstrip('fF') if n else ''
|
||
return {
|
||
'number': digits or None,
|
||
'ccp': None if raw[11] == 0xff else raw[11],
|
||
'ext': None if raw[12] == 0xff else raw[12],
|
||
}
|
||
|
||
|
||
def _decode_ad(raw, p1=None):
|
||
"""EF_AD (§10.3.18): op mode + additional info + optional MNC length."""
|
||
if all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
op = raw[0] & 0x03
|
||
out = {'op_mode': {0: 'normal operation', 1: 'specific features'}.get(op, f'0x{op:02X}'),
|
||
'test_mode': bool(raw[0] & 0x04)}
|
||
if len(raw) >= 3:
|
||
out['additional_info'] = raw[1:3].hex().upper()
|
||
if len(raw) >= 4 and raw[3] != 0xff:
|
||
out['mnc_length'] = raw[3]
|
||
return out
|
||
|
||
|
||
def _decode_acl(raw, p1=None):
|
||
"""EF_ACL (§4.2.48): APN count byte + 'DD'-tagged APN TLVs."""
|
||
if all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
apns = []
|
||
for tag, _length, value in parse_tlv(raw[1:]):
|
||
if tag == 0xDD:
|
||
apns.append(value.decode('ascii', 'replace') if value else
|
||
'(network provided)')
|
||
return {'apns': apns} if apns else {'raw': raw.hex().upper()}
|
||
|
||
|
||
def _decode_smsp(raw, p1=None):
|
||
"""EF_SMSP (§10.5.6): alpha(Y) + PI + TP-DA(12) + SCA(12) + PID/DCS/VP."""
|
||
if all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
if len(raw) < 28:
|
||
return {'raw': raw.hex().upper()}
|
||
y = len(raw) - 28
|
||
out = {}
|
||
alpha = _decode_annex_a(raw[:y].rstrip(b'\xff'))
|
||
if alpha:
|
||
out['alpha'] = alpha
|
||
pi = raw[y]
|
||
out['param_indicators'] = f'0x{pi:02X}'
|
||
if not (pi & 0x01):
|
||
out['tp_da'] = _decode_bcd_address(raw[y + 1:y + 13][:12])
|
||
if not (pi & 0x10):
|
||
out['sca'] = _decode_bcd_address(raw[y + 13:y + 25][:12])
|
||
if not (pi & 0x02):
|
||
out['tp_pid'] = f'0x{raw[y + 25]:02X}'
|
||
if not (pi & 0x04):
|
||
out['tp_dcs'] = f'0x{raw[y + 26]:02X}'
|
||
if not (pi & 0x08):
|
||
out['tp_vp'] = f'0x{raw[y + 27]:02X}'
|
||
return out
|
||
|
||
|
||
def _decode_spdi(raw, p1=None):
|
||
"""EF_SPDI (§4.2.66): 'A3' container (or bare '80' value tags) with
|
||
3-byte PLMN records — same shape as EF_PLMNwAcT for _file_summary."""
|
||
if all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
plmns = []
|
||
|
||
def collect(value):
|
||
for i in range(0, len(value) - 2, 3):
|
||
plmn = _decode_plmn(value[i:i + 3])
|
||
if plmn and plmn not in plmns:
|
||
plmns.append(plmn)
|
||
|
||
for tag, _length, value in parse_tlv(raw):
|
||
if tag == 0xA3:
|
||
got = False
|
||
for t2, _l2, v2 in parse_tlv(value):
|
||
if t2 == 0x80:
|
||
collect(v2)
|
||
got = True
|
||
if not got:
|
||
collect(value) # bare records inside A3
|
||
elif tag == 0x80:
|
||
collect(value)
|
||
if not plmns:
|
||
return {'raw': raw.hex().upper()}
|
||
return {'plmns': plmns}
|
||
|
||
|
||
def _decode_spn(raw, p1=None):
|
||
if len(raw) < 2:
|
||
return {'raw': raw.hex().upper()}
|
||
cond = raw[0]
|
||
text = _decode_annex_a(raw[1:].rstrip(b'\xff'))
|
||
out = {'display_condition': f'0x{cond:02X}'}
|
||
if text:
|
||
out['name'] = text
|
||
return out
|
||
|
||
|
||
def _decode_loci(raw, p1=None):
|
||
# EF_LOCI (TS 31.102 §4.2.16): TMSI 4 + LAI 5 + TMSI_TIME 1 + update status 1 = 11 bytes.
|
||
if len(raw) < 11:
|
||
return {'raw': raw.hex().upper()}
|
||
tmsi = raw[0:4]
|
||
lai = raw[4:9]
|
||
plmn = _decode_plmn(lai[:3]) or {}
|
||
return {
|
||
'tmsi': None if all(b == 0xff for b in tmsi) else tmsi.hex().upper(),
|
||
'mcc': plmn.get('mcc'), 'mnc': plmn.get('mnc'),
|
||
'lac': '0x' + lai[3:5].hex().upper(),
|
||
'tmsi_time': f'0x{raw[9]:02X}',
|
||
'location_update_status': f'0x{raw[10]:02X}',
|
||
}
|
||
|
||
|
||
def _decode_acc(raw, p1=None):
|
||
if len(raw) < 2:
|
||
return {'raw': raw.hex().upper()}
|
||
val = (raw[0] << 8) | raw[1]
|
||
classes = [i for i in range(16) if val & (0x8000 >> i)]
|
||
return {'access_classes': classes}
|
||
|
||
|
||
def _decode_phase(raw, p1=None):
|
||
v = raw[0] if raw else 0
|
||
return {'phase': _PHASE_NAMES.get(v, f'0x{v:02X}')}
|
||
|
||
|
||
def _decode_epsloci(raw, p1=None):
|
||
# EF_EPSLOCI (TS 31.102 §4.2.91): GUTI 12 + TAI 5 + update status 1 = 18 bytes.
|
||
if all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
if len(raw) < 18:
|
||
return {'raw': raw.hex().upper()}
|
||
guti = raw[0:12]
|
||
tai = raw[12:17]
|
||
plmn = _decode_plmn(tai[:3]) or {}
|
||
return {
|
||
'guti': None if all(b == 0xff for b in guti) else guti.hex().upper(),
|
||
'mcc': plmn.get('mcc'), 'mnc': plmn.get('mnc'),
|
||
'tac': '0x' + tai[3:5].hex().upper(),
|
||
'eps_update_status': f'0x{raw[17]:02X}',
|
||
}
|
||
|
||
|
||
def _decode_psloci(raw, p1=None):
|
||
# EF_PSLOCI (TS 31.102 §4.2.23): P-TMSI 4 + P-TMSI sig 3 + RAI 6 + status 1 = 14 bytes.
|
||
if all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
if len(raw) < 14:
|
||
return {'raw': raw.hex().upper()}
|
||
ptmsi = raw[0:4]
|
||
rai = raw[7:13]
|
||
plmn = _decode_plmn(rai[:3]) or {}
|
||
return {
|
||
'p_tmsi': None if all(b == 0xff for b in ptmsi) else ptmsi.hex().upper(),
|
||
'p_tmsi_signature': raw[4:7].hex().upper(),
|
||
'mcc': plmn.get('mcc'), 'mnc': plmn.get('mnc'),
|
||
'lac': '0x' + rai[3:5].hex().upper(),
|
||
'rac': '0x' + rai[5:6].hex().upper(),
|
||
'update_status': f'0x{raw[13]:02X}',
|
||
}
|
||
|
||
|
||
def _decode_nai(raw, p1=None):
|
||
"""ISIM identity files (EF_IMPI/DOMAIN/IMPU): BER-TLV tag 0x80/0x81 + value.
|
||
|
||
An erased or unused record is all-0xFF, or a lone identity tag, or a
|
||
tag with a zero-length value (e.g. ``80 00`` + FF filler) — these are
|
||
reported as empty rather than decoded as text. Only the TLV *value*
|
||
bytes are decoded (UTF-8 for a SIP/TEL URI); the TLV header and any
|
||
trailing erase filler are never fed to the text decoder.
|
||
"""
|
||
if not raw or all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
data = bytes(raw).rstrip(b'\xff') # drop erase filler
|
||
if not data or (data[0] in (0x80, 0x81) and len(data) <= 2):
|
||
return {'empty': True} # lone tag, or tag + zero-length value = unused
|
||
texts = []
|
||
for tag, _length, value in parse_tlv(data):
|
||
if tag in (0x80, 0x81) and value:
|
||
texts.append(value.decode('utf-8', 'replace'))
|
||
if texts:
|
||
return {'text': texts[0] if len(texts) == 1 else ', '.join(texts)}
|
||
return {'raw': raw.hex().upper()}
|
||
|
||
|
||
def _decode_epsnsc(raw, p1=None):
|
||
# EF_EPSNSC (TS 31.102 §4.2.92): record = A0 { 80 KSI_ASME, 81 K_ASME,
|
||
# 82 UpLink NAS COUNT, 83 DownLink NAS COUNT, 84 NAS algorithms }.
|
||
if not raw or all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
inner = raw
|
||
for tag, _length, value in parse_tlv(raw):
|
||
if tag == 0xA0:
|
||
inner = value
|
||
break
|
||
out = {}
|
||
for tag, _length, value in parse_tlv(inner):
|
||
if tag == 0x80:
|
||
out['ksi_asme'] = value.hex().upper()
|
||
elif tag == 0x81:
|
||
out['k_asme'] = value.hex().upper()
|
||
elif tag == 0x82:
|
||
out['uplink_nas_count'] = int.from_bytes(value, 'big')
|
||
elif tag == 0x83:
|
||
out['downlink_nas_count'] = int.from_bytes(value, 'big')
|
||
elif tag == 0x84:
|
||
out['nas_algorithms'] = value.hex().upper()
|
||
if not out:
|
||
return {'raw': raw.hex().upper()}
|
||
return out
|
||
|
||
|
||
def _decode_hex(raw, p1=None):
|
||
if not raw or all(b == 0xff for b in raw):
|
||
return {'empty': True}
|
||
return {'raw': raw.hex().upper()}
|
||
|
||
|
||
FILE_DECODERS = {
|
||
'2fe2': _decode_iccid,
|
||
'6f07': _decode_imsi,
|
||
'6f05': _decode_li,
|
||
'2f05': _decode_li,
|
||
'6f3c': _decode_sms_rec,
|
||
'6f38': lambda raw, p1=None: _decode_service_table(raw, UST_SERVICES),
|
||
'6f30': _decode_plmn_list,
|
||
'6f7b': _decode_plmn_list,
|
||
'6f31': _decode_plmn_list,
|
||
'6fd9': _decode_plmn_list,
|
||
'6f60': _decode_plmn_wact,
|
||
'6f61': _decode_plmn_wact,
|
||
'6f62': _decode_plmn_wact,
|
||
'2f00': _decode_dir,
|
||
'6f40': _decode_adn,
|
||
'6f49': _decode_adn,
|
||
'6f3b': _decode_adn,
|
||
'6f3a': _decode_adn,
|
||
'6f4d': _decode_adn,
|
||
'6fc7': _decode_adn,
|
||
'6f80': _decode_adn,
|
||
'6f81': _decode_adn,
|
||
'6f45': _decode_cbmi,
|
||
'6f50': _decode_cbmir,
|
||
'2f06': _decode_arr,
|
||
'6f06': _decode_arr,
|
||
'6fc5': _decode_pnn,
|
||
'6fb7': _decode_ecc,
|
||
'6f46': _decode_spn,
|
||
'6f7e': _decode_loci,
|
||
'6f73': _decode_psloci,
|
||
'6fe3': _decode_epsloci,
|
||
'6fe4': _decode_epsnsc,
|
||
'6f08': _decode_hex,
|
||
'6f09': _decode_hex,
|
||
'6f3e': _decode_hex,
|
||
'6f3f': _decode_hex,
|
||
'6fae': _decode_phase,
|
||
'6f78': _decode_acc,
|
||
'6fad': _decode_hex,
|
||
'6f02': _decode_nai,
|
||
'6f03': _decode_nai,
|
||
'6f04': _decode_nai,
|
||
'6fcb': _decode_hex,
|
||
'6fc4': _decode_hex,
|
||
'6fca': _decode_hex,
|
||
'6fc9': _decode_hex,
|
||
'6f20': _decode_kc,
|
||
'6f52': _decode_kc,
|
||
'6fc6': _decode_opl,
|
||
'6f4a': _decode_ext1,
|
||
'6f4b': _decode_ext1,
|
||
'6f4c': _decode_ext1,
|
||
'6f4e': _decode_ext1,
|
||
'6f55': _decode_ext1,
|
||
'6fad': _decode_ad,
|
||
'6f57': _decode_acl,
|
||
'6f42': _decode_smsp,
|
||
'6fcd': _decode_spdi,
|
||
}
|
||
|
||
|
||
def _decode_file_data(fid, raw, p1=None):
|
||
"""Decode file data using the FID's registered decoder."""
|
||
if not raw:
|
||
return None
|
||
fn = FILE_DECODERS.get(fid)
|
||
if not fn:
|
||
return None
|
||
try:
|
||
out = fn(raw, p1=p1)
|
||
except Exception:
|
||
return None
|
||
if not out:
|
||
return None
|
||
out['fid'] = fid
|
||
out['ef'] = KNOWN_FIDS.get(fid, fid.upper())
|
||
return out
|
||
|
||
|
||
_RECORD_STRUCTURES = ('linear fixed', 'cyclic')
|
||
|
||
|
||
def _is_record_file(sel):
|
||
"""True if *sel* is (or may be) a record-based EF.
|
||
|
||
A None structure means we don't know the file structure (e.g. the
|
||
FCP/GET RESPONSE was lost too), so we allow the decode as best effort.
|
||
"""
|
||
if not sel:
|
||
return True
|
||
structure = sel.get('structure')
|
||
if structure is None:
|
||
return True
|
||
return structure in _RECORD_STRUCTURES
|
||
|
||
|
||
def _stale_file_note(sel):
|
||
"""Mark a decode as skipped because the selection is inconsistent."""
|
||
fid = sel.get('fid') or ''
|
||
return {'fid': fid, 'ef': KNOWN_FIDS.get(fid, fid.upper()), 'stale': True}
|
||
|
||
|
||
def _file_summary(f):
|
||
"""Compact one-line summary of a decoded file body."""
|
||
if f.get('stale'):
|
||
return 'record op on non-record file (selection stale?)'
|
||
if f.get('empty'):
|
||
return 'empty'
|
||
if f.get('unknown'):
|
||
return 'unknown file'
|
||
if f.get('imsi'):
|
||
return f"IMSI {f['imsi']}"
|
||
if f.get('iccid'):
|
||
return f"ICCID {f['iccid']}"
|
||
if f.get('languages'):
|
||
return 'languages ' + ' '.join(f['languages'])
|
||
if 'number' in f and 'name' in f:
|
||
return (f['name'] + ' ' + f['number']).strip()
|
||
if f.get('number'):
|
||
return f['number']
|
||
if f.get('text'):
|
||
return f['text']
|
||
if f.get('name'):
|
||
return f['name']
|
||
if f.get('plmns'):
|
||
return ', '.join(f"{p['mcc']}/{p['mnc']}" + (f" ({p['access_tech']})" if p.get('access_tech') else '')
|
||
for p in f['plmns'])
|
||
if f.get('services'):
|
||
return f"{len(f['services'])} services allocated"
|
||
if f.get('applications'):
|
||
parts = []
|
||
for a in f['applications']:
|
||
s = a.get('label') or a.get('aid') or ''
|
||
eap = a.get('eap')
|
||
if eap:
|
||
types = ', '.join(str(t) for t in eap.get('eap_types', []))
|
||
dfs = ', '.join(KNOWN_FIDS.get(d, d) for d in eap.get('dfs', []))
|
||
s += f" [{eap.get('label', 'EAP')}: {types} in {dfs}]"
|
||
parts.append(s)
|
||
return '; '.join(parts)
|
||
if f.get('tpdu'):
|
||
t = f['tpdu']
|
||
s = t.get('mti', '')
|
||
if t.get('text'):
|
||
s += f" \u00ab{t['text']}\u00bb"
|
||
return f"SMS {s}".strip()
|
||
if f.get('direction'):
|
||
return f"SMS {f['direction']} — {f['status']}"
|
||
if f.get('guti'):
|
||
return f"GUTI {f['guti']} · TAC {f.get('tac', '')} · status {f.get('eps_update_status', '')}"
|
||
if f.get('p_tmsi'):
|
||
return f"P-TMSI {f['p_tmsi']} · status {f.get('update_status', '')}"
|
||
if 'ksi_asme' in f:
|
||
return f"KSI {f['ksi_asme']} · NAS counts {f.get('uplink_nas_count', '?')}/{f.get('downlink_nas_count', '?')}"
|
||
if f.get('mcc'):
|
||
parts = [f"MCC {f['mcc']} MNC {f['mnc']}"]
|
||
if f.get('lac'):
|
||
parts.append(f"LAC {f['lac']}")
|
||
if f.get('tac'):
|
||
parts.append(f"TAC {f['tac']}")
|
||
if f.get('rac'):
|
||
parts.append(f"RAC {f['rac']}")
|
||
return ' · '.join(parts)
|
||
if f.get('access_classes') is not None:
|
||
return 'classes ' + ', '.join(str(c) for c in f['access_classes'])
|
||
if f.get('phase'):
|
||
return f['phase']
|
||
if f.get('range'):
|
||
return f"range {f['range'][0]}-{f['range'][1]}"
|
||
if f.get('message_ids') is not None:
|
||
return f"{len(f['message_ids'])} message IDs"
|
||
if f.get('record_numbers') is not None:
|
||
nums = f['record_numbers']
|
||
shown = ', '.join(str(n) for n in nums[:10])
|
||
if len(nums) > 10:
|
||
shown += ', \u2026'
|
||
return f"{len(nums)} record(s): {shown}"
|
||
return None
|
||
|
||
|
||
def _fcp_summary(response_for, fd, response):
|
||
"""Build a summary for a GET RESPONSE (FCP) following SELECT."""
|
||
ft = fd.get('file_type') or ''
|
||
label = f"response for {response_for}, {ft}"
|
||
if ft == 'DF or ADF':
|
||
name = response.get('file_id_name')
|
||
if name:
|
||
label += f" ({name})"
|
||
else:
|
||
struct = fd.get('structure') or ''
|
||
if struct:
|
||
label += f", {struct}"
|
||
nrec = fd.get('num_records')
|
||
rlen = fd.get('record_length')
|
||
fsize = response.get('file_size') or response.get('total_file_size')
|
||
if nrec and rlen:
|
||
label += f", {nrec} rec \u00d7 {rlen} B"
|
||
elif fsize:
|
||
label += f", {fsize} B"
|
||
return label
|
||
|
||
|
||
def _fcp_brief(response):
|
||
"""Build a short "what this FCP describes" fragment for STATUS entries.
|
||
|
||
E.g. ``FCP of MF (DF or ADF)`` or ``FCP of EF_ICCID (Working EF,
|
||
transparent, 10 B)``.
|
||
"""
|
||
fd = response.get('file_descriptor') or {}
|
||
fid = response.get('file_id')
|
||
name = response.get('file_id_name')
|
||
what = name or (fid.upper() if fid else None)
|
||
if not what and not fd:
|
||
return None
|
||
label = f'FCP of {what}' if what else 'FCP'
|
||
details = []
|
||
ft = fd.get('file_type')
|
||
if ft:
|
||
details.append(ft)
|
||
if ft and ft != 'DF or ADF':
|
||
struct = fd.get('structure')
|
||
if struct:
|
||
details.append(struct)
|
||
nrec = fd.get('num_records')
|
||
rlen = fd.get('record_length')
|
||
fsize = response.get('file_size') or response.get('total_file_size')
|
||
if nrec and rlen:
|
||
details.append(f'{nrec} rec \u00d7 {rlen} B')
|
||
elif fsize:
|
||
details.append(f'{fsize} B')
|
||
if details:
|
||
label += f" ({', '.join(details)})"
|
||
return label
|
||
|
||
|
||
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
|
||
p1p2 = result.get('p1p2')
|
||
if p1p2 and not p1p2.get('unused'):
|
||
if 'sfi' in p1p2:
|
||
p1txt = f"SFI {p1p2['sfi']}"
|
||
if p1p2.get('offset'):
|
||
p1txt += f", offset {p1p2['offset']}"
|
||
elif 'offset' in p1p2:
|
||
p1txt = f"Offset: 0x{p1p2['offset']:04X}"
|
||
else:
|
||
p1txt = f"{p1p2['label']}: 0x{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 p2.get('sfi'):
|
||
p2txt = f"SFI {p2['sfi']}"
|
||
elif 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)
|
||
|
||
if ins == 'f2' and body:
|
||
# STATUS response data is the FCP of the current DF/EF (seen in a
|
||
# TPDU capture as the bytes after the 5-byte command header).
|
||
size = body.get('size') or 0
|
||
txt = f"Data: {size} byte{'s' if size != 1 else ''}"
|
||
brief = _fcp_brief(result.get('response') or {})
|
||
if brief:
|
||
txt += f' \u2014 {brief}'
|
||
parts.append(txt)
|
||
|
||
tp = result.get('tp')
|
||
if tp and tp.get('summary'):
|
||
parts.append(tp['summary'])
|
||
|
||
cmd = result.get('cmd')
|
||
if cmd:
|
||
if cmd.get('context'):
|
||
parts.append(cmd['context'])
|
||
if cmd.get('qualifier'):
|
||
parts.append(cmd['qualifier'])
|
||
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('tp_da'):
|
||
parts.append(f"DA {cmd['tp_da']}")
|
||
events = list(cmd.get('events') or [])
|
||
status = cmd.get('location_status')
|
||
if status and 'Location status' in events:
|
||
# Fold the decoded Location Status value into its event entry
|
||
# (Normal service / Limited service / No service).
|
||
events[events.index('Location status')] = f'Location status: {status}'
|
||
elif status:
|
||
events.append(f'Location status: {status}')
|
||
if events:
|
||
parts.append(', '.join(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)
|
||
|
||
supply = cmd.get('power_supply')
|
||
interfaces = cmd.get('interfaces')
|
||
sgp22 = cmd.get('euicc_sgp22')
|
||
sgp32 = cmd.get('euicc_sgp32')
|
||
if supply or cmd.get('extended_channels') or interfaces or sgp22 or sgp32:
|
||
tc = []
|
||
if supply:
|
||
if supply.get('classes'):
|
||
tc.append('Class ' + '+'.join(supply['classes']))
|
||
if supply.get('max_current_ma') is not None:
|
||
tc.append(f"{supply['max_current_ma']} mA")
|
||
if supply.get('clock_mhz') is not None:
|
||
tc.append(f"CLK {supply['clock_mhz']:g} MHz")
|
||
elif supply.get('clock_raw') is not None:
|
||
tc.append('CLK n/a')
|
||
if cmd.get('extended_channels'):
|
||
tc.append('extended channels')
|
||
if interfaces:
|
||
tc.append(', '.join(interfaces))
|
||
if sgp22:
|
||
funcs = sgp22.get('functions') or []
|
||
tc.append('eUICC: ' + (', '.join(funcs) if funcs
|
||
else '0x' + sgp22.get('raw', '')))
|
||
if sgp32:
|
||
tc.append('eUICC SGP.32: 0x' + sgp32.get('raw', ''))
|
||
if tc:
|
||
parts.append(' \u00b7 '.join(tc))
|
||
|
||
if result.get('response_to'):
|
||
parts.append('\u2192 ' + result['response_to'])
|
||
response = result.get('response')
|
||
if response and response.get('qualifier'):
|
||
parts.append(response['qualifier'])
|
||
if result.get('response_for'):
|
||
response = result.get('response') or {}
|
||
fd = response.get('file_descriptor')
|
||
if fd:
|
||
parts.append(_fcp_summary(result['response_for'], fd, response))
|
||
elif response.get('tar'):
|
||
st = response.get('status') or {}
|
||
parts.append(f"{st.get('name') or ('0x' + st.get('code', ''))} · TAR {response['tar']}")
|
||
else:
|
||
parts.append('response for ' + result['response_for'])
|
||
if response and response.get('name'):
|
||
parts.append(response['name'])
|
||
|
||
file_dec = result.get('file')
|
||
if file_dec:
|
||
if file_dec.get('unknown'):
|
||
parts.append('unknown file')
|
||
else:
|
||
ef = file_dec.get('ef') or file_dec.get('fid') or ''
|
||
txt = _file_summary(file_dec)
|
||
if txt:
|
||
parts.append(f"{ef} {txt}".strip())
|
||
elif ef:
|
||
parts.append(ef)
|
||
|
||
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 ins in (0xB0, 0xD6): # READ/UPDATE BINARY → SFI/offset P1-P2
|
||
result['p1p2'] = _decode_binary_offset(p1, p2)
|
||
elif spec:
|
||
if 'p1p2' in spec:
|
||
offset = (p1 << 8) | p2
|
||
if spec['p1p2'].get('unused'):
|
||
result['p1p2'] = {'unused': True, 'value': offset}
|
||
else:
|
||
result['p1p2'] = {'label': spec['p1p2']['label'], 'value': offset}
|
||
else:
|
||
result['p1'] = _decode_field(spec.get('p1', {}), p1)
|
||
result['p2'] = _decode_field(spec.get('p2', {}), p2)
|
||
if ins in (0xB2, 0xDC, 0xA2):
|
||
# Record commands: P2 b8-b4 = SFI (TS 102 221 §11.1.5/6/7).
|
||
sfi = p2 >> 3
|
||
if sfi:
|
||
result['p2']['sfi'] = sfi
|
||
|
||
# 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 len(remaining) >= 2 and (extra_total >= 2 or extra_total < 0):
|
||
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)
|
||
|
||
# Length sanity: P3 (Lc/Le) vs actual data captured.
|
||
if cmd_body_len > p3:
|
||
result['length_mismatch'] = {'kind': 'excessive', 'expected': p3, 'actual': cmd_body_len}
|
||
elif cmd_body_len < p3 and not (spec and spec.get('le')):
|
||
result['length_mismatch'] = {'kind': 'truncated', 'expected': p3, 'actual': cmd_body_len}
|
||
|
||
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
|
||
response = _decode_tr_result(body) or {}
|
||
tr_qualifier = decode_tr_qualifier(body)
|
||
if tr_qualifier:
|
||
response['qualifier'] = tr_qualifier
|
||
result['response'] = response or None
|
||
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)
|
||
elif ins == 0xF2: # STATUS → response data is the FCP of the current DF/EF
|
||
fcp = _decode_fcp(body)
|
||
if fcp:
|
||
result['response'] = fcp
|
||
elif ins == 0x10: # TERMINAL PROFILE → device characteristics
|
||
tp = _decode_terminal_profile(body)
|
||
if tp:
|
||
result['tp'] = tp
|
||
elif ins == 0xAA: # TERMINAL CAPABILITY → terminal capability template
|
||
caps = _decode_terminal_capability(body)
|
||
if caps:
|
||
result['cmd'] = caps
|
||
|
||
# File data decode for READ/UPDATE using the current selection.
|
||
p1p2 = result.get('p1p2') or {}
|
||
rec_sfi = (result.get('p2') or {}).get('sfi')
|
||
if ins in (0xB0, 0xD6) and prev and 'sfi' in p1p2:
|
||
# SFI referencing → resolve the target EF, not the last selection.
|
||
sfi = p1p2['sfi']
|
||
fid = (prev.get('sfi_map') or {}).get(sfi)
|
||
if fid:
|
||
file_dec = _decode_file_data(fid, body, p1=p1)
|
||
if file_dec:
|
||
result['file'] = file_dec
|
||
else:
|
||
result['file'] = {'fid': fid, 'ef': KNOWN_FIDS.get(fid, fid.upper()),
|
||
'raw': body.hex().upper()}
|
||
else:
|
||
result['file'] = {'sfi': sfi, 'unknown': True, 'ef': f'SFI {sfi}'}
|
||
elif ins in (0xB2, 0xDC) and prev and rec_sfi:
|
||
# Record command with SFI (P2 b8-b4) → resolve the target EF.
|
||
fid = (prev.get('sfi_map') or {}).get(rec_sfi)
|
||
if fid:
|
||
file_dec = _decode_file_data(fid, body, p1=p1)
|
||
if file_dec:
|
||
result['file'] = file_dec
|
||
else:
|
||
result['file'] = {'fid': fid, 'ef': KNOWN_FIDS.get(fid, fid.upper()),
|
||
'raw': body.hex().upper()}
|
||
else:
|
||
result['file'] = {'sfi': rec_sfi, 'unknown': True, 'ef': f'SFI {rec_sfi}'}
|
||
elif ins == 0xA2 and prev and rec_sfi:
|
||
# SEARCH RECORD with SFI → attach the target file name (the body
|
||
# is a search pattern, not file data).
|
||
fid = (prev.get('sfi_map') or {}).get(rec_sfi)
|
||
if fid:
|
||
result['file'] = {'fid': fid, 'ef': KNOWN_FIDS.get(fid, fid.upper())}
|
||
else:
|
||
result['file'] = {'sfi': rec_sfi, 'unknown': True, 'ef': f'SFI {rec_sfi}'}
|
||
elif ins in (0xB0, 0xB2, 0xD6, 0xDC) and prev and prev.get('sel'):
|
||
offset = p1p2.get('offset', 0)
|
||
if ins in (0xB2, 0xDC) and not _is_record_file(prev['sel']):
|
||
result['file'] = _stale_file_note(prev['sel'])
|
||
else:
|
||
file_dec = None
|
||
if ins in (0xB2, 0xDC) or offset == 0:
|
||
file_dec = _decode_file_data(prev['sel']['fid'], body, p1=p1)
|
||
if file_dec:
|
||
result['file'] = file_dec
|
||
else:
|
||
# Known file, no content decoder (or non-zero offset) —
|
||
# still attribute the operation to the selected file.
|
||
result['file'] = {
|
||
'fid': prev['sel']['fid'],
|
||
'ef': KNOWN_FIDS.get(prev['sel']['fid'],
|
||
prev['sel']['fid'].upper()),
|
||
}
|
||
|
||
# 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:
|
||
if prev_ins in (0xB0, 0xB2) and prev.get('sel') and prev.get('file_ok'):
|
||
if prev_ins == 0xB2 and not _is_record_file(prev['sel']):
|
||
result['file'] = _stale_file_note(prev['sel'])
|
||
else:
|
||
file_dec = _decode_file_data(prev['sel']['fid'], remaining[:cmd_body_len])
|
||
if file_dec:
|
||
result['file'] = file_dec
|
||
else:
|
||
# Known file, no content decoder — attribute anyway.
|
||
fid = prev['sel']['fid']
|
||
result['file'] = {
|
||
'fid': fid,
|
||
'ef': KNOWN_FIDS.get(fid, fid.upper()),
|
||
}
|
||
elif prev_ins == 0xA2 and prev.get('sel') and prev.get('file_ok'):
|
||
if not _is_record_file(prev['sel']):
|
||
result['file'] = _stale_file_note(prev['sel'])
|
||
else:
|
||
# SEARCH RECORD → list of matching record numbers
|
||
fid = prev['sel']['fid']
|
||
result['file'] = {
|
||
'fid': fid,
|
||
'ef': KNOWN_FIDS.get(fid, fid.upper()),
|
||
'record_numbers': list(remaining[:cmd_body_len]),
|
||
}
|
||
elif prev_ins == 0xC2: # ENVELOPE → Response Packet (PoR)
|
||
data = remaining[:cmd_body_len]
|
||
# RPI mapping precedes the packet: UDHL='02', IEIa='71',
|
||
# IEIDLa='00' (TS 31.115 §4.4).
|
||
if data[:3] == b'\x02\x71\x00':
|
||
result['response'] = _decode_response_packet(
|
||
data[3:], ciphered=bool(prev.get('por_ciphered')))
|
||
else:
|
||
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',
|
||
}
|
||
|
||
# Sniff data error flags (SNIFF_DATA_FLAG_ERROR_*, simtrace_prot.h).
|
||
DATA_FLAGS = {
|
||
1 << 5: 'incomplete',
|
||
1 << 6: 'malformed',
|
||
1 << 7: 'checksum error',
|
||
1 << 8: 'overrun',
|
||
1 << 9: 'framing error',
|
||
1 << 10: 'parity error',
|
||
}
|
||
|
||
# sigrok-iso7816-stream line-event payload flags (payload[2] of RST/VCC,
|
||
# decoder v1.9.0+): bit 0 set → a big-endian uint32 CLK frequency in Hz
|
||
# follows at bytes 3..6.
|
||
LINE_EVENT_FLAG_CLK_HZ = 0x01
|
||
|
||
|
||
def _flag_names(flags, mapping):
|
||
names = []
|
||
for mask, name in sorted(mapping.items()):
|
||
if flags & mask:
|
||
names.append(name)
|
||
return names
|
||
|
||
|
||
def decode_change(flags):
|
||
"""Decode SIMtrace2 sniff_change flags into a list of human-readable names."""
|
||
bits = _flag_names(flags, CHANGE_FLAGS)
|
||
return {
|
||
'type': 'change',
|
||
'flags_hex': f'{flags:08x}',
|
||
'flags': bits if bits else ['no changes'],
|
||
}
|
||
|
||
|
||
def decode_line_event(raw_data, kind):
|
||
"""Decode a sigrok-iso7816-stream RST/VCC line event (GSMTAP sub_type
|
||
0x10/0x11): payload [direction, level, flags, clk_hz_be32?].
|
||
|
||
direction — RST: 1 = asserted (high→low), 0 = de-asserted (low→high);
|
||
VCC: 1 = power applied (low→high), 0 = removed (high→low).
|
||
level — resulting line level: 0 = low, 1 = high.
|
||
flags — bit 0 (LINE_EVENT_FLAG_CLK_HZ) set when a CLK frequency follows
|
||
(decoder v1.9.0+); bytes 3..6 are then a big-endian uint32 in Hz.
|
||
|
||
``event`` is the canonical value; ``label`` is the human-facing
|
||
summary (e.g. RST de-asserted → "RST de-asserted — ATR follows",
|
||
VCC applied → "VCC ON (power-up)").
|
||
"""
|
||
result = {'type': kind}
|
||
if raw_data and len(raw_data) >= 2:
|
||
direction, level = raw_data[0], raw_data[1]
|
||
if kind == 'rst':
|
||
if direction:
|
||
result['event'] = 'reset asserted'
|
||
result['label'] = 'RESET ASSERTED'
|
||
else:
|
||
result['event'] = 'reset de-asserted'
|
||
result['label'] = 'RST de-asserted — ATR follows'
|
||
else:
|
||
if direction:
|
||
result['event'] = 'power applied'
|
||
result['label'] = 'VCC ON (power-up)'
|
||
else:
|
||
result['event'] = 'power removed'
|
||
result['label'] = 'VCC OFF (power-down)'
|
||
result['level'] = 'high' if level else 'low'
|
||
if (len(raw_data) >= 7 and raw_data[2] & LINE_EVENT_FLAG_CLK_HZ):
|
||
result['clk_hz'] = int.from_bytes(raw_data[3:7], 'big')
|
||
result['raw'] = raw_data.hex().upper()
|
||
return result
|
||
|
||
|
||
def decode_data_flags(flags):
|
||
"""Decode SIMtrace2 sniff_data error flags into a list of names (or None)."""
|
||
if not flags:
|
||
return None
|
||
names = _flag_names(flags, DATA_FLAGS)
|
||
return names or None
|
||
|
||
|
||
def gsmtap_flag_names(flags):
|
||
"""Decode the GSMTAP header ``res`` per-packet flag bits into a list of
|
||
human-readable names (or None).
|
||
|
||
GSMTAP_FLAG_BAD_FCS is set by the sigrok-iso7816-stream decoder on a
|
||
desynced TPDU (mis-framed exchange / implausible status word).
|
||
"""
|
||
names = []
|
||
if flags & GSMTAP_FLAG_BAD_FCS:
|
||
names.append('bad FCS (desynced)')
|
||
return names or None
|
||
|
||
|
||
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 atr_rate_fields(clk_hz, decoded):
|
||
"""Derive the CLK frequency, data rate and ETU for a decoded ATR.
|
||
|
||
The Fi/Di communicated by TA1 (ISO 7816-3 §8.3) give the bit duration:
|
||
``data_rate = clk_hz × D / F`` and ``ETU = F / (clk_hz × D)``. When TA1
|
||
is absent the interface defaults (F=372, D=1) apply.
|
||
"""
|
||
f, d = 372, 1
|
||
for ifc in (decoded.get('interface') or []):
|
||
if ifc.get('name') == 'TA1' and 'f' in ifc and 'd' in ifc:
|
||
f, d = ifc['f'], ifc['d']
|
||
break
|
||
return {
|
||
'clk_hz': clk_hz,
|
||
'data_rate': int(round(clk_hz * d / f)),
|
||
'etu_us': round(f / (clk_hz * d) * 1e6, 1),
|
||
}
|
||
|
||
|
||
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, clk_hz=None):
|
||
"""Decode an Answer-To-Reset (ISO 7816-3 §8) into a structured dict.
|
||
|
||
*clk_hz* — when the sigrok-iso7816-stream decoder reported a CLK
|
||
frequency on a preceding RST event, the ATR gains ``clk_hz``,
|
||
``data_rate`` and ``etu_us`` fields derived from TA1 Fi/Di.
|
||
"""
|
||
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:
|
||
if clk_hz:
|
||
result.update(atr_rate_fields(clk_hz, result))
|
||
return result
|
||
|
||
t0 = body[0]
|
||
result['t0'] = f'{t0:02X}'
|
||
result['historical_len'] = t0 & 0x0F
|
||
|
||
interface = []
|
||
protocols = []
|
||
pps = False
|
||
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
|
||
if cur_t == 15:
|
||
pps = True
|
||
else:
|
||
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
|
||
if pps:
|
||
result['pps'] = True
|
||
|
||
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']) and not pps
|
||
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
|
||
|
||
if clk_hz:
|
||
result.update(atr_rate_fields(clk_hz, result))
|
||
|
||
return result
|
||
|
||
|
||
def _atr_summary(result):
|
||
"""Build the APDU-list description of an ATR.
|
||
|
||
Protocol(s), TA1 Fi/Di (with the F/D ratio), the T=15 class indicator
|
||
and — when a CLK rate was measured — the resulting data rate.
|
||
"""
|
||
if 't0' not in result:
|
||
return None # unparsed/unknown-convention ATR
|
||
parts = []
|
||
protocols = result.get('protocols') or ['T=0']
|
||
parts.append(', '.join(protocols))
|
||
interface = result.get('interface') or []
|
||
for ifc in interface:
|
||
if ifc.get('name') == 'TA1':
|
||
vals = []
|
||
if 'f' in ifc:
|
||
vals.append(f"F={ifc['f']}")
|
||
if 'd' in ifc:
|
||
vals.append(f"D={ifc['d']}")
|
||
if ifc.get('f_div_d') is not None:
|
||
vals.append(f"F/D={ifc['f_div_d']}")
|
||
if vals:
|
||
parts.append(', '.join(vals))
|
||
break
|
||
for ifc in interface:
|
||
if ifc.get('classes'):
|
||
parts.append('classes ' + ', '.join(ifc['classes']))
|
||
break
|
||
if result.get('data_rate'):
|
||
parts.append(f"{result['data_rate']} bit/s")
|
||
return ' \u00b7 '.join(parts)
|
||
|
||
|
||
def _pps_summary(result):
|
||
"""Build the APDU-list description of a PPS exchange (protocol + Fi/Di)."""
|
||
parts = []
|
||
if result.get('protocol'):
|
||
parts.append(result['protocol'])
|
||
fd = result.get('fi_di') or {}
|
||
vals = []
|
||
if 'f' in fd:
|
||
vals.append(f"F={fd['f']}")
|
||
if 'd' in fd:
|
||
vals.append(f"D={fd['d']}")
|
||
if fd.get('f_div_d') is not None:
|
||
vals.append(f"F/D={fd['f_div_d']}")
|
||
if vals:
|
||
parts.append(', '.join(vals))
|
||
return ' \u00b7 '.join(parts) if parts else None
|
||
|
||
|
||
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, clk_hz=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. *clk_hz* is the CLK frequency reported by a
|
||
preceding RST event (sigrok-iso7816-stream v1.9.0+), used to derive
|
||
the data rate of an ATR.
|
||
"""
|
||
if msg_type == 'tpdu' and raw_data:
|
||
result = decode_message(raw_data, prev=prev)
|
||
if result is not None:
|
||
errs = (decode_data_flags(flags) or []) + (gsmtap_flag_names(flags) or [])
|
||
if errs:
|
||
result['errors'] = errs
|
||
return result
|
||
if msg_type == 'change':
|
||
return decode_change(flags)
|
||
if msg_type in ('rst', 'vcc'):
|
||
result = decode_line_event(raw_data, msg_type)
|
||
if result.get('clk_hz'):
|
||
result['summary'] = f"CLK {result['clk_hz'] / 1e6:.3f} MHz"
|
||
errs = gsmtap_flag_names(flags)
|
||
if errs:
|
||
result['errors'] = errs
|
||
return result
|
||
if msg_type == 'fidi' and raw_data:
|
||
return decode_fidi(raw_data)
|
||
if msg_type == 'atr':
|
||
result = _decode_atr(raw_data, clk_hz=clk_hz)
|
||
if result is not None:
|
||
result['summary'] = _atr_summary(result)
|
||
errs = (decode_data_flags(flags) or []) + (gsmtap_flag_names(flags) or [])
|
||
if errs:
|
||
result['errors'] = errs
|
||
return result
|
||
if msg_type == 'pps':
|
||
result = _decode_pps(raw_data)
|
||
if result is not None:
|
||
result['summary'] = _pps_summary(result)
|
||
errs = (decode_data_flags(flags) or []) + (gsmtap_flag_names(flags) or [])
|
||
if errs:
|
||
result['errors'] = errs
|
||
return result
|
||
return None
|
||
|
||
|
||
def _decode_binary_offset(p1, p2):
|
||
"""Decode READ/UPDATE BINARY P1/P2 (TS 102 221 §11.1.3.2 Table 11.10).
|
||
|
||
b8 of P1 = 0 → offset = b7..b1 of P1 << 8 | P2.
|
||
b8 of P1 = 1 → SFI referencing: b5..b1 of P1 = SFI, P2 = offset.
|
||
"""
|
||
raw = (p1 << 8) | p2
|
||
if p1 & 0x80:
|
||
return {'value': raw, 'sfi': p1 & 0x1F, 'offset': p2}
|
||
return {'value': raw, 'offset': ((p1 & 0x7F) << 8) | p2}
|
||
|
||
|
||
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
|