From 7fd3a3c973662b80ac4202164712ffcb1db5415d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 22 Sep 2026 07:56:21 +0300 Subject: [PATCH] fix: emit wire-shaped TPDUs in the GSMTAP stream (v3.1.1) The GSMTAP stream sent logical APDUs, so a receiver (SIMtrace Analyser, Wireshark) flagged almost every packet: case-4 commands carried their Le byte ('length_mismatch: excessive') and responses were headerless packets decoded as bogus commands ('truncated'). The ATR also arrived in the middle of the stream. - gsmtap.py: the tracer now emits wire-shaped TPDUs, matching a hardware sniffer capture: case 4 -> the command without Le plus the 61XX 'bytes available' SW (derived from the real response length), then the data as a GET RESPONSE TPDU (00C00000 + data + SW, chunked at 255); case 1/2/3 -> one packet cmd + data + SW; unparseable APDUs fall back to raw command/response packets. - No ATR/VCC/RST/PPS events at all (no line-level access over PC/SC); the ATR sending helper and the server-side plumbing were removed. - Verified by replaying the bad capture's exchanges through the new tracer: 116 packets, zero decoder warnings (previously nearly all). - tests: the per-case wire forms, chunking, fallbacks; README/AGENTS document the wire shape and its limits; version 3.1.1, sw simple-v236. --- README.md | 2 +- README_RUS.md | 2 +- frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- pysim_simple_server/__main__.py | 6 +- pysim_simple_server/gsmtap.py | 100 +++++++++++++++++++++++--------- pysim_simple_server/server.py | 26 +-------- tests/test_gsmtap.py | 86 ++++++++++++++++++--------- 9 files changed, 137 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 0416f56..4e25e9d 100644 --- a/README.md +++ b/README.md @@ -685,7 +685,7 @@ pysim-simple-server --http-port 8080 | `-d` / `--device` | Serial device path | | `--no-card-init` | Skip card init to preserve the CAT session (no file manager) | | `--apdu-trace` | Log APDU-level traces to stderr | -| `--gsmtap [HOST[:PORT]]` | Stream every APDU (and the card ATR at equip) as GSMTAP-SIM UDP packets for Wireshark / SIMtrace Analyser (`--capture gsmtap`); default target `127.0.0.1:4729`. Combines with `--apdu-trace` | +| `--gsmtap [HOST[:PORT]]` | Stream the card APDUs as GSMTAP-SIM UDP packets for Wireshark / SIMtrace Analyser (`--capture gsmtap`); default target `127.0.0.1:4729`. APDUs only (no ATR), emitted in wire shape (case-4 Le stripped, data as a GET RESPONSE TPDU, case-2/3 responses merged into the command packet). Combines with `--apdu-trace` | | `--log-requests` | Log request/response payloads to stderr | | `--sms-oa` / `--sms-sm-sc` | SMS-DELIVER originating address / SM-SC for PoR-in-submit | | `--terminal-profile` | TERMINAL PROFILE payload hex (default: 33-byte real-handset profile that advertises BIP events/commands; the live card ignores HTTP OTA without it) | diff --git a/README_RUS.md b/README_RUS.md index 40ba617..01dff16 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -668,7 +668,7 @@ pysim-simple-server --http-port 8080 | `-d` / `--device` | Путь к serial-устройству | | `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) | | `--apdu-trace` | Лог APDU-трафика в stderr | -| `--gsmtap [HOST[:PORT]]` | Поток всех APDU (и ATR карты при equip) в виде GSMTAP-SIM UDP-пакетов для Wireshark / SIMtrace Analyser (`--capture gsmtap`); цель по умолчанию `127.0.0.1:4729`. Совместимо с `--apdu-trace` | +| `--gsmtap [HOST[:PORT]]` | Поток APDU карты в виде GSMTAP-SIM UDP-пакетов для Wireshark / SIMtrace Analyser (`--capture gsmtap`); цель по умолчанию `127.0.0.1:4729`. Только APDU (без ATR), в «проводной» форме (у case-4 убирается Le, данные — как GET RESPONSE TPDU, ответы case-2/3 объединяются с командой). Совместимо с `--apdu-trace` | | `--log-requests` | Лог запросов/ответов в stderr | | `--sms-oa` / `--sms-sm-sc` | Адрес отправителя SMS-DELIVER / SM-SC для PoR-in-submit | | `--terminal-profile` | Hex TERMINAL PROFILE (по умолчанию — 33-байтовый профиль реального телефона с BIP-событиями/командами; без него живая карта не запускает HTTP OTA) | diff --git a/frontend/index.html b/frontend/index.html index 1564d0f..d083885 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1436,7 +1436,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.1.0'; +const SIMPLE_VERSION = '3.1.1'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== diff --git a/frontend/sw.js b/frontend/sw.js index fea4de0..626ffb8 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v235'; +const CACHE = 'simple-v236'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index c5d1df8..324c9be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.1.0" +version = "3.1.1" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/__main__.py b/pysim_simple_server/__main__.py index 14be27b..79dfaa2 100644 --- a/pysim_simple_server/__main__.py +++ b/pysim_simple_server/__main__.py @@ -14,7 +14,7 @@ from pySim.cards import UiccCardBase from .shell import load_pysim_app from . import fastinit from . import gsmtap -from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _netstate_read, _netstate_install, _send_gsmtap_atr, _LineFilter +from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _netstate_read, _netstate_install, _LineFilter _server_start = 0 @@ -267,10 +267,6 @@ def main(): server.card_present = card is not None server.card_session = 1 if card is not None else 0 server.iccid = iccid - server.gsmtap = gsmtap_sender - # Stream the ATR so a GSMTAP receiver (SIMtrace Analyser, Wireshark) has - # the session context before the first APDU of this session. - _send_gsmtap_atr(server) # Network state monitor: install the state read during the startup init # (right after the ICCID, before the TERMINAL PROFILE). No readable # ICCID means the card is considered unusable - give up. diff --git a/pysim_simple_server/gsmtap.py b/pysim_simple_server/gsmtap.py index d24c254..1e11fd9 100644 --- a/pysim_simple_server/gsmtap.py +++ b/pysim_simple_server/gsmtap.py @@ -1,18 +1,30 @@ # coding=utf-8 """GSMTAP-SIM UDP sender for live APDU capture. -Streams every APDU the server sends/receives as GSMTAP-SIM packets, so a +Streams the card APDUs the server sends/receives as GSMTAP-SIM packets, so a GSMTAP receiver (SIMtrace Analyser ``--capture gsmtap``, Wireshark, or simtrace2-sniff) can follow the card dialogue live. Enabled with -``--gsmtap [HOST[:PORT]]`` only (default ``127.0.0.1:4729``) - there is no -UI or API for it. +``--gsmtap [HOST[:PORT]]`` only (default ``127.0.0.1:4729``) - there is no UI +or API for it. The packet format is the one shared by libosmocore's ``gsmtap.h``, simtrace2-sniff, sigrok-iso7816-stream and the SIMtrace Analyser: a 16-byte -big-endian header (version 2, ``hdr_len`` 4, type 0x04 = SIM, sub_type, -``res`` flags) followed by the raw APDU/TPDU bytes. A response is sent as -``data + SW1SW2`` (the wire form); the receiver infers the direction from -the ISO 7816 case, exactly like a sniffer capture. +big-endian header (version 2, ``hdr_len`` 4, type 0x04 = SIM, sub_type 0x00 = +APDU, ``res`` flags) followed by the raw TPDU bytes. + +The tracer emits **wire-shaped TPDUs**, not logical APDUs, because that is +what a receiver pairs and decodes: + +* case 4 (command with data + Le): the command without its Le byte plus the + "61XX bytes available" status word, then the response data as a GET + RESPONSE TPDU (``00C00000 + data + SW``); +* case 2 / 1 / 3: one packet with the command, the response data and the SW; +* unparseable APDUs fall back to a raw command packet and a raw response one. + +The APDU data is always the real one - only the T=0 framing (which the PC/SC +reader hides) is reconstructed, so the ``61XX`` length is the final response +length and internal retries are not visible. No ATR/VCC/RST/PPS events are +sent (the server has no line-level access). Sending is fire-and-forget on a non-blocking socket: a missing listener must never affect card I/O. @@ -22,13 +34,14 @@ import socket import struct from pySim.transport import ApduTracer +from pySim.utils import h2b, parse_command_apdu GSMTAP_VERSION = 0x02 GSMTAP_HDR_LEN = 4 # in 32-bit words (16 bytes) GSMTAP_TYPE_SIM = 0x04 GSMTAP_SIM_APDU = 0x00 -GSMTAP_SIM_ATR = 0x01 +GSMTAP_SIM_ATR = 0x01 # not sent; kept for reference GSMTAP_UDP_PORT = 4729 DEFAULT_TARGET = '127.0.0.1:%d' % GSMTAP_UDP_PORT @@ -36,6 +49,8 @@ DEFAULT_TARGET = '127.0.0.1:%d' % GSMTAP_UDP_PORT _HDR_FMT = '!BBBBHBBIBBBB' # 16 bytes, big-endian _HDR_SIZE = struct.calcsize(_HDR_FMT) +_GET_RESPONSE_CHUNK = 255 # max GET RESPONSE payload per TPDU + def build_packet(sub_type, data, flags=0, slot_nr=0): """Build a complete GSMTAP-SIM packet (header + payload) as bytes.""" @@ -91,9 +106,6 @@ class GsmtapSender: def send_apdu(self, data, slot_nr=0): self.send(GSMTAP_SIM_APDU, data, slot_nr=slot_nr) - def send_atr(self, data, slot_nr=0): - self.send(GSMTAP_SIM_ATR, data, slot_nr=slot_nr) - def close(self): try: self._sock.close() @@ -101,12 +113,20 @@ class GsmtapSender: pass -class GsmtapApduTracer(ApduTracer): - """pySim APDU tracer that streams every APDU as a GSMTAP-SIM packet. +def _apdu_case(cmd): + """ISO 7816-3 case of a command APDU, or None when unparseable.""" + try: + case, _lc, _le, _data = parse_command_apdu(h2b(cmd)) + return case + except Exception: + return None - Commands are sent as-is; a response is sent as ``data + SW1SW2`` so the - receiver sees the same wire TPDU a hardware sniffer would capture. - Malformed hex never raises into pySim's transport. + +class GsmtapApduTracer(ApduTracer): + """pySim APDU tracer streaming wire-shaped TPDUs as GSMTAP-SIM packets. + + See the module docstring for the emitted forms. Malformed hex never + raises into pySim's transport. """ def __init__(self, sender): @@ -114,21 +134,47 @@ class GsmtapApduTracer(ApduTracer): self.sender = sender def trace_command(self, cmd): - if not cmd: - return - try: - self.sender.send_apdu(bytes.fromhex(cmd)) - except (ValueError, TypeError): - pass + """Nothing is sent here: the wire packets need the response.""" def trace_response(self, cmd, sw, resp): - data = (resp or '') + (sw or '') - if not data: - return try: - self.sender.send_apdu(bytes.fromhex(data)) + raw = bytes.fromhex(cmd or '') + resp_bytes = bytes.fromhex(resp or '') + sw_bytes = bytes.fromhex(sw or '') except (ValueError, TypeError): - pass + return + + case = _apdu_case(cmd) + if case is None or not raw: + # Unparseable APDU: keep the raw command / raw response form. + if raw: + self.sender.send_apdu(raw) + if resp_bytes or sw_bytes: + self.sender.send_apdu(resp_bytes + sw_bytes) + return + + if case == 4 and len(raw) > 1: + # The wire TPDU carries no Le byte for case 4. + body = raw[:-1] + if not resp_bytes: + self.sender.send_apdu(body + sw_bytes) + return + size = len(resp_bytes) + # T=0: "61XX bytes available", then the data via GET RESPONSE. + self.sender.send_apdu(body + bytes([0x61, min(size, 0xFF)])) + offset = 0 + while offset < size: + chunk = min(size - offset, _GET_RESPONSE_CHUNK) + last = offset + chunk >= size + self.sender.send_apdu( + bytes([0x00, 0xC0, 0x00, 0x00, chunk]) + + resp_bytes[offset:offset + chunk] + + (sw_bytes if last else b'')) + offset += chunk + return + + # case 1/2/3: the command TPDU and its response share one packet. + self.sender.send_apdu(raw + resp_bytes + sw_bytes) class FanoutApduTracer(ApduTracer): diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index ecab3b7..d98f9d1 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -28,7 +28,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.1.0' +VERSION = '3.1.1' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -2532,34 +2532,10 @@ def _handle_card_disconnect(): _reset_proactive_log() -def _send_gsmtap_atr(server): - """Stream the card's ATR as a GSMTAP-SIM packet (--gsmtap only). - - Sent at every equip so a GSMTAP receiver (SIMtrace Analyser, Wireshark) - has the session context before the first APDU of the new session.""" - sender = getattr(server, 'gsmtap', None) - if sender is None: - return - rs = getattr(server, 'rs', None) - atr = None - if rs is not None: - try: - atr = (rs.identity or {}).get('ATR') - except Exception: - atr = None - if not atr: - return - try: - sender.send_atr(bytes.fromhex(atr)) - except (ValueError, TypeError): - pass - - def _apply_equipped_card(server): """Common post-equip state refresh + TERMINAL PROFILE, shared by the /api/command equip branch and the auto-equip worker.""" global _CARD_CONNECTED - _send_gsmtap_atr(server) server.stk_pending = None server.menu_active = False _cancel_menu_timeout() diff --git a/tests/test_gsmtap.py b/tests/test_gsmtap.py index 810d0d9..5a6da51 100644 --- a/tests/test_gsmtap.py +++ b/tests/test_gsmtap.py @@ -7,6 +7,9 @@ import unittest from pysim_simple_server import gsmtap +FCI = ('62278202782183023F00A50A8001718302C0F28701018A01058B032F0601' + 'C60990014083010183010A') + class PacketTests(unittest.TestCase): def test_header_and_payload(self): @@ -23,12 +26,6 @@ class PacketTests(unittest.TestCase): self.assertEqual((antenna, slot, res), (0, 0, 0)) self.assertEqual(pkt[16:], apdu) - def test_atr_subtype_and_slot(self): - pkt = gsmtap.build_packet(gsmtap.GSMTAP_SIM_ATR, b'\x3b\x00', slot_nr=2) - self.assertEqual(pkt[12], gsmtap.GSMTAP_SIM_ATR) # sub_type - self.assertEqual(pkt[14], 2) # sub_slot - self.assertEqual(pkt[16:], b'\x3b\x00') - def test_parse_target_defaults_and_overrides(self): self.assertEqual(gsmtap.parse_target(None), ('127.0.0.1', 4729)) self.assertEqual(gsmtap.parse_target(''), ('127.0.0.1', 4729)) @@ -39,7 +36,7 @@ class PacketTests(unittest.TestCase): class SenderTests(unittest.TestCase): - def test_send_apdu_and_atr_reach_a_udp_listener(self): + def test_send_apdu_reaches_a_udp_listener(self): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(('127.0.0.1', 0)) sock.settimeout(2.0) @@ -47,16 +44,12 @@ class SenderTests(unittest.TestCase): sender = gsmtap.GsmtapSender(host, port) try: sender.send_apdu(bytes.fromhex('00A40004023F00')) - sender.send_atr(bytes.fromhex('3B00')) - first, _addr = sock.recvfrom(2048) - second, _addr = sock.recvfrom(2048) + data, _addr = sock.recvfrom(2048) finally: sender.close() sock.close() - self.assertEqual(first[12], gsmtap.GSMTAP_SIM_APDU) # sub_type - self.assertEqual(first[16:], bytes.fromhex('00A40004023F00')) - self.assertEqual(second[12], gsmtap.GSMTAP_SIM_ATR) - self.assertEqual(second[16:], bytes.fromhex('3B00')) + self.assertEqual(data[12], gsmtap.GSMTAP_SIM_APDU) # sub_type + self.assertEqual(data[16:], bytes.fromhex('00A40004023F00')) def test_send_never_raises_without_a_listener(self): sender = gsmtap.GsmtapSender('127.0.0.1', 1) # nothing listening @@ -75,26 +68,61 @@ class Recorder: class TracerTests(unittest.TestCase): - def test_command_and_response_are_wire_shaped(self): + def trace(self, cmd, sw='9000', resp=''): rec = Recorder() - tracer = gsmtap.GsmtapApduTracer(rec) - tracer.trace_command('00A40004023F00') - tracer.trace_response('00A40004023F00', '9000', '622982027821') - self.assertEqual(rec.apdus, [ - bytes.fromhex('00A40004023F00'), - bytes.fromhex('6229820278219000'), # data + SW1SW2 + gsmtap.GsmtapApduTracer(rec).trace_response(cmd, sw, resp) + return rec.apdus + + def test_case4_data_becomes_command_plus_get_response(self): + # A case-4 command goes on the wire without its Le byte and the data + # follows via GET RESPONSE (T=0). + self.assertEqual(self.trace('00A40004023F0000', resp=FCI), [ + bytes.fromhex('00A40004023F00') + bytes([0x61, len(FCI) // 2]), + bytes.fromhex('00C00000') + bytes([len(FCI) // 2]) + + bytes.fromhex(FCI) + bytes.fromhex('9000'), ]) - def test_response_without_data_is_the_sw(self): - rec = Recorder() - gsmtap.GsmtapApduTracer(rec).trace_response('00B000000A', '6A82', '') - self.assertEqual(rec.apdus, [bytes.fromhex('6A82')]) + def test_case4_error_is_command_plus_sw(self): + self.assertEqual( + self.trace('00A4040410A0000005591010FFFFFFFF890000010000', sw='6A82'), + [bytes.fromhex('00A4040410A0000005591010FFFFFFFF8900000100' + '6A82')]) - def test_malformed_hex_never_raises(self): + def test_case2_merges_the_response_into_the_command_packet(self): + self.assertEqual(self.trace('00B000000A', resp='980711090000640070F2'), + [bytes.fromhex('00B000000A980711090000640070F29000')]) + + def test_case2_error_keeps_the_status_word(self): + self.assertEqual(self.trace('80F2000C00', sw='6A82'), + [bytes.fromhex('80F2000C006A82')]) + + def test_case3_merges_the_status_word(self): + tp = '8010000022' + 'FF' * 34 + self.assertEqual(self.trace(tp, sw='9130'), + [bytes.fromhex(tp + '9130')]) + + def test_long_case4_response_is_chunked(self): + data = 'AA' * 300 + packets = self.trace('00A40004023F0000', resp=data) + self.assertEqual(len(packets), 3) # command + 2 GET RESPONSEs + self.assertEqual(packets[0], + bytes.fromhex('00A40004023F00') + bytes([0x61, 255])) + self.assertEqual(packets[1][:5], bytes([0x00, 0xC0, 0x00, 0x00, 255])) + self.assertEqual(packets[1][5:], bytes.fromhex(data[:255 * 2])) + self.assertEqual(packets[2][:5], bytes([0x00, 0xC0, 0x00, 0x00, 45])) + self.assertEqual(packets[2][5:], + bytes.fromhex(data[255 * 2:]) + bytes.fromhex('9000')) + + def test_unparseable_apdu_falls_back_to_raw_packets(self): + self.assertEqual(self.trace('00A4', resp='AA'), + [bytes.fromhex('00A4'), bytes.fromhex('AA9000')]) + + def test_malformed_hex_sends_nothing(self): + self.assertEqual(self.trace('zz', resp='AA'), []) + self.assertEqual(self.trace('', resp=''), []) + + def test_trace_command_sends_nothing(self): rec = Recorder() - tracer = gsmtap.GsmtapApduTracer(rec) - tracer.trace_command('not-hex') - tracer.trace_response('00A4', None, None) + gsmtap.GsmtapApduTracer(rec).trace_command('00A40004023F0000') self.assertEqual(rec.apdus, []) def test_fanout_forwards_every_callback(self):