TERMINAL CAPABILITY: decode the A9 template (TS 102 221 §11.1.19)

- 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)
This commit is contained in:
catarrh committed 2026-09-20 00:11:41 +03:00
1 parent 38650b4797
commit 561801a983
9 files changed
+210 -6

No files matched your search

+4
View File
@@ -39,6 +39,10 @@ GSMTAP into this analyser.
proactive commands with decoded qualifiers, TERMINAL RESPONSE, ENVELOPE proactive commands with decoded qualifiers, TERMINAL RESPONSE, ENVELOPE
(Menu Selection, Call Control, SMS-PP download…); tolerates known (Menu Selection, Call Control, SMS-PP download…); tolerates known
non-compliant legacy-card quirks and preserves unrecognized TLVs non-compliant legacy-card quirks and preserves unrecognized TLVs
- TERMINAL CAPABILITY (TS 102 221 §11.1.19) — terminal power supply
(voltage class, maximum current, clock), extended logical channels,
additional interfaces (UICC-CLF) and eUICC device capabilities
(SGP.22/SGP.32)
- SMS TPDU — SMS-DELIVER/SUBMIT, SCTS timestamps, UDH information elements - SMS TPDU — SMS-DELIVER/SUBMIT, SCTS timestamps, UDH information elements
- SCP80 OTA secured packets — SPI bits (ciphering, PoR requirement/mode, - SCP80 OTA secured packets — SPI bits (ciphering, PoR requirement/mode,
RC/CC/DS, counter), KIc/KID algorithm + key set, TAR, CNTR/PCNTR; RC/CC/DS, counter), KIc/KID algorithm + key set, TAR, CNTR/PCNTR;
+4
View File
@@ -40,6 +40,10 @@ SIM/UICC из UDP-потока GSMTAP (или импортирует файлы
proactive-команды с декодированными квалификаторами, TERMINAL RESPONSE, proactive-команды с декодированными квалификаторами, TERMINAL RESPONSE,
ENVELOPE (Menu Selection, Call Control, SMS-PP download…); учитываются ENVELOPE (Menu Selection, Call Control, SMS-PP download…); учитываются
известные несоответствия старых карт, нераспознанные TLV сохраняются известные несоответствия старых карт, нераспознанные TLV сохраняются
- TERMINAL CAPABILITY (TS 102 221 §11.1.19) — питание терминала (класс
напряжения, максимальный ток, тактовая частота), расширенные логические
каналы, дополнительные интерфейсы (UICC-CLF) и возможности eUICC
(SGP.22/SGP.32)
- SMS TPDU — SMS-DELIVER/SUBMIT, метки времени SCTS, информационные - SMS TPDU — SMS-DELIVER/SUBMIT, метки времени SCTS, информационные
элементы UDH элементы UDH
- SCP80 OTA secured packets — биты SPI (шифрование, требование/режим PoR, - SCP80 OTA secured packets — биты SPI (шифрование, требование/режим PoR,
+26 -1
View File
@@ -1300,6 +1300,31 @@ function cmdRows(cmd) {
rows.push(['Encapsulated', cmd.encapsulated.type]); rows.push(['Encapsulated', cmd.encapsulated.type]);
} }
if (cmd.text_note) rows.push(['Text note', cmd.text_note]); if (cmd.text_note) rows.push(['Text note', cmd.text_note]);
// TERMINAL CAPABILITY (TS 102 221 §11.1.19)
if (cmd.power_supply) {
const ps = cmd.power_supply;
const bits = [];
bits.push(ps.classes && ps.classes.length
? 'class ' + ps.classes.join('+')
: 'class 0x' + hx(ps.class_raw || 0, 2));
if (ps.max_current_ma !== undefined) bits.push(ps.max_current_ma + ' mA');
if (ps.clock_mhz !== undefined && ps.clock_mhz !== null) bits.push(ps.clock_mhz + ' MHz clock');
else if (ps.clock_raw !== undefined) bits.push('clock not indicated');
rows.push(['Power supply', bits.join(', ')]);
}
if (cmd.extended_channels) rows.push(['Logical channels', 'extended support']);
if (cmd.extended_channels_raw) rows.push(['Logical channels raw', cmd.extended_channels_raw]);
if (cmd.interfaces) rows.push(['Additional interfaces',
cmd.interfaces.length ? cmd.interfaces.join(', ') : 'none']);
if (cmd.interfaces_raw) rows.push(['Interfaces raw', cmd.interfaces_raw]);
if (cmd.euicc_sgp22) {
const e = cmd.euicc_sgp22;
const names = (e.functions || []).join(', ');
rows.push(['eUICC (SGP.22)', names ? names + ' \u00b7 0x' + e.raw : '0x' + e.raw]);
}
if (cmd.euicc_sgp32) rows.push(['eUICC (SGP.32)', '0x' + (cmd.euicc_sgp32.raw || '')]);
if (cmd.raw_tlv) { if (cmd.raw_tlv) {
for (const t of cmd.raw_tlv) rows.push(['TLV 0x' + t.tag, t.value || '(empty)']); for (const t of cmd.raw_tlv) rows.push(['TLV 0x' + t.tag, t.value || '(empty)']);
} }
@@ -2681,7 +2706,7 @@ function onFilterChange() {
} }
// ──────────────────── Versioning ──────────────────── // ──────────────────── Versioning ────────────────────
const PWA_VERSION = '2.0.0'; const PWA_VERSION = '2.1.0';
const MIN_SERVER_VERSION = '1.0.0'; const MIN_SERVER_VERSION = '1.0.0';
function parseVersion(v) { function parseVersion(v) {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simtrace-analyser-v54'; const CACHE = 'simtrace-analyser-v55';
const PRECACHE = [ const PRECACHE = [
'index.html', 'index.html',
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project] [project]
name = "simtrace-analyser" name = "simtrace-analyser"
version = "2.0.0" version = "2.1.0"
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)" description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
readme = {file = "README.md", content-type = "text/markdown"} readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "GPL-2.0-or-later"} license = {text = "GPL-2.0-or-later"}
+113 -1
View File
@@ -834,7 +834,7 @@ APDU_SPEC = {
0xAA: { 0xAA: {
'name': 'TERMINAL CAPABILITY', 'name': 'TERMINAL CAPABILITY',
'p1p2': {'unused': True}, 'p1p2': {'unused': True},
'body': {'label': 'TLV data'}, 'body': {'label': 'Terminal capability template (A9)'},
}, },
0x73: { 0x73: {
'name': 'MANAGE SECURE CHANNEL', 'name': 'MANAGE SECURE CHANNEL',
@@ -2861,6 +2861,86 @@ def _decode_terminal_profile(data):
return result 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 ──────────────────── # ──────────────────── Decode entry point ────────────────────
# ──────────────────── File data decoders (READ/UPDATE) ──────────────────── # ──────────────────── File data decoders (READ/UPDATE) ────────────────────
@@ -3906,6 +3986,34 @@ def _build_summary(result):
s += f" \u00ab{tpdu['text']}\u00bb" s += f" \u00ab{tpdu['text']}\u00bb"
parts.append(s) 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'): if result.get('response_to'):
parts.append('\u2192 ' + result['response_to']) parts.append('\u2192 ' + result['response_to'])
response = result.get('response') response = result.get('response')
@@ -4052,6 +4160,10 @@ def decode_message(raw_data, prev=None):
tp = _decode_terminal_profile(body) tp = _decode_terminal_profile(body)
if tp: if tp:
result['tp'] = 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. # File data decode for READ/UPDATE using the current selection.
p1p2 = result.get('p1p2') or {} p1p2 = result.get('p1p2') or {}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the SIMtrace Analyser version.""" """Single source of truth for the SIMtrace Analyser version."""
__version__ = '2.0.0' __version__ = '2.1.0'
+59
View File
@@ -1079,6 +1079,65 @@ class TestTerminalProfile(unittest.TestCase):
self.assertEqual(tp['summary'], 'handset \u00b7 BIP') self.assertEqual(tp['summary'], 'handset \u00b7 BIP')
class TestTerminalCapability(unittest.TestCase):
"""TERMINAL CAPABILITY (TS 102 221 §11.1.19) — real samples captured
from the local trace sessions (49/55/58/61/206/208)."""
def _dec(self, body_hex):
raw = '80AA0000%02x%s9000' % (len(body_hex) // 2, body_hex)
return decode_message(bytes.fromhex(raw))
def test_power_supply(self):
# A9 0A 80 03 04 3C FF 81 00 82 01 00 — class C, 60 mA, clock n/a.
r = self._dec('A90A8003043CFF8100820100')
self.assertEqual(r['body']['label'], 'Terminal capability template (A9)')
ps = r['cmd']['power_supply']
self.assertEqual(ps['classes'], ['C'])
self.assertEqual(ps['class_raw'], 0x04)
self.assertEqual(ps['max_current_ma'], 60)
self.assertIsNone(ps['clock_mhz'])
self.assertTrue(r['cmd']['extended_channels'])
self.assertEqual(r['cmd']['interfaces'], [])
self.assertEqual(r['summary'],
'Class C \u00b7 60 mA \u00b7 CLK n/a \u00b7 extended channels')
def test_interfaces_and_euicc(self):
# A9 08 81 00 82 01 01 83 01 87 — UICC-CLF + SGP.22 LPA functions
# (b8 is an RFU bit and must not affect the function list).
r = self._dec('A9088100820101830187')
cmd = r['cmd']
self.assertNotIn('power_supply', cmd)
self.assertEqual(cmd['interfaces'], ['UICC-CLF'])
self.assertEqual(cmd['euicc_sgp22']['raw'], '87')
self.assertEqual(cmd['euicc_sgp22']['functions'], ['LUId', 'LPDd', 'LDSd'])
self.assertEqual(r['summary'],
'extended channels \u00b7 UICC-CLF \u00b7 '
'eUICC: LUId, LPDd, LDSd')
def test_interfaces_only(self):
# A9 05 81 00 82 01 01 — phones that omit the power supply TLV.
r = self._dec('A9058100820101')
self.assertNotIn('power_supply', r['cmd'])
self.assertEqual(r['summary'], 'extended channels \u00b7 UICC-CLF')
def test_extended_channels_nonzero_length(self):
# A UICC shall interpret any length as zero (TS 102 221 §11.1.19.2.2).
r = self._dec('A906810100820101')
self.assertTrue(r['cmd']['extended_channels'])
self.assertEqual(r['cmd']['extended_channels_raw'], '00')
def test_private_tlv_preserved(self):
# Unknown/private TLV ('DD') inside the A9 template must survive.
r = self._dec('A9088100820101DD017F')
self.assertEqual(r['cmd']['raw_tlv'], [{'tag': 'DD', 'value': '7F'}])
self.assertEqual(r['summary'], 'extended channels \u00b7 UICC-CLF')
def test_no_a9_template(self):
# Command data without the A9 template stays undecoded (no crash).
r = self._dec('8003043CFF')
self.assertNotIn('cmd', r)
class TestSummary(unittest.TestCase): class TestSummary(unittest.TestCase):
def test_select_path_from_mf(self): def test_select_path_from_mf(self):
r = decode_message(bytes.fromhex('a0a40804022fe29000')) r = decode_message(bytes.fromhex('a0a40804022fe29000'))
+1 -1
View File
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
class TestVersion(unittest.TestCase): class TestVersion(unittest.TestCase):
def test_package_version(self): def test_package_version(self):
from simtrace2_pysniff import __version__ from simtrace2_pysniff import __version__
self.assertEqual(__version__, '2.0.0') self.assertEqual(__version__, '2.1.0')
def test_status_includes_version(self): def test_status_includes_version(self):
import os import os