Hoernchen has uploaded this change for review. ( https://gerrit.osmocom.org/c/pysim/+/43545?usp=email )
Change subject: smpp2sim: make the SCP81 BIP relay work ......................................................................
smpp2sim: make the SCP81 BIP relay work
The BIP relay ( the "handset" side for SCP81) never worked: the connect callback in handle_OpenChannel was "never called" as the fixme says, everything else was missing.
Fixme cause: card APDU I/O is driven synchronously, proactive command loop lives in a blocking while loop (pySim.transport.LinkBase.send_apdu_checksw) that runs on the Twisted reactor thread. A Twisted TCP4ClientEndpoint + connectProtocol only completes when the reactor does reactor things, but the reactor thread is stuck in that loop for the whole proactive session...
Fixme fix: don't fight the reactor, just drive the relay channel with a plain old blocking socket, which fits the synchronous execution model. Channel numbers now come from the command Device identities (channel_N -> low nibble) instead of the hard coded chan_nr == 1.
Additionally fix two bugs found on the path to scp81 glory: - TERMINAL RESPONSE device identities are forced to terminal->UICC per TS 102 223 6.8.2 (prepare_response() inverts the command identities, which for a channel-addressed BIP command yields channel_N->UICC). - Error responses now build a valid AddlInfoBip cause, prepare_response() hard coded empty "additional information" cannot be encoded for a BIP error.
And some tests based on real card interactions.
Change-Id: If96c768f2e35c20ea3753e601059410121517b60 --- M pySim-smpp2sim.py M pySim/bip.py M tests/unittests/test_bip_relay.py 3 files changed, 533 insertions(+), 40 deletions(-)
git pull ssh://gerrit.osmocom.org:29418/pysim refs/changes/45/43545/1
diff --git a/pySim-smpp2sim.py b/pySim-smpp2sim.py index 2c7ff51..f7a45f5 100755 --- a/pySim-smpp2sim.py +++ b/pySim-smpp2sim.py @@ -30,10 +30,13 @@
import argparse import logging +import socket +import threading +import time import colorlog
from twisted.protocols import basic -from twisted.internet import defer, endpoints, protocol, reactor, task +from twisted.internet import defer, endpoints, reactor, task from twisted.cred.portal import IRealm from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse from twisted.cred.portal import Portal @@ -55,6 +58,7 @@ from pySim.cat import ProactiveCommand, SendShortMessage, SMS_TPDU, SMSPPDownload, BearerDescription from pySim.cat import DeviceIdentities, Address, OtherAddress, UiccTransportLevel, BufferSize from pySim.cat import ChannelStatus, ChannelData, ChannelDataLength +from pySim.cat import EventList, EventDownload, Result from pySim.utils import b2h, h2b
logger = logging.getLogger(__name__) @@ -72,18 +76,6 @@ print("-> %s %s" % (cmd[:10], cmd[10:])) print("<- %s: %s" % (sw, resp))
-class TcpProtocol(protocol.Protocol): - def dataReceived(self, data): - pass - - def connectionLost(self, reason): - pass - - -def tcp_connected_callback(p: protocol.Protocol): - """called by twisted TCP client.""" - logger.error("%s: connected!" % p) - def dcs_is_8bit(dcs): if dcs == pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT, pdu_types.DataCodingDefault.OCTET_UNSPECIFIED): @@ -118,6 +110,11 @@ smppEndpoint = endpoints.TCP6ServerEndpoint(reactor, tcp_port, interface=bind_ip) smppEndpoint.listen(self.factory) self.tp = self.scc = self.card = None + # Serialise card/APDU access. + # - SMPP handler drives the card from reactor thread + # - BIP relay data-available path drives it from socket reader thread. + # The transport is not re-entrant, both must take this lock. + self._card_lock = threading.Lock()
def connect_to_card(self, tp: LinkBase): self.tp = tp @@ -130,6 +127,21 @@ self.card.select_adf_by_aid(adf='usim') # FIXME: create a more realistic profile than ffffff self.scc.terminal_profile('ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff') + # Connect the BIP relay inbound path to the card. + # relay socket receives data -> ME initiated ENVELOPE EVENT DOWNLOA + # -> triggers RECEIVE DATA proactive session. + # FIXME this cross-thread push to the card is exercised only with real hardware + # the card free tests cover socket relay + envelope construction, not delivery. + handler = getattr(tp, 'proactive_handler', None) + if isinstance(handler, Proact): + handler.data_available_sink = self._deliver_data_available + + def _deliver_data_available(self, envelope_hex: str): + """push ME initiated ENVELOPE EVENT DOWNLOAD to the card""" + with self._card_lock: + logger.info("ENVELOPE(Data available): %s" % envelope_hex) + (data, sw) = self.scc.envelope(envelope_hex) + logger.info("SW %s: %s" % (sw, data))
def _msgHandler(self, system_id, smpp, pdu): """Handler for incoming messages received via SMPP from ESME.""" @@ -164,7 +176,8 @@ # 3) send to the card envelope_hex = b2h(sms_dl.to_tlv()) logger.info("ENVELOPE: %s" % envelope_hex) - (data, sw) = self.scc.envelope(envelope_hex) + with self._card_lock: + (data, sw) = self.scc.envelope(envelope_hex) logger.info("SW %s: %s" % (sw, data)) if sw in ['9200', '9300']: # TODO send back RP-ERROR message with TP-FCS == 'SIM Application Toolkit Busy' diff --git a/pySim/bip.py b/pySim/bip.py index 5fc83ce..e5ec029 100644 --- a/pySim/bip.py +++ b/pySim/bip.py @@ -17,6 +17,19 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see http://www.gnu.org/licenses/.
+# A ProactiveHandler with TCP sockets that backs the BIP channels, +# so a card can run its own IP session (SCP81/HTTPS, CAT_TP, ...) +# +# Currently used by pySim-smpp2sim.py which connects the SMS path to its SMPP server. +# Other drivers can pass their own sinks: +# +# handler = Proact(data_available_sink=..., sms_sink=...) +# tp = init_reader(opts, proactive_handler=handler) +# +# Both sinks are optional. +# Without them the handler builds and logs what it would send. + + import logging import socket import threading @@ -34,43 +47,161 @@ logger = logging.getLogger(__name__)
class ProactChannel: - """Representation of a single protective channel.""" + """Representation of a single BIP channel, backed by a blocking TCP + socket. + + Why blocking sockets and not Twisted endpoints, considering we have + twisted? + The proactive-command loop lives in a blocking while-loop, + "pySim.transport.LinkBase.send_apdu_checksw" that runs on the + Twisted reactor thread. A Twisted async TCP client only makes any + progress when the reactor uhh... reacts, but the reactor is stuck + in that loop for the whole proactive session -> the connectProtocol() + Deferred never fires while we are handling OPEN/SEND/RECEIVE CHANNEL. + Plain blocking sockets just work: connect() in handle_OpenChannel, + send() in handle_SendData, recv() feeding a buffer for handle_ReceiveData. + No need to make it harder than it has to be to handle the "massive" T0 + bandwidth.. + + While TLS runs on the card and is the thing we are actually interested + in this only moves opaque bytes between the card and the socket, and + never looks at the payload. + """ + # how much we try to read off the socket per recv() + RECV_CHUNK = 4096 + def __init__(self, channels: 'ProactChannels', chan_nr: int): self.channels = channels self.chan_nr = chan_nr - self.ep = None + self.sock = None + # TS 102 223 says the terminal keeps an Rx buffer per channel; RECEIVE + # DATA drains it, and it is filled asynchronously as the peer sends. + self.rx_buf = bytearray() + self._rx_lock = threading.Lock() + self._reader = None + self._closing = False + self.peer_closed = False + + def connect(self, host: str, port: int, timeout: float = 10.0): + """Open the blocking TCP socket and start the background Rx reader.""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + s.settimeout(timeout) + s.connect((host, port)) + # Back to blocking mode for the reader thread. + # CLOSE CHANNEL unblocks the pending recv() via shutdown(). + s.settimeout(None) + self.sock = s + self._reader = threading.Thread(target=self._rx_loop, + name='bip-rx-%d' % self.chan_nr, daemon=True) + self._reader.start() + + def _rx_loop(self): + """Continuously read from the socket into rx_buf, like a real ME. + TS 102 223 7.5.10.1 says the event is raised 'only if the targeted channel + buffer is empty when new data arrives in it' + So we fire the channels data available hook on the empty->non-empty transition """ + while not self._closing: + try: + data = self.sock.recv(self.RECV_CHUNK) + except (OSError, ValueError): + break + if not data: + self.peer_closed = True + break + with self._rx_lock: + was_empty = len(self.rx_buf) == 0 + self.rx_buf.extend(data) + if was_empty and not self._closing: + self.channels.notify_data_available(self) + + def send(self, data: bytes): + """Tx, write bytes to the socket == SEND DATA""" + self.sock.sendall(data) + + def rx_available(self) -> int: + with self._rx_lock: + return len(self.rx_buf) + + def take_rx(self, n: int): + """Remove+return up to n bytes from rxbuf + remaining bytes""" + with self._rx_lock: + chunk = bytes(self.rx_buf[:n]) + del self.rx_buf[:n] + remaining = len(self.rx_buf) + return chunk, remaining + + def wait_rx(self, timeout: float) -> int: + """wait up to timeout seconds until the rxbuf has data + returns the number of bytes available + Cards have a "data available" event, card free callers use + this to wait for the echoed bytes.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + avail = self.rx_available() + if avail or self.peer_closed: + return avail + time.sleep(0.005) + return self.rx_available()
def close(self): - """Close the channel.""" - if self.ep: - self.ep.disconnect() + """Close channel: stop reader, close socket, drop bookkeeping.""" + self._closing = True + if self.sock is not None: + try: + self.sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + self.sock.close() + except OSError: + pass + # CLOSE CHANNEL synchronously handled inside the rx reader thread + # (data-available -> ENVELOPE -> FETCH -> handle_CloseChannel -> close), + # so close() can be called on the reader thread. + # Joining self raises "cannot join current thread" so better skip i.. + # setting _closing + shutting down the socket already makes _rx_loop + # return on the next iteration anyway. + if self._reader is not None and self._reader is not threading.current_thread(): + self._reader.join(timeout=1.0) self.channels.channel_delete(self.chan_nr)
class ProactChannels: """Wrapper class for maintaining state of proactive channels.""" - def __init__(self): + def __init__(self, on_data_available=None): self.channels = {} + # called from a channel rx reader thread on empty->non-empty buf + # transition, with the ProactChannel as its parameter + self._on_data_available = on_data_available
def channel_create(self) -> ProactChannel: """Create a new proactive channel, allocating its integer number.""" - for i in range(1, 9): + for i in range(1, 8): if not i in self.channels: self.channels[i] = ProactChannel(self, i) return self.channels[i] raise ValueError('Cannot allocate another channel: All channels active')
def channel_delete(self, chan_nr: int): - del self.channels[chan_nr] + self.channels.pop(chan_nr, None) + + def notify_data_available(self, chan: ProactChannel): + if self._on_data_available: + self._on_data_available(chan)
class Proact(ProactiveHandler): #def __init__(self, smpp_factory): # self.smpp_factory = smpp_factory - def __init__(self, sms_sink=None): + def __init__(self, data_available_sink=None, sms_sink=None): + # data_available_sink(envelope_hex) delivers ENVELOPE EVENT DOWNLOAD + # to the card when socket data arrives + # None in card free/test mode + self.data_available_sink = data_available_sink # sms_sink(pdu) delivers an MO-SMS the card sent (SEND SHORT MESSAGE) # onwards; pySim-smpp2sim.py hands it to its SMPP server. None -> log # and drop. self.sms_sink = sms_sink - self.channels = ProactChannels() + self.channels = ProactChannels(on_data_available=self._on_channel_data_available)
@staticmethod def _find_first_element_of_type(instlist, cls): @@ -79,6 +210,80 @@ return i return None
+ @staticmethod + def _channel_nr_from_dev_ids(dev_id_ie: DeviceIdentities) -> int: + """Maps id like channel_1 -> channel number. + TS 102 223 Section 8.7 says low nibble is channel number, + channel-N = 0x21..0x27""" + dest = dev_id_ie.decoded['dest_dev_id'] + return DeviceIdentities.DEV_IDS.inverse[dest] & 0x0f + + def _channel_for(self, dev_id_ie: DeviceIdentities): + """Resolve the ProactChannel addressed by a command dev id, or None""" + return self.channels.channels.get(self._channel_nr_from_dev_ids(dev_id_ie), None) + + @staticmethod + def _channel_status(chan_nr: int, established: bool = True) -> str: + """TS 102 223 Section 8.56 channel status value for the + default/network bearer: + - byte 3 low 3 bits = channel id + - bit 8 = link established + - byte 4 = 00 no further info""" + b3 = (0x80 if established else 0x00) | (chan_nr & 0x07) + return '%02x00' % b3 + + def _bip_response_head(self, pcmd: ProactiveCommand, + general_result: str = 'performed_successfully', + additional_information: str = ''): + """CommandDetails / DeviceIdentities / Result head part of a BIP TERMINAL + RESPONSE. Built on prepare_response() but with two changes: + + - Device identities forced source=terminal, dest=UICC. + TS 102 223 6.8.2 mandates for every TERMINAL RESPONSE + prepare_response() inverts the commands device id, which is + right for a uicc->terminal command but would yield a wrong + channel_N->UICC for the channel addressed BIP commands. + + - Result is recreated for non success cases. prepare_response() + hard codes empty "additional information", but for enum results + like BIP error -> AddlInfoBip the empty value cannot be encoded at + all, so we always ask prepare_response() for a success Result + and swap for a properly encoded one here.""" + head = self.prepare_response(pcmd, 'performed_successfully') + for i, ie in enumerate(head): + if isinstance(ie, DeviceIdentities): + head[i] = DeviceIdentities(decoded={'source_dev_id': 'terminal', + 'dest_dev_id': 'uicc'}) + elif isinstance(ie, Result) and general_result != 'performed_successfully': + res = Result() + res.from_dict({'result': {'general_result': general_result, + 'additional_information': additional_information}}) + head[i] = res + return head + + def build_data_available_envelope(self, chan: ProactChannel) -> bytes: + """TS 102 223 7.5.10.2 ENVELOPE EVENT DOWNLOAD + Event list, Device id terminal->UICC, Channel status, + Channel data length (bytes available or FF for > 255).""" + avail = min(chan.rx_available(), 0xff) + ed = EventDownload(children=[ + EventList(decoded=['data_available']), + DeviceIdentities(decoded={'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}), + ChannelStatus(decoded=self._channel_status(chan.chan_nr)), + ChannelDataLength(decoded=avail), + ]) + return ed.to_tlv() + + def _on_channel_data_available(self, chan: ProactChannel): + """rx reader thread hook: socket data arrived while the channel buffer + was empty. card uses ENVELOPE EVENT DOWNLOAD + responds by FETCHing RECEIVE DATA + proactive command. Card free only builds and logs""" + envelope_hex = b2h(self.build_data_available_envelope(chan)) + logger.info("channel %u: %u byte(s) available -> ENVELOPE(Data available) %s", + chan.chan_nr, chan.rx_available(), envelope_hex) + if self.data_available_sink: + self.data_available_sink(envelope_hex) + """Call-back which the pySim transport core calls whenever it receives a proactive command from the SIM.""" def handle_SendShortMessage(self, pcmd: ProactiveCommand): @@ -136,16 +341,22 @@ raise ValueError('Unsupported protocol_type') if other_addr_ie.decoded.get('type_of_address', None) != 'ipv4': raise ValueError('Unsupported type_of_address') - ipv4_bytes = h2b(other_addr_ie.decoded['address']) - ipv4_str = '%u.%u.%u.%u' % (ipv4_bytes[0], ipv4_bytes[1], ipv4_bytes[2], ipv4_bytes[3]) + addr_bytes = h2b(other_addr_ie.decoded['address']) if isinstance( + other_addr_ie.decoded['address'], str) else other_addr_ie.decoded['address'] + ipv4_str = '%u.%u.%u.%u' % (addr_bytes[0], addr_bytes[1], addr_bytes[2], addr_bytes[3]) port_nr = transp_lvl_ie.decoded['port_number'] - print("%s:%u" % (ipv4_str, port_nr)) + logger.info("OpenChannel: connecting to %s:%u", ipv4_str, port_nr) channel = self.channels.channel_create() - channel.ep = endpoints.TCP4ClientEndpoint(reactor, ipv4_str, port_nr) - channel.prot = TcpProtocol() - d = endpoints.connectProtocol(channel.ep, channel.prot) - # FIXME: why is this never called despite the client showing the inbound connection? - d.addCallback(tcp_connected_callback) + # yes, blocking connect() + try: + channel.connect(ipv4_str, port_nr) + except OSError as e: + logger.warning("OpenChannel: connect to %s:%u failed: %s", ipv4_str, port_nr, e) + self.channels.channel_delete(channel.chan_nr) + return self._bip_response_head(pcmd, 'bearer_independent_protocol_error', + 'channel_closed') + [ + ChannelStatus(decoded=self._channel_status(channel.chan_nr, established=False)), + bearer_desc_ie, buffer_size_ie]
# Terminal Response example: [ # {'command_details': {'command_number': 1, @@ -157,12 +368,22 @@ # {'bearer_description': {'bearer_type': 'default', 'bearer_parameters': ''}}, # {'buffer_size': 1024} # ] - return self.prepare_response(pcmd) + [ChannelStatus(decoded='8100'), bearer_desc_ie, buffer_size_ie] + return self._bip_response_head(pcmd) + [ + ChannelStatus(decoded=self._channel_status(channel.chan_nr)), + bearer_desc_ie, buffer_size_ie]
def handle_CloseChannel(self, pcmd: ProactiveCommand): """Close a channel.""" logger.info("CloseChannel") logger.info(pcmd) + dev_id_ie = Proact._find_first_element_of_type(pcmd.children, DeviceIdentities) + chan = self._channel_for(dev_id_ie) + if chan is None: + # channel closed / invalid + return self._bip_response_head(pcmd, 'bearer_independent_protocol_error', + 'channel_id_not_valid') + chan.close() + return self._bip_response_head(pcmd)
def handle_ReceiveData(self, pcmd: ProactiveCommand): """Receive/read data from the socket.""" @@ -175,6 +396,21 @@ # ]} logger.info("ReceiveData") logger.info(pcmd) + dev_id_ie = Proact._find_first_element_of_type(pcmd.children, DeviceIdentities) + req_len_ie = Proact._find_first_element_of_type(pcmd.children, ChannelDataLength) + chan = self._channel_for(dev_id_ie) + if chan is None: + return self._bip_response_head(pcmd, 'bearer_independent_protocol_error', + 'channel_id_not_valid') + # TS 102 223 8.54: RECEIVE DATA contains the requested count the card wants + requested = req_len_ie.decoded if req_len_ie is not None else chan.rx_available() + data, remaining = chan.take_rx(requested) + # TS 102 223 6.4.29: + # - return data available in the Rx buffer + num bytes still remaining (FF if > 255) + # - if fewer than requested available terminal must NOT wait, report and returns what we have + general_result = 'performed_successfully' + if len(data) < requested: + general_result = 'performed_with_missing_information' # Terminal Response example: [ # {'command_details': {'command_number': 1, # 'type_of_command': 'receive_data', @@ -184,7 +420,9 @@ # {'channel_data': '16030100040e000000'}, # {'channel_data_length': 0} # ] - return self.prepare_response(pcmd) + [] + return self._bip_response_head(pcmd, general_result) + [ + ChannelData(decoded=b2h(data)), + ChannelDataLength(decoded=min(remaining, 0xff))]
def handle_SendData(self, pcmd: ProactiveCommand): """Send/write data received from the SIM to the socket.""" @@ -199,10 +437,18 @@ logger.info(pcmd) dev_id_ie = Proact._find_first_element_of_type(pcmd.children, DeviceIdentities) chan_data_ie = Proact._find_first_element_of_type(pcmd.children, ChannelData) - chan_str = dev_id_ie.decoded['dest_dev_id'] - chan_nr = 1 # FIXME - chan = self.channels.channels.get(chan_nr, None) - # FIXME chan.prot.transport.write(h2b(chan_data_ie.decoded)) + chan = self._channel_for(dev_id_ie) + if chan is None: + return self._bip_response_head(pcmd, 'bearer_independent_protocol_error', + 'channel_id_not_valid') + # lets accept hexstrings as well + payload = chan_data_ie.decoded + if isinstance(payload, str): + payload = h2b(payload) + # command_qualifier bit 1 selects 'send immediately' / Tx-buffer store and forward + # For TCP stream all we have is a socket and TCP takes care of segmentation, + # so just send. + chan.send(payload) # Terminal Response example: [ # {'command_details': {'command_number': 1, # 'type_of_command': 'send_data', @@ -211,7 +457,8 @@ # {'result': {'general_result': 'performed_successfully', 'additional_information': ''}}, # {'channel_data_length': 255} # ] - return self.prepare_response(pcmd) + [ChannelDataLength(decoded=255)] + # TS 102 223 6.4.30 / 8.54 Channel data length = free space tx buf; FF == > 255 available + return self._bip_response_head(pcmd) + [ChannelDataLength(decoded=255)]
def handle_SetUpEventList(self, pcmd: ProactiveCommand): # {'set_up_event_list': [{'command_details': {'command_number': 1, diff --git a/tests/unittests/test_bip_relay.py b/tests/unittests/test_bip_relay.py index e2e7ff8..cf11b7f 100644 --- a/tests/unittests/test_bip_relay.py +++ b/tests/unittests/test_bip_relay.py @@ -19,8 +19,241 @@
import unittest
-from pySim.bip import Proact +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 _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].rx_available(), 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) + + +if __name__ == "__main__": + unittest.main()
class BipSinkTest(unittest.TestCase):