Files
pysim/tests/unittests/test_bip_relay.py
T
Eric Wild d671cee649 bip: refuse OPEN CHANNEL with a TERMINAL RESPONSE
handle_OpenChannel() raises where TS 102 223 demands a TERMINAL RESPONSE.
Raising takes the whole proactive session down with it.

All four are a BIP errors and differ only in the cause byte of 8.12.11.

Change-Id: If8f01e6acb9cb3f7af952a0760fc093db81be53a
2026-09-23 18:01:16 +02:00

352 lines
16 KiB
Python

#!/usr/bin/env python3
# (C) 2026 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de>
#
# Author: Eric Wild
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import socket
import threading
import time
import unittest
from osmocom.utils import b2h, h2b
from pySim.sms import SMS_SUBMIT, AddressField
from pySim.cat import (ProactiveCommand, CommandDetails, DeviceIdentities,
BearerDescription, BufferSize, UiccTransportLevel,
OtherAddress, ChannelData, ChannelDataLength, ChannelStatus,
Result)
from pySim.bip import Proact
class _EchoServer:
"""behold, my tiny threaded TCP echo server listening on 127.0.0.1:<port>"""
def __init__(self):
self._srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._srv.bind(('127.0.0.1', 0))
self._srv.listen(1)
self.port = self._srv.getsockname()[1]
self.accepted = threading.Event()
self._conns = []
self._stop = False
threading.Thread(target=self._run, daemon=True).start()
def _run(self):
try:
conn, _ = self._srv.accept()
except OSError:
return
self._conns.append(conn)
self.accepted.set()
while not self._stop:
try:
data = conn.recv(4096)
except OSError:
break
if not data:
break
conn.sendall(data)
def close(self):
self._stop = True
for s in [self._srv] + self._conns:
try:
s.close()
except OSError:
pass
def _pcmd(children_tlvs):
"""Assemble D0 proactive-command TLV from child IE bytes,
decode it like transport does after a FETCH"""
body = b''.join(children_tlvs)
pdu = h2b('D0') + bytes([len(body)]) + body
return ProactiveCommand().from_tlv(pdu)
def _open_channel(port, ip='127.0.0.1', cmd_nr=1):
a, b, c, d = (int(x) for x in ip.split('.'))
return _pcmd([
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'open_channel',
'command_qualifier': 3}).to_tlv(),
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': 'terminal'}).to_tlv(),
BearerDescription(decoded={'bearer_type': 'default', 'bearer_parameters': b''}).to_tlv(),
BufferSize(decoded=1024).to_tlv(),
UiccTransportLevel(decoded={'protocol_type': 'tcp_uicc_client_remote',
'port_number': port}).to_tlv(),
OtherAddress(decoded={'type_of_address': 'ipv4',
'address': bytes([a, b, c, d])}).to_tlv(),
])
def _open_channel_raw(extra_ies, cmd_nr=1):
"""OPEN CHANNEL with only the head data"""
return _pcmd([
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'open_channel',
'command_qualifier': 3}).to_tlv(),
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': 'terminal'}).to_tlv(),
BearerDescription(decoded={'bearer_type': 'default', 'bearer_parameters': b''}).to_tlv(),
BufferSize(decoded=1024).to_tlv(),
] + extra_ies)
def _send_data(payload, chan='channel_1', cmd_nr=1):
return _pcmd([
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'send_data',
'command_qualifier': 1}).to_tlv(),
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': chan}).to_tlv(),
ChannelData(decoded=b2h(payload)).to_tlv(),
])
def _receive_data(length, chan='channel_1', cmd_nr=1):
return _pcmd([
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'receive_data',
'command_qualifier': 0}).to_tlv(),
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': chan}).to_tlv(),
ChannelDataLength(decoded=length).to_tlv(),
])
def _close_channel(chan='channel_1', cmd_nr=1):
return _pcmd([
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'close_channel',
'command_qualifier': 0}).to_tlv(),
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': chan}).to_tlv(),
])
def _first(til, cls):
return next((x for x in til if isinstance(x, cls)), None)
class BipRelayRoundTripTest(unittest.TestCase):
"""Drive the fixed Proact handlers (blocking sockets) with synthetic
proactive commands against a local echo server and assert a byte round-trip
plus the channel bookkeeping / error handling."""
def setUp(self):
self.echo = _EchoServer()
self.addCleanup(self.echo.close)
self.events = []
self.proact = Proact(data_available_sink=self.events.append)
self.addCleanup(self._close_all_channels)
def _close_all_channels(self):
for chan in list(self.proact.channels.channels.values()):
try:
chan.close()
except Exception:
pass
def _open(self, cmd_nr=1):
til = self.proact.handle_OpenChannel(_open_channel(self.echo.port, cmd_nr=cmd_nr))
# every TLV in the response must serialise (the transport does exactly
# this to post the TERMINAL RESPONSE)
b''.join(x.to_tlv() for x in til)
return til
def test_open_send_receive_roundtrip(self):
# OPEN CHANNEL -> socket connected, channel 1 opened, link established
til = self._open()
self.assertTrue(self.echo.accepted.wait(timeout=2.0))
self.assertIn(1, self.proact.channels.channels)
cd = _first(til, CommandDetails)
self.assertEqual(cd.decoded['type_of_command'], 'open_channel')
# TS 102 223 6.8.2 TERMINAL RESPONSE device id: terminal -> UICC
self.assertEqual(b2h(_first(til, DeviceIdentities).to_tlv()), '82028281')
# channel status: channel 1, link established
self.assertEqual(_first(til, ChannelStatus).decoded, '8100')
self.assertEqual(_first(til, Result).decoded['general_result'], 'performed_successfully')
# SEND DATA -> bytes written to the socket, echo server sends them back
payload = b'Hello SCP81 relay - opaque TLS record bytes'
til = self.proact.handle_SendData(_send_data(payload))
b''.join(x.to_tlv() for x in til)
# channel data length in the response = free Tx space, FF = ">255"
self.assertEqual(_first(til, ChannelDataLength).decoded, 255)
self.assertEqual(_first(til, Result).decoded['general_result'], 'performed_successfully')
# RECEIVE DATA -> drain the bytes back to the "card". real card
# uses data-available event, we poll the buffer
# and may need several RECEIVE DATA commands, as the spec allows.
got = bytearray()
deadline = time.monotonic() + 3.0
while len(got) < len(payload) and time.monotonic() < deadline:
chan = self.proact.channels.channels[1]
chan.wait_rx(1.0)
til = self.proact.handle_ReceiveData(_receive_data(len(payload) - len(got)))
b''.join(x.to_tlv() for x in til)
self.assertEqual(b2h(_first(til, DeviceIdentities).to_tlv()), '82028281')
got += h2b(_first(til, ChannelData).decoded)
self.assertEqual(bytes(got), payload, "byte round-trip through the BIP relay")
# CLOSE CHANNEL -> socket closed, bookkeeping cleared
til = self.proact.handle_CloseChannel(_close_channel())
b''.join(x.to_tlv() for x in til)
self.assertEqual(_first(til, Result).decoded['general_result'], 'performed_successfully')
self.assertNotIn(1, self.proact.channels.channels)
def test_data_available_event_envelope(self):
# The empty->non-empty Rx transition raises ENVELOPE EVENT DOWNLOAD
self._open()
self.assertTrue(self.echo.accepted.wait(timeout=2.0))
payload = b'PONG'
self.proact.handle_SendData(_send_data(payload))
chan = self.proact.channels.channels[1]
self.assertGreater(chan.wait_rx(2.0), 0)
# give the reader thread a beat to invoke the sink
deadline = time.monotonic() + 2.0
while not self.events and time.monotonic() < deadline:
time.sleep(0.01)
self.assertEqual(len(self.events), 1, "one data-available event on the empty->non-empty edge")
env = h2b(self.events[0])
# d6 0e | 99 01 09 (event: data available) | 82 02 82 81 terminal->UICC
# | b8 02 81 00 (channel 1 established) | b7 01 XX bytes available
self.assertEqual(b2h(env[:15]), 'd60e99010982028281b8028100b701')
self.assertGreaterEqual(env[15], 1)
self.assertLessEqual(env[15], len(payload))
def test_channel_number_from_device_identities(self):
# Two channels, not the old hardcoded 1
e2 = _EchoServer()
self.addCleanup(e2.close)
self.proact.handle_OpenChannel(_open_channel(self.echo.port))
# open a second channel with a second echo server
til2 = self.proact.handle_OpenChannel(_open_channel(e2.port))
self.assertEqual(sorted(self.proact.channels.channels), [1, 2])
self.assertEqual(_first(til2, ChannelStatus).decoded, '8200') # channel 2, established
# SEND DATA addressed to channel_2 must reach the second socket
self.assertTrue(e2.accepted.wait(timeout=2.0))
self.proact.handle_SendData(_send_data(b'two', chan='channel_2'))
chan2 = self.proact.channels.channels[2]
self.assertGreater(chan2.wait_rx(2.0), 0)
til = self.proact.handle_ReceiveData(_receive_data(3, chan='channel_2'))
self.assertEqual(h2b(_first(til, ChannelData).decoded), b'two')
# ..and nothing on chan 1
self.assertEqual(self.proact.channels.channels[1].available_rx(), 0)
def test_commands_on_closed_channel_report_bip_error(self):
# SEND/RECEIVE/CLOSE on a channel that was never opened must be rejected
# with a BIP error
for til in (self.proact.handle_SendData(_send_data(b'x', chan='channel_4')),
self.proact.handle_ReceiveData(_receive_data(1, chan='channel_4')),
self.proact.handle_CloseChannel(_close_channel(chan='channel_4'))):
b''.join(x.to_tlv() for x in til)
res = _first(til, Result).decoded
self.assertEqual(res['general_result'], 'bearer_independent_protocol_error')
self.assertEqual(res['additional_information'], 'channel_id_not_valid')
def test_receive_more_than_available_is_missing_info(self):
# terminal must NOT wait if fewer than the requested bytes are buffered,
# eturns what it has with "performed with missing information".
self._open()
self.assertTrue(self.echo.accepted.wait(timeout=2.0))
til = self.proact.handle_ReceiveData(_receive_data(10))
b''.join(x.to_tlv() for x in til)
self.assertEqual(_first(til, Result).decoded['general_result'],
'performed_with_missing_information')
self.assertEqual(h2b(_first(til, ChannelData).decoded), b'')
self.assertEqual(_first(til, ChannelDataLength).decoded, 0)
class OpenChannelRefusalTest(unittest.TestCase):
"""Refusal is a TERMINAL RESPONSE, not an exception, raising takes the whole
proactive session down and leaves the card wondering why"""
ADDR = OtherAddress(decoded={'type_of_address': 'ipv4', 'address': bytes([127, 0, 0, 1])})
TCP = UiccTransportLevel(decoded={'protocol_type': 'tcp_uicc_client_remote', 'port_number': 1234})
def setUp(self):
self.proact = Proact()
self.addCleanup(self._close_all_channels)
def _close_all_channels(self):
for chan in list(self.proact.channels.channels.values()):
try:
chan.close()
except Exception:
pass
def _assert_refused(self, til, additional_information, chan_nr=0):
b''.join(x.to_tlv() for x in til) # must serialise, the transport posts it
res = _first(til, Result).decoded
self.assertEqual(res['general_result'], 'bearer_independent_protocol_error')
self.assertEqual(res['additional_information'], additional_information)
self.assertEqual(_first(til, ChannelStatus).decoded, '%02x00' % chan_nr) # 8.56
self.assertIsNotNone(_first(til, BearerDescription)) # 6.8.20
self.assertIsNotNone(_first(til, BufferSize)) # 6.8.21
self.assertEqual(b2h(_first(til, DeviceIdentities).to_tlv()), '82028281') # 6.8.2
def test_transport_level(self):
cases = [[self.ADDR.to_tlv()]] # absent, 6.6.27.x Optional
for proto in ('udp_uicc_client_remote', 'tcp_uicc_server', 'udp_uicc_client_local',
'tcp_uicc_client_local', 'direct_channel'): # not TCP client remote
tl = UiccTransportLevel(decoded={'protocol_type': proto, 'port_number': 1234})
cases.append([tl.to_tlv(), self.ADDR.to_tlv()])
for extra in cases:
with self.subTest(extra=b2h(extra[0])):
self._assert_refused(self.proact.handle_OpenChannel(_open_channel_raw(extra)),
'requested_uicc_if_transp_level_not_available')
def test_destination_address(self):
v6 = OtherAddress(decoded={'type_of_address': 'ipv6', 'address': bytes(16)})
for extra in ([self.TCP.to_tlv()], # absent
[self.TCP.to_tlv(), v6.to_tlv()]): # not IPv4
with self.subTest(extra=len(extra)):
self._assert_refused(self.proact.handle_OpenChannel(_open_channel_raw(extra)),
'no_specific_cause')
def test_no_channel_left(self):
for _ in range(7): # 6.4.27.2, 6.4.27.3
self.proact.channels.channel_create()
cmd = _open_channel_raw([self.TCP.to_tlv(), self.ADDR.to_tlv()])
self._assert_refused(self.proact.handle_OpenChannel(cmd), 'no_channel_availabile')
def test_connect_failure(self):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # port nothing listens on
s.bind(('127.0.0.1', 0))
dead_port = s.getsockname()[1]
s.close()
til = self.proact.handle_OpenChannel(_open_channel(dead_port))
self._assert_refused(til, 'channel_closed', chan_nr=1) # 6.4.30
self.assertEqual(self.proact.channels.channels, {}) # channel given back
class BipSinkTest(unittest.TestCase):
"""Both sinks are optional, a driver with no SMS path at all must not crash and burn
with a card that sends one, and one that has one must get the PDU."""
def _submit(self):
return SMS_SUBMIT(tp_da=AddressField('12345', 'unknown', 'unknown'),
tp_ud=b'\x01\x02', tp_udl=2, tp_dcs=0xf6)
def test_sinks_default_to_none(self):
p = Proact()
self.assertIsNone(p.sms_sink)
def test_mo_sms_goes_to_the_sink(self):
seen = []
Proact(sms_sink=seen.append).send_sms_via_smpp(self._submit())
self.assertEqual(len(seen), 1)
def test_no_sms_sink_drops_instead_of_raising(self):
with self.assertLogs('pySim.bip', level='INFO'):
Proact().send_sms_via_smpp(self._submit())