Flag ciphered SCP80 fields: on-wire CNTR/PCNTR/RC are ciphertext

User-reported: the decoded CNTR (0x1830A44E41 ≈ 104 billion) cannot
be the real counter — the OTA platform shows ~200 for that card.

Root cause: per TS 102 225 Table 2 note 1, when SPI indicates
ciphering the whole block from CNTR through the secured data
(CNTR, PCNTR, RC/CC/DS, secured data) is ENCRYPTED. Only CPI, CPL,
CHI/CHL, SPI, KIc, KID and TAR travel in the clear. Our decoder
parsed the ciphertext bytes as if they were the real CNTR/PCNTR/RC
and presented them as such — misleading (the true values are only
recoverable after 3DES decryption with the card's keys).

Fix:
- _decode_secured_packet: emit 'ciphered': True when SPI1.b3 is set
  (wire hex values kept for reference); docstring documents the
  ciphered block.
- _decode_response_packet: accepts ciphered (command SPI2.b5, PoR
  ciphering) and flags the response likewise; database context now
  carries por_ciphered from the ENVELOPE so the GET RESPONSE PoR
  inherits it. Standalone SMS-SUBMIT PoRs carry no SPI — state
  unknowable, defaults to False (documented).
- Frontend: CNTR/PCNTR/RC-CC-DS labels get "(encrypted)" when the
  respective packet is ciphered (command, response, and ENVELOPE
  response renderings).

Tests: +4 TestSecuredCiphering (ciphered flagged, unciphered plain
CNTR e.g. 200, response flag, PoR-via-context). 348 total. Version
1.18.0, SW cache simtrace-analyser-v26.
This commit is contained in:
catarrh committed 2026-08-24 19:51:40 +03:00
1 parent 14445ae38f
commit 840e233baf
8 files changed
+93 -23

No files matched your search

+17 -2
View File
@@ -2293,6 +2293,11 @@ def _decode_secured_packet(body):
*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 {}
@@ -2305,6 +2310,7 @@ def _decode_secured_packet(body):
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]
@@ -2313,6 +2319,7 @@ def _decode_secured_packet(body):
result = {
'cpl': cpl,
'chl': chl,
'ciphered': ciphered,
'spi': {
'hex': spi.hex().upper(),
'counter': _OTA_COUNTER.get((spi1 >> 4) & 0x03, 'reserved'),
@@ -2349,11 +2356,17 @@ def _decode_secured_packet(body):
return result
def _decode_response_packet(body):
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 {}
@@ -2370,6 +2383,7 @@ def _decode_response_packet(body):
result = {
'rpl': rpl,
'rhl': rhl,
'ciphered': bool(ciphered),
'tar': tar.hex().upper(),
'cntr': cntr.hex().upper(),
'pcntr': pcntr,
@@ -3929,7 +3943,8 @@ def decode_message(raw_data, prev=None):
# 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:])
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: