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:
1 parent
14445ae38f
commit
840e233baf
8 files changed
+93
-23
No files matched your search
+22
-17
@@ -633,13 +633,14 @@ function responseRows(resp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (resp.rpl !== undefined) {
|
if (resp.rpl !== undefined) {
|
||||||
|
const renc = resp.ciphered ? ' (encrypted)' : '';
|
||||||
rows.push(['Response packet', 'Response Packet Length ' + resp.rpl + ' · Response Header Length ' + resp.rhl]);
|
rows.push(['Response packet', 'Response Packet Length ' + resp.rpl + ' · Response Header Length ' + resp.rhl]);
|
||||||
rows.push(['TAR', '0x' + resp.tar]);
|
rows.push(['TAR', '0x' + resp.tar]);
|
||||||
rows.push(['CNTR', '0x' + resp.cntr]);
|
rows.push(['CNTR' + renc, '0x' + resp.cntr]);
|
||||||
rows.push(['PCNTR', resp.pcntr]);
|
rows.push(['PCNTR' + renc, resp.pcntr]);
|
||||||
const st = resp.status || {};
|
const st = resp.status || {};
|
||||||
rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]);
|
rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]);
|
||||||
if (resp.rc_cc_ds) rows.push(['RC/CC/DS', resp.rc_cc_ds]);
|
if (resp.rc_cc_ds) rows.push(['RC/CC/DS' + renc, resp.rc_cc_ds]);
|
||||||
if (resp.data) rows.push(['Response data', resp.data]);
|
if (resp.data) rows.push(['Response data', resp.data]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,23 +764,27 @@ function cmdRows(cmd) {
|
|||||||
rows.push(['KIc', 'key ' + sec.kic.key + ' — ' + sec.kic.algo]);
|
rows.push(['KIc', 'key ' + sec.kic.key + ' — ' + sec.kic.algo]);
|
||||||
rows.push(['KID', 'key ' + sec.kid.key + ' — ' + sec.kid.algo]);
|
rows.push(['KID', 'key ' + sec.kid.key + ' — ' + sec.kid.algo]);
|
||||||
rows.push(['TAR', '0x' + sec.tar]);
|
rows.push(['TAR', '0x' + sec.tar]);
|
||||||
rows.push(['CNTR', '0x' + sec.cntr]);
|
// When the packet is ciphered, CNTR/PCNTR/RC-CC-DS on the wire
|
||||||
rows.push(['PCNTR', sec.pcntr]);
|
// are ciphertext (TS 102 225 Table 2 note 1) — not real values.
|
||||||
if (sec.rc_cc_ds) rows.push(['RC/CC/DS', sec.rc_cc_ds]);
|
const enc = sec.ciphered ? ' (encrypted)' : '';
|
||||||
|
rows.push(['CNTR' + enc, '0x' + sec.cntr]);
|
||||||
|
rows.push(['PCNTR' + enc, sec.pcntr]);
|
||||||
|
if (sec.rc_cc_ds) rows.push(['RC/CC/DS' + enc, sec.rc_cc_ds]);
|
||||||
rows.push(['Secured data', sec.data]);
|
rows.push(['Secured data', sec.data]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rp = t.response_packet;
|
const rp = t.response_packet;
|
||||||
if (rp && !rp.raw) {
|
if (rp && !rp.raw) {
|
||||||
rows.push(['Response packet', 'Response Packet Length ' + rp.rpl + ' · Response Header Length ' + rp.rhl]);
|
const renc = rp.ciphered ? ' (encrypted)' : '';
|
||||||
rows.push(['TAR', '0x' + rp.tar]);
|
rows.push(['Response packet', 'Response Packet Length ' + rp.rpl + ' · Response Header Length ' + rp.rhl]);
|
||||||
rows.push(['CNTR', '0x' + rp.cntr]);
|
rows.push(['TAR', '0x' + rp.tar]);
|
||||||
rows.push(['PCNTR', rp.pcntr]);
|
rows.push(['CNTR' + renc, '0x' + rp.cntr]);
|
||||||
const st = rp.status || {};
|
rows.push(['PCNTR' + renc, rp.pcntr]);
|
||||||
rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]);
|
const st = rp.status || {};
|
||||||
if (rp.rc_cc_ds) rows.push(['RC/CC/DS', rp.rc_cc_ds]);
|
rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]);
|
||||||
rows.push(['Response data', rp.data]);
|
if (rp.rc_cc_ds) rows.push(['RC/CC/DS' + renc, rp.rc_cc_ds]);
|
||||||
}
|
rows.push(['Response data', rp.data]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rows.length) return '';
|
if (!rows.length) return '';
|
||||||
@@ -1901,7 +1906,7 @@ function onFilterChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────── Versioning ────────────────────
|
// ──────────────────── Versioning ────────────────────
|
||||||
const PWA_VERSION = '1.17.1';
|
const PWA_VERSION = '1.18.0';
|
||||||
const MIN_SERVER_VERSION = '1.0.0';
|
const MIN_SERVER_VERSION = '1.0.0';
|
||||||
|
|
||||||
function parseVersion(v) {
|
function parseVersion(v) {
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simtrace-analyser-v25';
|
const CACHE = 'simtrace-analyser-v26';
|
||||||
|
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'index.html',
|
'index.html',
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "simtrace2-pysniff"
|
name = "simtrace2-pysniff"
|
||||||
version = "1.17.1"
|
version = "1.18.0"
|
||||||
description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)"
|
description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)"
|
||||||
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"}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from .gsmtap import GsmtapSender, GsmtapReceiver
|
|||||||
from .pcap import PcapWriter
|
from .pcap import PcapWriter
|
||||||
from .dump import FileDumper, format_message
|
from .dump import FileDumper, format_message
|
||||||
|
|
||||||
__version__ = '1.17.1'
|
__version__ = '1.18.0'
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'SniffSession',
|
'SniffSession',
|
||||||
'DeviceDisconnected',
|
'DeviceDisconnected',
|
||||||
|
|||||||
@@ -310,11 +310,13 @@ class Database:
|
|||||||
file_ok = True
|
file_ok = True
|
||||||
elif ins in (0xB0, 0xD6):
|
elif ins in (0xB0, 0xD6):
|
||||||
file_ok = (d.get('p1p2') or {}).get('value', 0) == 0
|
file_ok = (d.get('p1p2') or {}).get('value', 0) == 0
|
||||||
|
sec = ((d.get('cmd') or {}).get('tpdu') or {}).get('secured') or {}
|
||||||
return {
|
return {
|
||||||
'ins': ins,
|
'ins': ins,
|
||||||
'ins_name': d.get('ins_name'),
|
'ins_name': d.get('ins_name'),
|
||||||
'sw1': (d.get('sw') or {}).get('sw1'),
|
'sw1': (d.get('sw') or {}).get('sw1'),
|
||||||
'file_ok': file_ok,
|
'file_ok': file_ok,
|
||||||
|
'por_ciphered': bool((sec.get('spi') or {}).get('por_ciphered')),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _selection_after(self, d, sel):
|
def _selection_after(self, d, sel):
|
||||||
|
|||||||
@@ -2293,6 +2293,11 @@ def _decode_secured_packet(body):
|
|||||||
|
|
||||||
*body* is the TP-UD remainder after the UDH (CPI IEI 0x70). Returns a
|
*body* is the TP-UD remainder after the UDH (CPI IEI 0x70). Returns a
|
||||||
dict of the decoded header fields; ``{'raw': hex}`` when unparseable.
|
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:
|
if not body or len(body) < 16:
|
||||||
return {'raw': body.hex().upper()} if body else {}
|
return {'raw': body.hex().upper()} if body else {}
|
||||||
@@ -2305,6 +2310,7 @@ def _decode_secured_packet(body):
|
|||||||
cntr = body[10:15]
|
cntr = body[10:15]
|
||||||
pcntr = body[15]
|
pcntr = body[15]
|
||||||
spi1, spi2 = spi[0], spi[1]
|
spi1, spi2 = spi[0], spi[1]
|
||||||
|
ciphered = bool(spi1 & 0x04)
|
||||||
rc_cc_ds_kind = spi1 & 0x03
|
rc_cc_ds_kind = spi1 & 0x03
|
||||||
rc_cc_ds_len = max(0, chl - 13) # 13 = SPI+KIc+KID+TAR+CNTR+PCNTR
|
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]
|
rc_cc_ds = body[16:16 + rc_cc_ds_len]
|
||||||
@@ -2313,6 +2319,7 @@ def _decode_secured_packet(body):
|
|||||||
result = {
|
result = {
|
||||||
'cpl': cpl,
|
'cpl': cpl,
|
||||||
'chl': chl,
|
'chl': chl,
|
||||||
|
'ciphered': ciphered,
|
||||||
'spi': {
|
'spi': {
|
||||||
'hex': spi.hex().upper(),
|
'hex': spi.hex().upper(),
|
||||||
'counter': _OTA_COUNTER.get((spi1 >> 4) & 0x03, 'reserved'),
|
'counter': _OTA_COUNTER.get((spi1 >> 4) & 0x03, 'reserved'),
|
||||||
@@ -2349,11 +2356,17 @@ def _decode_secured_packet(body):
|
|||||||
return result
|
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).
|
"""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
|
*body* is the TP-UD remainder after the UDH (RPI IEI 0x71). Returns a
|
||||||
dict of the decoded header fields; ``{'raw': hex}`` when unparseable.
|
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:
|
if not body or len(body) < 13:
|
||||||
return {'raw': body.hex().upper()} if body else {}
|
return {'raw': body.hex().upper()} if body else {}
|
||||||
@@ -2370,6 +2383,7 @@ def _decode_response_packet(body):
|
|||||||
result = {
|
result = {
|
||||||
'rpl': rpl,
|
'rpl': rpl,
|
||||||
'rhl': rhl,
|
'rhl': rhl,
|
||||||
|
'ciphered': bool(ciphered),
|
||||||
'tar': tar.hex().upper(),
|
'tar': tar.hex().upper(),
|
||||||
'cntr': cntr.hex().upper(),
|
'cntr': cntr.hex().upper(),
|
||||||
'pcntr': pcntr,
|
'pcntr': pcntr,
|
||||||
@@ -3929,7 +3943,8 @@ def decode_message(raw_data, prev=None):
|
|||||||
# RPI mapping precedes the packet: UDHL='02', IEIa='71',
|
# RPI mapping precedes the packet: UDHL='02', IEIa='71',
|
||||||
# IEIDLa='00' (TS 31.115 §4.4).
|
# IEIDLa='00' (TS 31.115 §4.4).
|
||||||
if data[:3] == b'\x02\x71\x00':
|
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:
|
else:
|
||||||
response = _decode_response_for(prev_ins, remaining[:cmd_body_len])
|
response = _decode_response_for(prev_ins, remaining[:cmd_body_len])
|
||||||
if response:
|
if response:
|
||||||
|
|||||||
@@ -2080,6 +2080,54 @@ class TestSwUiccSpecific(unittest.TestCase):
|
|||||||
self.assertIsNone(self._sw('6205')['name'])
|
self.assertIsNone(self._sw('6205')['name'])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecuredCiphering(unittest.TestCase):
|
||||||
|
"""Ciphered SCP80 packets: on-wire CNTR/PCNTR/RC are ciphertext
|
||||||
|
(TS 102 225 Table 2 note 1) — flagged, not shown as real values."""
|
||||||
|
|
||||||
|
def test_ciphered_packet_flagged(self):
|
||||||
|
# stk_test1-style SMS-PP DOWNLOAD secured packet (ciphering on):
|
||||||
|
# CNTR on the wire is ciphertext; the card's real counter (~200)
|
||||||
|
# is only recoverable after 3DES decryption.
|
||||||
|
from simtrace2_pysniff.server.decode import _decode_secured_packet
|
||||||
|
sec = bytes.fromhex('003815160115 15b000011830a44e4191ad45d0ec'
|
||||||
|
'2a291315986468a539d248fcdce7952c31cd635baec'
|
||||||
|
'7e93a5d567b59f6283316c0dad8603bf3'.replace(' ', ''))
|
||||||
|
r = _decode_secured_packet(sec)
|
||||||
|
self.assertTrue(r['ciphered'])
|
||||||
|
self.assertTrue(r['spi']['ciphering'])
|
||||||
|
self.assertEqual(r['cntr'], '1830A44E41') # wire bytes kept, flagged
|
||||||
|
|
||||||
|
def test_unciphered_packet_plain_cntr(self):
|
||||||
|
# SPI1 = 0x10: counter present, NO ciphering, RC/CC/DS none →
|
||||||
|
# CNTR is genuine plaintext (e.g. 200 = 0x00000000C8).
|
||||||
|
from simtrace2_pysniff.server.decode import _decode_secured_packet
|
||||||
|
sec = bytes.fromhex('00100d10011515b0000100000000c800aabb')
|
||||||
|
r = _decode_secured_packet(sec)
|
||||||
|
self.assertFalse(r['ciphered'])
|
||||||
|
self.assertEqual(r['cntr'], '00000000C8')
|
||||||
|
self.assertNotIn('rc_cc_ds', r)
|
||||||
|
|
||||||
|
def test_response_packet_ciphered_flag(self):
|
||||||
|
from simtrace2_pysniff.server.decode import _decode_response_packet
|
||||||
|
body = bytes.fromhex('001f0a00000000000000500000ab12800101230d08a0000001510000000f9a9000')
|
||||||
|
r = _decode_response_packet(body, ciphered=True)
|
||||||
|
self.assertTrue(r['ciphered'])
|
||||||
|
r2 = _decode_response_packet(body)
|
||||||
|
self.assertFalse(r2['ciphered'])
|
||||||
|
|
||||||
|
def test_por_ciphered_via_context(self):
|
||||||
|
# GET RESPONSE after a ciphered ENVELOPE: the response packet's
|
||||||
|
# ciphered flag flows from the command's SPI2.b5 via prev context.
|
||||||
|
prev = {'ins': 0xC2, 'ins_name': 'ENVELOPE', 'sw1': '61',
|
||||||
|
'por_ciphered': True}
|
||||||
|
raw = bytes.fromhex('80c0000019' +
|
||||||
|
'0271' +
|
||||||
|
'001f0a00000000000000500000ab12800101230d08a0000001510000000f9a9000'[:0x19 * 2] +
|
||||||
|
'9000')
|
||||||
|
r = decode_message(raw, prev=prev)
|
||||||
|
self.assertTrue(r['response']['ciphered'])
|
||||||
|
|
||||||
|
|
||||||
class TestSpiBits(unittest.TestCase):
|
class TestSpiBits(unittest.TestCase):
|
||||||
"""TS 102 225 §5.1.1: SPI1 b3 = ciphering, SPI2 b5 = PoR ciphering."""
|
"""TS 102 225 §5.1.1: SPI1 b3 = ciphering, SPI2 b5 = PoR ciphering."""
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,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__, '1.17.1')
|
self.assertEqual(__version__, '1.18.0')
|
||||||
|
|
||||||
def test_status_includes_version(self):
|
def test_status_includes_version(self):
|
||||||
import os
|
import os
|
||||||
|
|||||||
Reference in new issue
Block a user