Decode STATUS FCP, REFRESH mode, and length mismatches

This commit is contained in:
catarrh committed 2026-08-17 20:06:28 +03:00
1 parent 0ae75bfdb2
commit f6ce7dfe3e
3 files changed
+133 -3

No files matched your search

+9 -1
View File
@@ -610,6 +610,9 @@ function cmdRows(cmd) {
}
// Proactive command fields (FETCH)
if (cmd.refresh_mode) rows.push(['Refresh mode', cmd.refresh_mode]);
if (cmd.file_list) rows.push(['File list', cmd.file_list.join(', ')]);
if (cmd.aid) rows.push(['AID', cmd.aid]);
if (cmd.title) rows.push(['Title', cmd.title]);
if (cmd.text !== undefined) rows.push(['Text', cmd.text]);
if (cmd.items) {
@@ -900,6 +903,11 @@ function showDetail(msg, entry) {
? `<tr><td class="label">Responding to</td><td class="name">${decoded.response_to}</td></tr>`
: '';
const lm = decoded.length_mismatch;
const lmNote = lm
? ` <span style="color:var(--error)">— ${lm.kind} (${lm.actual}/${lm.expected} bytes)</span>`
: '';
d.innerHTML = `<div class="detail-header">
<strong>${decoded.cat_command || decoded.ins_name}</strong>
<span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</span>
@@ -911,7 +919,7 @@ function showDetail(msg, entry) {
<tr><td class="label">INS</td><td class="name">0x${(decoded.ins_hex || '').toUpperCase()} — ${decoded.ins_name}</td></tr>
${catRow}${respRow}${trRow}
${p1p2}${p1}${p2}
<tr><td class="label">P3</td><td class="value">0x${hx(decoded.p3, 2)} — ${decoded.p3} bytes</td></tr>
<tr><td class="label">P3</td><td class="value">0x${hx(decoded.p3, 2)} — ${decoded.p3} bytes${lmNote}</td></tr>
${body}
${cmdRows(decoded.cmd)}
${responseRows(decoded.response)}
+55 -2
View File
@@ -67,8 +67,8 @@ def _sw_name(sw1, sw2):
_SW_PATTERNS = {
(0x63,): lambda s1, s2: ('PIN verification failed, %d retries remaining' % (s2 & 0x0f)
if s2 & 0xc0 == 0xc0 else None),
(0x62, 0x00): lambda s1, s2: 'Response: %d more bytes available' % s2,
(0x61,): lambda s1, s2: 'Response: %d bytes available (use GET RESPONSE)' % s2,
(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,
}
@@ -471,11 +471,13 @@ APDU_SPEC = {
},
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',
@@ -492,6 +494,7 @@ APDU_SPEC = {
},
0xB1: {
'name': 'READ RECORD (B1)',
'le': True,
'p1': {'fmt': 'uint8', 'label': 'Record number'},
'p2': {
'label': 'Mode',
@@ -580,6 +583,7 @@ APDU_SPEC = {
},
0x84: {
'name': 'GET CHALLENGE',
'le': True,
'p1p2': {'unused': True},
'body': None,
},
@@ -591,6 +595,7 @@ APDU_SPEC = {
},
0xC0: {
'name': 'GET RESPONSE',
'le': True,
'p1p2': {'unused': True},
'body': None,
},
@@ -601,6 +606,7 @@ APDU_SPEC = {
},
0x12: {
'name': 'FETCH',
'le': True,
'p1p2': {'unused': True},
'body': None,
},
@@ -628,6 +634,7 @@ APDU_SPEC = {
},
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'},
@@ -678,6 +685,7 @@ APDU_SPEC = {
},
0xCB: {
'name': 'RETRIEVE DATA',
'le': True,
'p1': {0x00: 'No indication'},
'p2': {'label': 'Mode', 'bits': {
0x80: 'First block',
@@ -706,6 +714,7 @@ APDU_SPEC = {
},
0xCA: {
'name': 'GET DATA',
'le': True,
'p1p2': {'fmt': 'uint16be', 'label': 'Tag'},
'body': {'label': 'TLV data'},
},
@@ -772,6 +781,21 @@ CAT_COMMAND_TYPES = {
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',
@@ -1978,8 +2002,10 @@ _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_AID = 0x2F
def _decode_proactive(body):
@@ -1999,12 +2025,17 @@ def _decode_proactive(body):
return None
cmd_type = None
qualifier = None
result = {}
items = []
file_list = []
aid = None
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 and value:
@@ -2021,10 +2052,20 @@ def _decode_proactive(body):
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_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()
if cmd_type is None:
return None
result['type'] = CAT_COMMAND_TYPES.get(cmd_type, f'0x{cmd_type:02X}')
if cmd_type == 0x01 and qualifier is not None: # REFRESH
result['refresh_mode'] = REFRESH_MODES.get(qualifier, f'0x{qualifier:02X}')
if file_list:
result['file_list'] = file_list
if aid:
result['aid'] = aid
if items:
result['items'] = items
return result
@@ -2688,6 +2729,8 @@ def _build_summary(result):
if cmd:
if cmd.get('context'):
parts.append(cmd['context'])
if cmd.get('refresh_mode'):
parts.append(cmd['refresh_mode'])
if cmd.get('title'):
parts.append(cmd['title'])
items = cmd.get('items')
@@ -2784,7 +2827,7 @@ def decode_message(raw_data, prev=None):
extra_total = len(remaining) - p3
sw_bytes = None
if extra_total >= 2:
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,
@@ -2796,6 +2839,12 @@ def decode_message(raw_data, prev=None):
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}
@@ -2826,6 +2875,10 @@ def decode_message(raw_data, prev=None):
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
# File data decode for READ/UPDATE using the current selection.
if ins in (0xB0, 0xB2, 0xD6, 0xDC) and prev and prev.get('sel'):
+69
View File
@@ -1180,5 +1180,74 @@ class TestP1P2(unittest.TestCase):
self.assertEqual(r['p1']['name'], 'Path from current DF')
class TestSwWrongLength(unittest.TestCase):
def test_6c(self):
from simtrace2_pysniff.server.decode import decode_sw
self.assertEqual(decode_sw(bytes.fromhex('6c38'))['name'],
'Wrong length (Le): correct length is 0x38 (56 bytes)')
def test_62_unknown_is_unnamed(self):
from simtrace2_pysniff.server.decode import decode_sw
self.assertIsNone(decode_sw(bytes.fromhex('6205'))['name'])
class TestStatusFcp(unittest.TestCase):
def test_status_body_is_fcp(self):
raw = bytes.fromhex(
'80f2000038'
'62368202782183027ff08410a0000000871002ff45ff0189020111008a01058b032f0608c60c90016083010183018183010a81040000039f'
'9000')
r = decode_message(raw)
self.assertEqual(r['ins_name'], 'STATUS')
self.assertEqual(r['sw']['sw1'], '90')
resp = r['response']
self.assertEqual(resp['template'], 'FCP')
self.assertEqual(resp['file_id'], '7FF0')
self.assertEqual(resp['df_name'], 'A0000000871002FF45FF018902011100')
self.assertEqual(resp['file_descriptor']['file_type'], 'DF or ADF')
self.assertEqual(resp['life_cycle'], 'operational state (activated)')
class TestLengthMismatch(unittest.TestCase):
def test_lc_truncated(self):
# ENVELOPE with Lc=21, body missing (only SW captured).
r = decode_message(bytes.fromhex('80c20000159000'))
self.assertEqual(r['sw']['sw1'], '90')
self.assertEqual(r['sw']['sw2'], '00')
self.assertNotIn('body', r)
self.assertEqual(r['length_mismatch'],
{'kind': 'truncated', 'expected': 21, 'actual': 0})
def test_lc_excessive(self):
# ENVELOPE with Lc=3 but 4 bytes of body + SW.
r = decode_message(bytes.fromhex('80c2000003d10102039000'))
self.assertEqual(r['length_mismatch'],
{'kind': 'excessive', 'expected': 3, 'actual': 4})
def test_select_well_formed(self):
r = decode_message(bytes.fromhex('a0a40000023f009000'))
self.assertNotIn('length_mismatch', r)
def test_le_short_response_not_flagged(self):
# READ BINARY (Le) returning an error SW with no data is not "truncated".
r = decode_message(bytes.fromhex('00b08f00066a82'))
self.assertEqual(r['sw']['sw1'], '6a')
self.assertNotIn('length_mismatch', r)
class TestRefresh(unittest.TestCase):
def test_uicc_reset(self):
r = decode_message(bytes.fromhex('801200000bd0098103010104820281829000'))
self.assertEqual(r['cat_command'], 'REFRESH')
self.assertEqual(r['cmd']['refresh_mode'], 'UICC Reset')
self.assertNotIn('file_list', r['cmd'])
self.assertNotIn('aid', r['cmd'])
def test_file_change_notification(self):
r = decode_message(bytes.fromhex('8012000011d00f81030101018202818292046f076f209000'))
self.assertEqual(r['cmd']['refresh_mode'], 'File Change Notification')
self.assertEqual(r['cmd']['file_list'], ['6F07', '6F20'])
if __name__ == '__main__':
unittest.main()