Hoernchen has uploaded this change for review.
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):
To view, visit change 43545. To unsubscribe, or for help writing mail filters, visit settings.