diff --git a/contrib/card2server.py b/contrib/card2server.py new file mode 100755 index 00000000..00458b80 --- /dev/null +++ b/contrib/card2server.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +"""Connect smartcard to a remote server, so the remote server can take control and +perform commands on it.""" + +import sys +import json +import logging +import argparse +from osmocom.utils import b2h +import websockets + +from pySim.transport import init_reader, argparse_add_reader_args, LinkBase +from pySim.commands import SimCardCommands +from pySim.wsrc.client_blocking import WsClientBlocking + +logging.basicConfig(format="[%(levelname)s] %(asctime)s %(message)s", level=logging.DEBUG) +logger = logging.getLogger(__name__) + +class CardWsClientBlocking(WsClientBlocking): + """Implementation of the card (reader) client of the WSRC (WebSocket Remote Card) protocol""" + + def __init__(self, ws_uri, tp: LinkBase): + super().__init__(ws_uri) + self.tp = tp + + def perform_outbound_hello(self): + hello_data = { + 'client_type': 'card', + 'atr': b2h(self.tp.get_atr()), + # TODO: include various card information in the HELLO message + } + super().perform_outbound_hello(hello_data) + + def handle_rx_c_apdu(self, rx: dict): + """handle an inbound APDU transceive command""" + data, sw = self.tp.send_apdu(rx['command']) + tx = { + 'response': data, + 'sw': sw, + } + self.tx_json('r_apdu', tx) + + def handle_rx_disconnect(self, rx: dict): + """server tells us to disconnect""" + self.tx_json('disconnect_ack') + # FIXME: tear down connection and/or terminate entire program + + def handle_rx_print(self, rx: dict): + """print a message (text) given by server to the local console/log""" + print(rx['message']) + # no response + + def handle_rx_reset_req(self, rx: dict): + """server tells us to reset the card""" + self.tp.reset() + self.tx_json('reset_resp', {'atr': b2h(self.tp.get_atr())}) + + +parser = argparse.ArgumentParser() +argparse_add_reader_args(parser) +parser.add_argument("--uri", default="ws://localhost:8765/", + help="URI of the sever to which to connect") + +opts = parser.parse_args() + +# open the card reader / slot +logger.info("Initializing Card Reader...") +tp = init_reader(opts) +logger.info("Connecting to Card...") +tp.connect() +scc = SimCardCommands(transport=tp) +logger.info("Detected Card with ATR: %s" % b2h(tp.get_atr())) + +# TODO: gather various information about the card; print it + +# create + connect the client to the server +cl = CardWsClientBlocking(opts.uri, tp) +logger.info("Connecting to remote server...") +try: + cl.connect() + print("Successfully connected to Server") +except ConnectionRefusedError as e: + print(e) + sys.exit(1) + +try: + while True: + # endless loop: wait for inbound command from server + execute it + cl.rx_and_execute_cmd() + # TODO: clean handling of websocket disconnect +except websockets.exceptions.ConnectionClosedOK as e: + print(e) + sys.exit(1) +except KeyboardInterrupt as e: + print(e.__class__.__name__) + sys.exit(2) diff --git a/contrib/card_server.py b/contrib/card_server.py new file mode 100755 index 00000000..d86b56ed --- /dev/null +++ b/contrib/card_server.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python + +import json +import asyncio +import logging +from typing import Optional, Tuple +from websockets.asyncio.server import serve +from websockets.exceptions import ConnectionClosedError +from osmocom.utils import Hexstr, swap_nibbles + +from pySim.utils import SwMatchstr, ResTuple, sw_match, dec_iccid +from pySim.exceptions import SwMatchError + +logging.basicConfig(format="[%(levelname)s] %(asctime)s %(message)s", level=logging.DEBUG) +logger = logging.getLogger(__name__) + +card_clients = set() +user_clients = set() + +class WsClient: + def __init__(self, websocket, hello: dict): + self.websocket = websocket + self.hello = hello + self.identity = {} + + def __str__(self): + return '%s(ws=%s)' % (self.__class__.__name__, self.websocket) + + async def rx_json(self): + rx = await self.websocket.recv() + rx_js = json.loads(rx) + logger.debug("Rx: %s", rx_js) + assert 'msg_type' in rx + return rx_js + + async def tx_json(self, msg_type:str, d: dict = {}): + """Transmit a json-serializable dict to the peer""" + d['msg_type'] = msg_type + d_js = json.dumps(d) + logger.debug("Tx: %s", d_js) + await self.websocket.send(d_js) + + async def tx_hello_ack(self): + await self.tx_json('hello_ack') + + async def xceive_json(self, msg_type:str, d:dict = {}, exp_msg_type:Optional[str] = None) -> dict: + await self.tx_json(msg_type, d) + rx = await self.rx_json() + if exp_msg_type: + assert rx['msg_type'] == exp_msg_type + return rx; + + async def tx_error(self, message: str): + """Transmit an error message to the peer""" + event = { + "message": message, + } + await self.tx_json('error', event) + + async def ws_hdlr(self): + """kind of a 'main' function for the websocket client: wait for incoming message, + and handle it.""" + try: + async for message in self.websocket: + method = getattr(self, 'handle_rx_%s' % message['msg_type'], None) + if not method: + await self.tx_error("Unknonw msg_type: %s" % message['msg_type']) + else: + method(message) + except ConnectionClosedError: + # we handle this in the outer loop + pass + +class CardClient(WsClient): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.state = 'init' + + def __str__(self): + eid = self.identity.get('EID', None) + if eid: + return '%s(EID=%s)' % (self.__class__.__name__, eid) + iccid = self.identity.get('ICCID', None) + if iccid: + return '%s(ICCID=%s)' % (self.__class__.__name__, iccid) + return super().__str__() + + """A websocket client that represents a reader/card. This is what we use to talk to a card""" + async def xceive_apdu_raw(self, cmd: Hexstr) -> ResTuple: + """transceive a single APDU with the card""" + message = await self.xceive_json('c_apdu', {'command': cmd}, 'r_apdu') + return message['response'], message['sw'] + + async def xceive_apdu(self, pdu: Hexstr) -> ResTuple: + """transceive an APDU with the card, handling T=0 GET_RESPONSE cases""" + prev_pdu = pdu + data, sw = await self.xceive_apdu_raw(pdu) + + if sw is not None: + while (sw[0:2] in ['9f', '61', '62', '63']): + # SW1=9F: 3GPP TS 51.011 9.4.1, Responses to commands which are correctly executed + # SW1=61: ISO/IEC 7816-4, Table 5 — General meaning of the interindustry values of SW1-SW2 + # SW1=62: ETSI TS 102 221 7.3.1.1.4 Clause 4b): 62xx, 63xx, 9xxx != 9000 + pdu_gr = pdu[0:2] + 'c00000' + sw[2:4] + prev_pdu = pdu_gr + d, sw = await self.xceive_apdu_raw(pdu_gr) + data += d + if sw[0:2] == '6c': + # SW1=6C: ETSI TS 102 221 Table 7.1: Procedure byte coding + pdu_gr = prev_pdu[0:8] + sw[2:4] + data, sw = await self.xceive_apdu_raw(pdu_gr) + + return data, sw + + async def xceive_apdu_checksw(self, pdu: Hexstr, sw: SwMatchstr = "9000") -> ResTuple: + """like xceive_apdu, but checking the status word matches the expected pattern""" + rv = await self.xceive_apdu(pdu) + last_sw = rv[1] + + if not sw_match(rv[1], sw): + raise SwMatchError(rv[1], sw.lower()) + return rv + + async def card_reset(self): + """reset the card""" + rx = await self.xceive_json('reset_req', exp_msg_type='reset_resp') + + async def get_iccid(self): + """high-level method to obtain the ICCID of the card""" + await self.xceive_apdu_checksw('00a40000023f00') # SELECT MF + await self.xceive_apdu_checksw('00a40000022fe2') # SELECT EF.ICCID + res, sw = await self.xceive_apdu_checksw('00b0000000') # READ BINARY + return dec_iccid(res) + + async def get_eid_sgp22(self): + """high-level method to obtain the EID of a SGP.22 consumer eUICC""" + await self.xceive_apdu_checksw('00a4040410a0000005591010ffffffff8900000100') + res, sw = await self.xceive_apdu_checksw('80e2910006bf3e035c015a') + return res[-32:] + + async def identify(self): + # identify the card by asking for its EID and/or ICCID + try: + eid = await self.get_eid_sgp22() + logger.debug("EID: %s", eid) + self.identity['EID'] = eid + except SwMatchError: + pass + try: + iccid = await self.get_iccid() + logger.debug("ICCID: %s", iccid) + self.identity['ICCID'] = iccid + except SwMatchError: + pass + logger.info("Card now in READY state") + self.state = 'ready' + + @staticmethod + def find_client_for_id(id_type: str, id_str: str) -> Optional['CardClient']: + for c in card_clients: + print("testing card %s in state %s" % (c, c.state)) + if c.state != 'ready': + continue + c_id = c.identity.get(id_type.upper(), None) + if c_id and c_id.lower() == id_str.lower(): + return c + return None + +class UserClient(WsClient): + """A websocket client representing a user application like pySim-shell.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.state = 'init' + + async def state_init(self): + """Wait for incoming 'select_card' and process it.""" + while True: + rx = await self.rx_json() + if rx['msg_type'] == 'select_card': + # look-up if the card can be found + card = CardClient.find_client_for_id(rx['id_type'], rx['id_str']) + if not card: + await self.tx_error('No CardClient found for %s == %s' % (rx['id_type'], rx['id_str'])) + continue + # transition to next statee + self.state = 'associated' + card.state = 'associated' + self.card = card + await self.tx_json('select_card_ack', {'identities': card.identity}) + break + else: + self.tx_error('Unknown message type %s' % rx['msg_type']) + + async def state_selected(self): + while True: + rx = await self.rx_json() + if rx['msg_type'] == 'c_apdu': + rsp, sw = await self.card.xceive_apdu_raw(rx['command']) + await self.tx_json('r_apdu', {'response': rsp, 'sw': sw}) + + +async def ws_conn_hdlr(websocket): + rx_raw = await websocket.recv() + rx = json.loads(rx_raw) + assert rx['msg_type'] == 'hello' + client_type = rx['client_type'] + logger.info("New client (type %s) connection accepted", client_type) + + if client_type == 'card': + card = CardClient(websocket, rx) + await card.tx_hello_ack() + card_clients.add(card) + # first obtain the identity of the card + await card.identify() + # then go into the "main loop" + try: + await card.ws_hdlr() + finally: + logger.info("%s: connection closed", card) + card_clients.remove(card) + elif client_type == 'user': + user = UserClient(websocket, rx) + await user.tx_hello_ack() + user_clients.add(user) + # first wait for the user to specify the select the card + await user.state_init() + try: + await user.state_selected() + finally: + logger.info("%s: connection closed", user) + user_clients.remove(user) + else: + logger.info("Rejecting client (unknown type %s) connection", client_type) + raise ValueError + + +async def main(): + async with serve(ws_conn_hdlr, "localhost", 8765): + await asyncio.get_running_loop().create_future() # run forever + +asyncio.run(main()) diff --git a/docs/index.rst b/docs/index.rst index 92be830a..2508808b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,6 +48,7 @@ pySim consists of several parts: sim-rest suci-keytool saip-tool + wsrc Indices and tables diff --git a/docs/wsrc.rst b/docs/wsrc.rst new file mode 100644 index 00000000..c45d77f7 --- /dev/null +++ b/docs/wsrc.rst @@ -0,0 +1,31 @@ +WebSocket Remote Card (WSRC) +============================ + +WSRC (*Web Socket Remote Card*) is a mechanism by which card readers can be made remotely available +via a computer network. The transport mechanism is (as the name implies) a WebSocket. This transport +method was chosen to be as firewall/NAT friendly as possible. + +WSRC Network Architecture +------------------------- + +In a WSRC network, there are three major elements: + +* The **WSRC Card Client** which exposes a locally attached smart card (usually via a Smart Card Reader) + to a remote *WSRC Server* +* The **WSRC Server** manges incoming connections from both *WSRC Card Clients* as well as *WSRC User Clients* +* The **WSRC User Client** is a user application, like for example pySim-shell, which is accessing a remote + card by connecting to the *WSRC Server* which relays the information to the selected *WSRC Card Client* + +WSRC Protocol +------------- + +The WSRC protocl consits of JSON objects being sent over a websocket. The websocket communication itself +is based on HTTP and should usually operate via TLS for security reasons. + +The detailed protocol is currently still WIP. The plan is to document it here. + + +pySim implementations +--------------------- + +TBD diff --git a/pySim/transport/__init__.py b/pySim/transport/__init__.py index 767691c8..d30fe879 100644 --- a/pySim/transport/__init__.py +++ b/pySim/transport/__init__.py @@ -333,11 +333,13 @@ def argparse_add_reader_args(arg_parser: argparse.ArgumentParser): from pySim.transport.pcsc import PcscSimLink from pySim.transport.modem_atcmd import ModemATCommandLink from pySim.transport.calypso import CalypsoSimLink + from pySim.transport.wsrc import WsrcSimLink SerialSimLink.argparse_add_reader_args(arg_parser) PcscSimLink.argparse_add_reader_args(arg_parser) ModemATCommandLink.argparse_add_reader_args(arg_parser) CalypsoSimLink.argparse_add_reader_args(arg_parser) + WsrcSimLink.argparse_add_reader_args(arg_parser) arg_parser.add_argument('--apdu-trace', action='store_true', help='Trace the command/response APDUs exchanged with the card') @@ -360,6 +362,9 @@ def init_reader(opts, **kwargs) -> LinkBase: elif opts.modem_dev is not None: from pySim.transport.modem_atcmd import ModemATCommandLink sl = ModemATCommandLink(opts, **kwargs) + elif opts.wsrc_server_url is not None: + from pySim.transport.wsrc import WsrcSimLink + sl = WsrcSimLink(opts, **kwargs) else: # Serial reader is default print("No reader/driver specified; falling back to default (Serial reader)") from pySim.transport.serial import SerialSimLink diff --git a/pySim/transport/wsrc.py b/pySim/transport/wsrc.py new file mode 100644 index 00000000..41eb86ba --- /dev/null +++ b/pySim/transport/wsrc.py @@ -0,0 +1,101 @@ +# Copyright (C) 2024 Harald Welte +# +# 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 . +# + +import argparse +from typing import Optional + +from osmocom.utils import h2i, i2h, Hexstr, is_hexstr + +from pySim.exceptions import NoCardError, ProtocolError, ReaderError +from pySim.transport import LinkBase +from pySim.utils import ResTuple +from pySim.wsrc.client_blocking import WsClientBlocking + +class UserWsClientBlocking(WsClientBlocking): + def perform_outbound_hello(self): + hello_data = { + 'client_type': 'user', + } + super().perform_outbound_hello(hello_data) + + def select_card(self, id_type:str, id_str:str): + rx = self.transceive_json('select_card', {'id_type': id_type, 'id_str': id_str}, + 'select_card_ack') + return rx + + def reset_card(self): + self.transceive_json('reset_req', {}, 'reset_resp') + + def xceive_apdu_raw(self, cmd: Hexstr) -> ResTuple: + rx = self.transceive_json('c_apdu', {'command': cmd}, 'r_apdu') + return rx['response'], rx['sw'] + + +class WsrcSimLink(LinkBase): + """ pySim: WSRC (WebSocket Remote Card) reader transport link.""" + name = 'WSRC' + + def __init__(self, opts: argparse.Namespace, **kwargs): + super().__init__(**kwargs) + self.identities = {} + self.server_url = opts.wsrc_server_url + if opts.wsrc_eid: + self.id_type = 'eid' + self.id_str = opts.wsrc_eid + elif opts.wsrc_iccid: + self.id_type = 'iccid' + self.id_str = opts.wsrc_iccid + self.client = UserWsClientBlocking(self.server_url) + self.client.connect() + + def __del__(self): + # FIXME: disconnect from server + pass + + def wait_for_card(self, timeout: Optional[int] = None, newcardonly: bool = False): + self.connect() + + def connect(self): + rx = self.client.select_card(self.id_type, self.id_str) + self.identitites = rx['identities'] + + def get_atr(self) -> Hexstr: + return self.identities['atr'] + + def disconnect(self): + self.__delete__() + + def _reset_card(self): + self.client.reset_card() + return 1 + + def _send_apdu_raw(self, pdu: Hexstr) -> ResTuple: + return self.client.xceive_apdu_raw(pdu) + + def __str__(self) -> str: + return "WSRC[%s=%s]" % (self.id_type, self.id_str) + + @staticmethod + def argparse_add_reader_args(arg_parser: argparse.ArgumentParser): + wsrc_group = arg_parser.add_argument_group('WebSocket Remote Card', + """WebSocket Remote Card (WSRC) is a protoocl by which remot cards / card readers + can be accessed via a network.""") + wsrc_group.add_argument('--wsrc-server-url', + help='URI of the WSRC server to connect to') + wsrc_group.add_argument('--wsrc-iccid', type=is_hexstr, + help='ICCID of the card to open via WSRC') + wsrc_group.add_argument('--wsrc-eid', type=is_hexstr, + help='EID of the card to open via WSRC') diff --git a/pySim/wsrc/__init__.py b/pySim/wsrc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pySim/wsrc/client_blocking.py b/pySim/wsrc/client_blocking.py new file mode 100644 index 00000000..c3e13595 --- /dev/null +++ b/pySim/wsrc/client_blocking.py @@ -0,0 +1,65 @@ +"""Connect smartcard to a remote server, so the remote server can take control and +perform commands on it.""" + +import abc +import json +import logging +from typing import Optional +from websockets.sync.client import connect +from osmocom.utils import b2h + +logger = logging.getLogger(__name__) + +class WsClientBlocking(abc.ABC): + """Generalized synchronous/blocking client for the WSRC (Web Socket Remote Card) protocol""" + + def __init__(self, ws_uri: str): + self.ws_uri = ws_uri + self.ws = None + + def connect(self): + self.ws = connect(uri=self.ws_uri) + self.perform_outbound_hello() + + def tx_json(self, msg_type: str, d: dict = {}): + """JSON-Encode and transmit a message to the given websocket.""" + d['msg_type'] = msg_type + d_js = json.dumps(d) + logger.debug("Tx: %s", d_js) + self.ws.send(d_js) + + def tx_error(self, message: str): + event = { + 'message': message, + } + self.tx_json('error', event) + + def rx_json(self): + """Receive a single message from the given websocket and JSON-decode it.""" + rx = self.ws.recv() + rx_js = json.loads(rx) + logger.debug("Rx: %s", rx_js) + assert 'msg_type' in rx + return rx_js + + def transceive_json(self, tx_msg_type: str, tx_d: Optional[dict], rx_msg_type: str) -> dict: + self.tx_json(tx_msg_type, tx_d) + rx = self.rx_json() + assert rx['msg_type'] == rx_msg_type + return rx + + def perform_outbound_hello(self, tx: dict = {}): + self.tx_json('hello', tx) + rx = self.rx_json() + assert rx['msg_type'] == 'hello_ack' + return rx + + def rx_and_execute_cmd(self): + """Receve and dispatch/execute a single command from the server.""" + rx = self.rx_json() + handler = getattr(self, 'handle_rx_%s' % rx['msg_type'], None) + if handler: + handler(rx) + else: + logger.error('Received unknown/unsupported msg_type %s' % rx['msg_type']) + self.tx_error('Message type "%s" is not supported' % rx['msg_type'])