diff --git a/contrib/rcp/rcp_client.py b/contrib/rcp/rcp_client.py
new file mode 100755
index 00000000..2cb815ee
--- /dev/null
+++ b/contrib/rcp/rcp_client.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+
+# (C) 2026 by sysmocom - s.f.m.c. GmbH
+# All Rights Reserved
+#
+# Author: Philipp Maier
+#
+# 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 sys
+import os
+import websockets
+import asyncio
+import argparse
+import logging
+from copy import deepcopy
+from pathlib import Path
+from pySim.log import PySimLogger
+from rcp_utils import CltConnHdlr, backtrace, pytype_to_type, load_ca_cert, load_json_schema, JsonValidator
+from pySim.transport import init_reader, argparse_add_reader_args, LinkBase
+from packaging.version import Version
+
+SERVER_TIMEOUT = 10
+
+# The RCP Client software version shall be incremented when there are changes to the RCP Client (this module) or changes
+# to other related modules, which affect the RCP Client. The RCP Client software version is also disclosed towards the
+# RCP Server.
+RCPC_VERSION_SOFTWARE = "1.0.0"
+
+# The RCP Client protocol version refers to the protocol spoken between RCP Client and RCP Server. The protocol version
+# shall be incremented when there are changes to the protocol (JSON Schema and/or application logic, see also
+# RCPC_VERSION_PROTOCOL in rcp_server.py).
+RCPC_VERSION_PROTOCOL = "1.0.0"
+
+log = PySimLogger.get(Path(__file__).stem)
+option_parser = argparse.ArgumentParser(description='RCP Client',
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+argparse_add_reader_args(option_parser)
+option_parser.add_argument("--verbose", help="Enable verbose logging",
+ action='store_true', default=False)
+option_parser.add_argument("--uri", help="URI of the RCP-Server")
+option_parser.add_argument("--ca-cert", help="SSL/TLS CA-Certificate of the RCP-Server")
+
+class RcpcCltConnHdlr(CltConnHdlr):
+ def __init__(self, sl, *args, **kwargs):
+ self.sl = sl
+ super().__init__(*args, **kwargs)
+
+ async def check_version(self):
+ """
+ Send the Protocol and Software version of this RCP Client to the RCP Server. The RCP Server will then check
+ if this client is (still) compatible. If an incompatibility is detected, the connection will be closed.
+ """
+ log.info("Checking version ...")
+ tx_json = {'rcpc_version': {'software' : RCPC_VERSION_SOFTWARE,
+ 'protocol' : RCPC_VERSION_PROTOCOL}}
+ log.info("RCP Client version: software=%s, protocol=%s",
+ RCPC_VERSION_SOFTWARE, RCPC_VERSION_PROTOCOL)
+ rx_json = await self._transact(tx_json)
+ rcps_version_software = Version(rx_json['rcpc_version']['software'])
+ rcps_version_protocol = Version(rx_json['rcpc_version']['protocol'])
+ rcps_version_info = str(rx_json['rcpc_version'].get('info'))
+ if rcps_version_info:
+ log.info("RCP Server version: software=%s, protocol=%s",
+ rcps_version_software, rcps_version_protocol)
+ else:
+ log.info("RCP Server version: software=%s, protocol=%s, %s",
+ rcps_version_software, rcps_version_protocol, rcps_version_info)
+
+ async def describe(self, suitable_for:dict) -> list:
+ log.info("Requesting module descriptions from RCP Server ...")
+ tx_json = {'rcpc_hello': {'suitable_for' : suitable_for}}
+ rx_json = await self._transact(tx_json)
+ module_descr = rx_json['rcpc_welcome']['module_descr']
+ if not module_descr:
+ raise ValueError("No RCP module available for this card")
+ return module_descr
+
+ async def run(self, cmd:str, cmd_argv) -> int:
+ log.info("Executing command with RCP Server ...")
+ tx_json = {'rcpc_command': {'cmd' : cmd, 'cmd_argv' : cmd_argv}}
+ while(True):
+ rx_json = await self._transact(tx_json)
+ tx_json = None
+ if 'rcpc_instr' in rx_json:
+ rcpc_instr = rx_json['rcpc_instr']
+ if 'c_apdu' in rcpc_instr:
+ c_apdu = rx_json['rcpc_instr']['c_apdu']
+ data, sw = sl.send_apdu(c_apdu)
+ tx_json = {'rcpc_result': {'r_apdu' : {'data': data.upper(), 'sw': sw.upper()}}}
+ elif 'reset' in rcpc_instr:
+ sl.reset_card()
+ atr = sl.get_atr()
+ tx_json = {'rcpc_result': {'atr' : atr.upper()}}
+ elif 'print' in rcpc_instr:
+ log.info(str(self) + " -- %s", rx_json['rcpc_instr']['print'])
+ tx_json = {'rcpc_result': {'empty' : None}}
+ elif 'rcpc_goodbye' in rx_json:
+ rc = rx_json['rcpc_goodbye']
+ log.info("Command execution done, rc: %d", rc)
+ return rc
+
+def check_if_user_needs_basic_help(argv):
+ """
+ The '--uri' argument is the minimum requirement to connect to the RCP Server to retrieve the information about the
+ dynamic commandline arguments. In case this argument is missing while '--help' or '-h' arguments are present. Then
+ we will fall back to display only a basic help that contains only the static commandline arguments (see above).
+ """
+
+ if '--help' in argv or '-h' in argv:
+ if '--uri' not in argv:
+ option_parser.parse_args()
+ sys.exit(1)
+
+def parse_known_arguemnts(argv):
+ """
+ Parse the commandline arguments we know so far. Ignore unknown arguments and filter out '--help' and '-h'
+ arguments, in case those are present.
+ """
+
+ argv_filtered = deepcopy(argv)
+ if '--help' in argv_filtered:
+ argv_filtered.remove('--help')
+ if '-h' in argv_filtered:
+ argv_filtered.remove('-h')
+ opts, unknown = option_parser.parse_known_args(argv_filtered)
+ return opts
+
+async def run_rcp_session(opts, sl, ssl_context) -> int:
+ """
+ Connect to the RCP Server, retrieve the module description, use the module description to complete the commandline
+ argument parser, execute the command that the user has selected.
+ """
+
+ # Request ATR from card
+ card_atr = sl.get_atr().upper()
+ log.info("Detected Card with ATR: %s" % card_atr)
+
+ # Connect to RCP server
+ log.info("RCP Server URI: %s" % opts.uri)
+ async with websockets.connect(opts.uri, ssl=ssl_context) as websocket:
+ rcpc_to_rcps_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(), "rcpc_to_rcps_schema.json"))
+ rcps_to_rcpc_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(), "rcps_to_rcpc_schema.json"))
+ json_validator = JsonValidator(rcps_to_rcpc_schema, rcpc_to_rcps_schema)
+ client = RcpcCltConnHdlr(sl, websocket, SERVER_TIMEOUT, json_validator)
+
+ # Check software and protocol version
+ await client.check_version()
+
+ # Retrieve module description
+ module_descrs = await client.describe({"atr" : card_atr})
+
+ # Complete the commandline parser and set up a dict that we can use as filter
+ # TODO: Maybe it makes sense to integrate this as a method into the RcpcCltConnHdlr class?
+ option_subparsers = option_parser.add_subparsers(dest='command', help="RCP command to use", required=True)
+ sys_argv_filter = {}
+ for module_descr in module_descrs:
+ cmd_descr = module_descr['cmd_descr']
+ for cmd in cmd_descr:
+ command_name = module_descr['name'] + "_" + cmd['name']
+ option_parser_cmd = option_subparsers.add_parser(command_name, help=cmd['help'])
+ sys_argv_filter[command_name] = []
+ for arg in cmd['args']:
+ arg['spec'] = pytype_to_type(arg['spec'])
+ option_parser_cmd.add_argument(arg['name'], **arg['spec'])
+ sys_argv_filter[command_name].append(arg['name'])
+
+ # Re-Parse commandline options with the completed commandline parser. In case commandline help is
+ # requested. The program is able to display the full help screen and exists.
+ opts = option_parser.parse_args()
+
+ # Filter the relevant command arguments from sys.argv
+ cmd_argv = []
+ next_is_value=False
+ for arg in sys.argv:
+ if arg in sys_argv_filter[opts.command]:
+ cmd_argv.append(arg)
+ next_is_value=True
+ elif next_is_value is True:
+ next_is_value=False
+ cmd_argv.append(arg)
+
+ # Run the command and close the connection
+ rc = await client.run(opts.command, cmd_argv)
+ await client.close()
+ return rc
+
+if __name__ == '__main__':
+
+ # Setup logging
+ PySimLogger.setup(print, {logging.WARN: "\033[33m", logging.DEBUG: "\033[90m"}, '--verbose' in sys.argv)
+
+ # Since parts of the commandline arguments are retrieved dynamically, we have to resolve a chicken-egg-problem.
+ # We cannot call option_parser.parse_args() at the beginning, since we haven't received all information to
+ # complete the option_parser yet. However in order to retrieve the arguments correctly we need to get the
+ # URI and the parameters for the smartcard reader before we make the connection. The situation is even further
+ # complicated in case the user requests commandline help.
+
+ # To resolve the problem we first check if the user needs basic help (no '--uri' parameter present). If this is the
+ # case, the program will exit with a basic help screen.
+ check_if_user_needs_basic_help(sys.argv)
+
+ # In all other cases we parse the arguments we know so far. In case the user requests commandline help, we will
+ # ignore this request and continue. The full help is then displayed later when the option_parser is completed
+ # afer we have requested the commandline argument descriptions from the RCP Server. (see below)
+ opts = parse_known_arguemnts(sys.argv)
+
+ # Load SSL/TLS CA certificate from file
+ if opts.ca_cert:
+ ssl_context = load_ca_cert("RCP Server CA", opts.ca_cert)
+ else:
+ ssl_context = None
+
+ # Initialize card reader
+ try:
+ sl = init_reader(opts)
+ sl.connect()
+ except Exception as e:
+ backtrace("Card reader initialization")
+ sys.exit(1)
+
+ # Run the RCP session
+ try:
+ rc = asyncio.run(run_rcp_session(opts, sl, ssl_context))
+ sys.exit(rc)
+ except SystemExit as rc:
+ sys.exit(rc)
+ except:
+ backtrace("RCP session")
+ sys.exit(1)
+
+
diff --git a/contrib/rcp/rcp_module_utils.py b/contrib/rcp/rcp_module_utils.py
new file mode 100644
index 00000000..5cd7b1bb
--- /dev/null
+++ b/contrib/rcp/rcp_module_utils.py
@@ -0,0 +1,424 @@
+#!/usr/bin/env python3
+
+# (C) 2026 by sysmocom - s.f.m.c. GmbH
+# All Rights Reserved
+#
+# Author: Philipp Maier
+#
+# 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 abc
+import os
+import argparse
+import logging
+import threading
+import asyncio
+import websockets
+from argparse import Namespace
+from copy import deepcopy
+from pathlib import Path
+from typing import Optional
+from osmocom.utils import Hexstr, is_hexstr
+from pySim.utils import ResTuple
+from pySim.transport import LinkBase
+from pySim.commands import SimCardCommands
+from pySim.log import PySimLogger
+from rcp_utils import SrvSyncConnHdlr, CltConnHdlr, backtrace, pytype_to_type, load_server_cert, load_ca_cert
+from rcp_utils import dict_from_key_value_pairs, load_json_schema, JsonValidator
+from rcp_server import RCPM_VERSION_PROTOCOL
+from websockets.sync.server import serve, ServerConnection
+from pySim.app import init_card
+from pySim.runtime import RuntimeState
+from pySim.cards import CardBase
+from pySim.card_key_provider import CardKeyFieldCryptor
+from packaging.version import Version
+
+# Response timeout towards the RCP Server (includes RCP Client latency)
+RCP_SERVER_TIMEOUT = 30 # sec.
+
+log = PySimLogger.get(Path(__file__).stem)
+
+class RcpsSimLink(LinkBase):
+ """
+ pySim: Transport Link for RCPM (Remote Card Procedure Module)
+ This is a 'headless' transport link implementation that can only be used from an RCPM module. It merely serves as
+ an adapter between the pySim transport API and the RCPM command server connection handler.
+ """
+
+ name = 'RCPM'
+
+ def __init__(self, conn_hdlr: SrvSyncConnHdlr, **kwargs):
+ self.conn_hdlr = conn_hdlr
+ self._atr = None
+ super().__init__(**kwargs)
+
+ def __str__(self) -> str:
+ return "rcpm:" + str(self.conn_hdlr)
+
+ def _send_apdu(self, apdu: Hexstr) -> ResTuple:
+ tx_json = {'rcps_instr': {'c_apdu' : apdu.upper()}}
+ rx_json = self.conn_hdlr._transact(tx_json)
+ data = rx_json['rcps_result']['r_apdu']['data']
+ sw = rx_json['rcps_result']['r_apdu']['sw']
+ return data, sw
+
+ def wait_for_card(self, timeout: Optional[int] = None, newcardonly: bool = False):
+ # In this setting, we do not have/cannot to wait for a card since we are not the entity that handles the
+ # direct connection to the card. When the procedure begins, we assume that the remote end already has set up
+ # a connection to the card and made it ready to perform operations on it.
+ pass
+
+ def connect(self):
+ # In this setting, we do not have/cannot to connect because we are not the entity that handles the direct
+ # connection to the card. The connection is established by the remote end.
+ pass
+
+ def get_atr(self) -> Hexstr:
+ return self._atr
+
+ def disconnect(self):
+ # In this setting, we do not have/cannot disconnect because we are not the entity that handles the direct
+ # connection to the card. The disconnect is eventually done by the remote end when the procedure has finished.
+ pass
+
+ def _reset_card(self):
+ tx_json = {'rcps_instr': {'reset' : None}}
+ rx_json = self.conn_hdlr._transact(tx_json)
+ self._atr = rx_json['rcps_result']['atr']
+ return 1
+
+class RcpsCltConnHdlr(CltConnHdlr):
+ """
+ The RCP Server client handler is used to connect to the RCP Server when RCP Module is started. The connection is
+ kept alive until the RCP Module is terminated. This connection is used to exchange management data with the RCP
+ Server.
+ """
+
+ def __init__(self, cmd_srv_addr: str, cmd_srv_port: int, module, *args, **kwargs):
+ self.cmd_srv_addr = cmd_srv_addr
+ self.cmd_srv_port = cmd_srv_port
+ self.module = module
+ super().__init__(*args, **kwargs)
+
+ async def check_version(self):
+ """
+ Send the Protocol and Software version of this RCP Module to the RCP Server. The RCP Server and the RCP Module
+ must always use the same protrocol version.
+ """
+ tx_json = {'rcpm_version': {'protocol' : RCPM_VERSION_PROTOCOL}}
+ rx_json = await self._transact(tx_json)
+ rcpm_version_protocol = Version(rx_json['rcpm_version']['protocol'])
+ if Version(RCPM_VERSION_PROTOCOL) != rcpm_version_protocol:
+ raise ValueError("Incompatible protocol version %s != %s", Version(RCPM_VERSION_PROTOCOL), rcpm_version_protocol)
+
+ async def describe(self):
+ """
+ Send a detailed description about this RCP Module to the RCP Server. This is also the initial message that
+ the RCP Server expects when an RCP Module connects.
+ """
+
+ # The rules (dict) in suitable_for (array of dict) may contain hexstrings. Here we go through those rules
+ # and convert those hexstrings to uppercase, since this is the standard we have set for the JSON messages.
+ suitable_for = []
+ for rule in self.module.suitable_for:
+ rule_filtered = {}
+ for k in rule:
+ if is_hexstr(rule[k]):
+ rule_filtered[k] = rule[k].upper()
+ else:
+ rule_filtered[k] = rule[k]
+ suitable_for.append(rule_filtered)
+
+ # Publish RCP Module description on the RCP server
+ tx_json = {'rcpm_hello':
+ {'name' : self.module.name,
+ 'cmd_descr' : self.module.cmd_descr,
+ 'suitable_for' : suitable_for,
+ 'addr' : self.cmd_srv_addr,
+ 'port' : self.cmd_srv_port
+ }
+ }
+ rx_json = await self._transact(tx_json)
+ if 'rcpm_welcome' not in rx_json:
+ raise ValueError("description not accepted by RCP Server")
+
+class RcpModule(abc.ABC):
+ """
+ Base class to implement to derive a concrete RCP module class
+ """
+
+ # Module name used to identify the module in logs and user output. This module name should be short and concise.
+ name = "RCPM"
+
+ # Command description of this module. The command description consists of a short and concise command name, a
+ # helpstring and an argument specification in the form of a python dict. This specification, consisting of
+ # 'name', 'help', and 'args' is is directly passed to agparse on the client side.
+ #
+ # In addition to that, the API user may specify which keys the RCP Server shall retrieve before a command is
+ # executed. This is done via the 'get_keys' field. This field is optional and has the form of a dict with
+ # two optional fields 'uicc' and 'euicc'. The value part of both fields is a list of strings which name the
+ # columns that are passed to the CardKeyProvider for lookup. When the 'uicc' field is set, then the RCP Server
+ # will automatically request the ICCID from the card and do the lookup. When the 'euicc' field is set, the RCP
+ # Server will do the same with the EID. It is possible to mix both fields to request keys for the eUICC and the
+ # currently activated eSIM profile at the same time. However, this may be a very rare corner case.
+ #
+ # Example:
+ # cmd_descr = [{'name' : 'reset',
+ # 'help': 'reset the card',
+ # 'args' : []},
+ # {'name' : 'read_binary',
+ # 'help': 'read binary data from a transparent file.',
+ # 'args' : [{ 'name' : '--fid',
+ # 'spec' : {'required' : True,
+ # 'help' : 'File identifier to of the file to read',
+ # 'action' : 'append',
+ # 'pytype' : 'str'},
+ # }
+ # ]},
+ # {'name' : 'unlock_aram',
+ # 'help': 'unlock a locked ARA-M applet on a sysmoISIM-SJA5',
+ # 'args' : [],
+ # 'get_keys' : {'uicc' : ['KIC', 'KID', 'KIK']}}
+ # ]
+ cmd_descr = []
+
+ # Card properties to determine if this module is suitable for a specific card type or card types. The RCP Server
+ # will match those properties against user requests to determine which module provides useful services to the
+ # user's card.
+ #
+ # Example: [{"atr" : "3b9f96803f87828031e073fe211f574543753130136502"}]
+ suitable_for = []
+
+ # In addition the above, the derived class must implement command methods for each command that is defined in the
+ # command description (see above). Each command method must begin with the prefix "cmd_" followed by the command
+ # name used in the command description. A command method must have the form as shown in the example shown below.
+ # Each method should return an integer value which will become the final return code of the RCP client program.
+ #
+ # Args:
+ # hdlr: RcpModuleHdlr object, this object is provided by the RcpmCmdSrvConnHdlr object, which calls
+ # the command method of the module. Through the RcpModuleHdlr object, the API user gets access
+ # to special service methods (e.g. print) and other required properties (e.g. the SimCardCommands
+ # objects, key material and others (see RcpModuleHdlr).
+ #
+ # Example:
+ # def cmd_reset(self, hdlr: RcpModuleHdlr) -> int: ...
+ # def cmd_read_binary(self, hdlr: RcpModuleHdlr) -> int: ...
+ # def cmd_unlock_aram(self, hdlr: RcpModuleHdlr) -> int: ...
+
+ # When the RCP Module class is passed to rcpm_run_module(), rcpm_run_module() also accepts *args and **kwargs
+ # parameter. Those parameters are passed to the constructor of RCP Module class when it is instaniated by
+ # rcpm_run_module(). API may override this constructor (below) with a custom implementation, if required.
+ def __init__(self, *args, **kwargs):
+ pass
+
+class RcpmCmdSrvConnHdlr(SrvSyncConnHdlr):
+ """
+ The RCP Module command server connection handler is used to handle dedicated connections from the RCP Server. Those
+ dedicated connections are technically transparent connections between the RCP Client and the RCP Module (this). The
+ RCP Server merely acts as a proxy at that point.
+ """
+
+ def __init__(self, module: RcpModule, field_cryptor: CardKeyFieldCryptor, *args, **kwargs):
+ SrvSyncConnHdlr.__init__(self, *args, *kwargs)
+ self.module = module
+ self.crypt = field_cryptor
+
+ def _parse_cmd_argv(self, cmd_suffix: str, cmd_argv: list[str]) -> Namespace:
+ """ Parse (and validate) the received argument vector """
+ # Use the cmd_descr of the module to create a (temporary) argument parser for the received argument vector.
+ cmd_parser = argparse.ArgumentParser()
+ for cmd in self.module.cmd_descr:
+ if cmd['name'] == cmd_suffix:
+ args = deepcopy(cmd['args'])
+ for arg in args:
+ arg['spec'] = pytype_to_type(arg['spec'])
+ cmd_parser.add_argument(arg['name'], **arg['spec'])
+
+ # Parse the arguments and return the parsed Namespace object.
+ try:
+ return cmd_parser.parse_args(cmd_argv)
+ except SystemExit:
+ raise ValueError("unable to parse arguments: %s", str(cmd_argv), )
+
+ def print(self, message: str):
+ """ Print a message on the client side """
+ log.info(str(self) + " -- %s" % message)
+ tx_json = {'rcps_instr': {'print' : message}}
+ rx_json = self._transact(tx_json)
+ if rx_json != {'rcps_result': {'empty' : None}}:
+ raise ValueError("unexpected response from RCP Client: %s", rx_json)
+
+ def procedure(self):
+ """ Receive and process a command from the RCP Client (via RCP Server) """
+
+ # Receive the command request.
+ rx_json = self._recv()
+ cmd = rx_json['rcps_command']['cmd']
+ cmd_argv = rx_json['rcps_command']['cmd_argv']
+ keys = rx_json['rcps_command'].get('keys')
+ log.info(str(self) + " -- executing command: %s %s", cmd, str(cmd_argv))
+
+ try:
+ # Make sure the command actually addresses this module.
+ cmd_prefix = self.module.name + "_"
+ if not cmd.startswith(cmd_prefix):
+ raise ValueError("invalid command: %s" % cmd)
+
+ # Make sure the module actually provides a command method for the requested command.
+ cmd_suffix = cmd[len(cmd_prefix):]
+ cmd_method = "cmd_" + cmd_suffix
+ if not hasattr(self.module, cmd_method):
+ raise ValueError("missing command method: %s" % cmd_method)
+
+ # Parse and validate command arguments.
+ cmd_args = self._parse_cmd_argv(cmd_suffix, cmd_argv)
+
+ # Setup a pySim RuntimeState, CardBase and a RuntimeLchan.
+ rs, card = init_card(RcpsSimLink(self))
+
+ # Hand over control to the command method provided by the specific module implementation.
+ rcp_module_hdlr = RcpModuleHdlr(self.print, rs, card, cmd_args, keys, self.crypt)
+ rs.reset()
+ try:
+ rc = getattr(self.module, cmd_method)(rcp_module_hdlr)
+ except Exception as e:
+ backtrace("command method")
+ rc = 1 # general error
+
+ except Exception as e:
+ backtrace("command parsing")
+ rc = 126 # cannot execute
+
+ # The prodedure is done, send "goodbye" message.
+ log.info(str(self) + " -- command execution done, rc: %d" % rc)
+ tx_json = {'rcps_goodbye': rc}
+ self._send(tx_json)
+
+class RcpModuleHdlr():
+ """
+ RCP Module handler class. This class is used by the RcpmCmdSrvConnHdlr to create the handler RcpModuleHdlr object
+ (hdlr), which is is passed to the command method. The RcpModuleHdlr gives the API user access to resources he can
+ use carry out the command.
+ """
+
+ # The RuntimeState (rs), the CardBase (card) and the RuntimeLchan (lchan) are the three major objects through which
+ # an API user may interact with the UICC/eUICC on the other remote end. Those objects have the same objectives as
+ # in pySim-shell.py, with lchan representing the currently selected lchan (set to self.rs.lchan[0] by default, API
+ # users may change the reference to a different lchan)
+ rs = None
+ card = None
+ lchan = None
+
+ # The cmd_args property contains the parsed command arguments which were passed by the end-user to the RCP Client.
+ # The arguments are already parsed and validated against the cmd_dscr property of the RcpModule. The arguments are
+ # in the form of a Namespace object and can be accessed like any argparse output. However, since the arguments
+ # contain user input, some caution is required.
+ cmd_args = None
+
+ # In case the retrieve_uicc_keys property of the RcpModule is used retrieve UICC key material, this property will
+ # contain the key material in the form of a dictionary. The format is similar to the return value of
+ # card_key_provider_get() (see also pySim.card_key_provider).
+ keys_uicc = {}
+
+ # Same as self.keys_uicc, but contains eUICC related key material in case requested using retrieve_uicc_keys.
+ keys_euicc = {}
+
+ def __init__(self, print: callable, rs: RuntimeState, card: CardBase, cmd_args: Namespace,
+ keys: dict, field_cryptor: CardKeyFieldCryptor):
+ self.print = print
+ self.rs = rs
+ self.card = card
+ self.lchan = self.rs.lchan[0]
+ self.cmd_args = cmd_args
+ if keys:
+ if 'uicc' in keys:
+ self.keys_uicc = dict_from_key_value_pairs(keys['uicc'], keylabel='key', valuelabel='value')
+ for key in self.keys_uicc.keys():
+ self.keys_uicc[key] = field_cryptor.decrypt_field(key, self.keys_uicc.get(key))
+ if 'euicc' in keys:
+ self.keys_euicc = dict_from_key_value_pairs(keys['euicc'], keylabel='key', valuelabel='value')
+ for key in self.keys_euicc.keys():
+ self.keys_euicc[key] = field_cryptor.decrypt_field(key, self.keys_euicc.get(key))
+
+def rcpm_setup_argparse(description: str):
+ """Create argument parser and add the basic arguments all RCP Modules should have"""
+
+ option_parser = argparse.ArgumentParser(description='RCP Module: ' + description,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+ option_parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
+ option_parser.add_argument("--uri", help="URI of the RCP-Server", required=True)
+ option_parser.add_argument("--rcps-ca-cert", help="SSL/TLS CA-Certificate of the RCP-Server", required=True)
+ option_parser.add_argument("--rcpm-cmd-server-addr", help="Local Host/IP to bind RCP-Module-Command-Server to",
+ required=True)
+ option_parser.add_argument("--rcpm-cmd-server-port", help="Local TCP port to bind RCP-Module-Command-Server to",
+ required=True, type=int)
+ option_parser.add_argument("--rcpm-cmd-server-cert", help="SSL/TLS Certificate of the RCP-Module-Command-Server",
+ required=True)
+ CardKeyFieldCryptor.argparse_add_args(option_parser)
+ return option_parser
+
+def rcpm_run_module(opts: Namespace, module: RcpModule, *args, **kwargs):
+
+ PySimLogger.setup(print, {logging.WARN: "\033[33m", logging.DEBUG: "\033[90m"}, opts.verbose)
+ log.info("RCP Module startup: %s", module.name)
+ log.debug("Main process ID: %d", os.getpid())
+
+ # Load SSL/TLS certificates.
+ rcpm_cmd_ssl_context = load_server_cert("RCPM Command Server", opts.rcpm_cmd_server_cert)
+ ssl_context = load_ca_cert("RCPM Server Client", opts.rcps_ca_cert)
+
+ # Load JSON schema for message validation between RCP Server and RCP Module (this process)
+ rcpm_to_rcps_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(), "rcpm_to_rcps_schema.json"))
+ rcps_to_rcpm_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(), "rcps_to_rcpm_schema.json"))
+
+ # Load JSON schema for message validation between RCP Server and RCP Module Command Server (this process)
+ rcpmcs_to_rcps_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(), "rcpmcs_to_rcps_schema.json"))
+ rcps_to_rcpmcs_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(), "rcps_to_rcpmcs_schema.json"))
+
+ # Start local RCP Client Command Server.
+ log.info("RCPC command server at: %s:%d" % (opts.rcpm_cmd_server_addr, opts.rcpm_cmd_server_port))
+ def rcpm_cmd_conn_hdlr(websocket: ServerConnection):
+ json_validator = JsonValidator(rcps_to_rcpmcs_schema, rcpmcs_to_rcps_schema)
+ transport_keys = CardKeyFieldCryptor.transport_keys_from_opts(opts)
+ field_cryptor = CardKeyFieldCryptor(transport_keys)
+ hdlr = RcpmCmdSrvConnHdlr(module(*args, *kwargs), field_cryptor, websocket, RCP_SERVER_TIMEOUT, json_validator)
+ hdlr.procedure()
+ hdlr.close()
+
+ server = serve(rcpm_cmd_conn_hdlr, opts.rcpm_cmd_server_addr, opts.rcpm_cmd_server_port, ssl=rcpm_cmd_ssl_context)
+ def rcpm_cmd_server():
+ log.debug("RCPC command server thread ID: %d", threading.get_native_id())
+ server.serve_forever()
+ rcpm_cmd_server_thread = threading.Thread(target = rcpm_cmd_server)
+ rcpm_cmd_server_thread.start()
+
+ # Connect to RCP Server and publish module description.
+ async def rcps_client():
+ async with websockets.connect(opts.uri, ping_timeout=10.0, ping_interval=1.0, ssl=ssl_context) as websocket:
+ json_validator = JsonValidator(rcps_to_rcpm_schema, rcpm_to_rcps_schema)
+ client = RcpsCltConnHdlr(opts.rcpm_cmd_server_addr, opts.rcpm_cmd_server_port, module, websocket,
+ RCP_SERVER_TIMEOUT, json_validator)
+ await client.check_version()
+ await client.describe()
+ await client.wait_close()
+ try:
+ asyncio.run(rcps_client())
+ except Exception as e:
+ backtrace("RCPS client")
+
+ # Shutdown
+ server.shutdown()
+ rcpm_cmd_server_thread.join()
+ log.info("RCP Module shutdown: %s", module.name)
diff --git a/contrib/rcp/rcp_server.py b/contrib/rcp/rcp_server.py
new file mode 100755
index 00000000..2e959c97
--- /dev/null
+++ b/contrib/rcp/rcp_server.py
@@ -0,0 +1,666 @@
+#!/usr/bin/env python3
+
+# (C) 2026 by sysmocom - s.f.m.c. GmbH
+# All Rights Reserved
+#
+# Author: Philipp Maier
+#
+# 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 os
+import sys
+import argparse
+import asyncio
+import logging
+import time
+import requests
+import json
+import websockets
+from osmocom.utils import Hexstr
+from pySim.utils import ResTuple
+from copy import deepcopy
+from pathlib import Path
+from pySim.log import PySimLogger
+from pySim.utils import dec_iccid
+from websockets.asyncio.server import serve, ServerConnection
+from rcp_utils import SrvConnHdlr, CltConnHdlr, JsonValidator, FlightRecorder
+from rcp_utils import load_json_schema, backtrace, pytype_to_type, load_server_cert, load_ca_cert
+from rcp_utils import key_value_pairs_from_dict
+from pySim.card_key_provider import card_key_provider_argparse_add_args, card_key_provider_init
+from pySim.card_key_provider import card_key_provider_get_field, card_key_provider_get
+from packaging.version import Version
+
+CLIENT_TIMEOUT = 10
+
+# The protocol version between the RCP Server and the RCP Module must always match up. In case there as changes to
+# the protocol (JSON Schema and/or application logic). This version number shall be incremented accordingly. Since
+# RCP Modules usually run from the same pySim modules as the RCP Server, a change to this version number should
+# not affect the RCP Module implementation itself.
+RCPM_VERSION_PROTOCOL = "1.0.0"
+
+# The RCP Server software version shall be incremented when there are changes to the RCP Sever (this module) or changes
+# to other related modules, which affect the RCP Server. The RCP Server software version is also disclosed towards the
+# RCP Client.
+RCPS_VERSION_SOFTWARE = "1.0.0"
+
+# The RCP Server protocol version refers to the protocol spoken between RCP Client and RCP Server. The protocol version
+# shall be incremented when there are changes to the protocol (JSON Schema and/or application logic). When an
+# RCP Client connects, this protocol version is compared against the protocol version that the client sends
+# (see also RCPC_VERSION_PROTOCOL in rcp_client.py). It is up to the RCP Server to decide whether or not a deviation
+# between protocol versions is tolerable or not.
+RCPS_VERSION_PROTOCOL = "1.0.0"
+
+log = PySimLogger.get(Path(__file__).stem)
+runtime_state = None
+rate_limiter = None
+option_parser = argparse.ArgumentParser(description='RCP Server',
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+option_parser.add_argument("--verbose", help="Enable verbose logging",
+ action='store_true', default=False)
+option_parser.add_argument("--rcpc-server-addr", help="Local Host/IP to bind RCP-Client-Server to",
+ required=True)
+option_parser.add_argument("--rcpc-server-port", help="Local TCP port to bind RCP-Client-Server to",
+ required=True, type=int)
+option_parser.add_argument("--rcpc-server-cert", help="SSL/TLS Certificate of the RCP-Client-Server",
+ required=True)
+option_parser.add_argument("--rcpc-request-limit", help="number of RCP Client requests per minute",
+ default=600)
+option_parser.add_argument("--rcpm-server-addr", help="Local Host/IP to bind RCP-Module-Server to",
+ required=True)
+option_parser.add_argument("--rcpm-server-port", help="Local TCP port to bind RCP-Module-Server to",
+ required=True, type=int)
+option_parser.add_argument("--rcpm-server-cert", help="SSL/TLS Certificate of the RCP-Module-Server",
+ required=True)
+option_parser.add_argument("--rcpm-module-ca-cert", help="SSL/TLS CA-Certificate of the RCP-Module-Command-Server",
+ required=True)
+option_parser.add_argument("--open-observe-url", help="OpenObserve API endpoint URL")
+option_parser.add_argument("--open-observe-email", help="OpenObserve service email address")
+option_parser.add_argument("--open-observe-token", help="OpenObserve service token")
+
+card_key_provider_argparse_add_args(option_parser)
+
+class ModuleRuntimeState:
+ def __init__(self, websocket:ServerConnection, name:str, cmd_descr:list, suitable_for:list, addr:str, port:int):
+ self.name = name
+ self.websocket = websocket
+
+ # Run the cmd_descr through argparse to catch malformed argument specifications early
+ for cmd in cmd_descr:
+ args = deepcopy(cmd['args'])
+ cmd_parser = argparse.ArgumentParser()
+ for arg in args:
+ try:
+ arg['spec'] = pytype_to_type(arg['spec'])
+ cmd_parser.add_argument(arg['name'], **arg['spec'])
+ except:
+ raise ValueError("invalid argument spec %s -- check RCP Module" % str(arg))
+
+ self.cmd_descr = cmd_descr
+ self.suitable_for = suitable_for
+ self.addr = addr
+ self.port = port
+ log.debug("new RCP Module context created: '%s'", name)
+
+ def is_suitable(self, suitable_for:dict) -> bool:
+ """Check if this module is 'suitable_for' a specific card"""
+ if suitable_for in self.suitable_for:
+ return True
+ return False
+
+ def describe(self) -> dict:
+ """Describe this module towards the RCP Client"""
+
+ # The command description sent by the RCP Module also includes fields that are intended to be seen
+ # only by the RCP Server. Here we set up the command description as it is expected by the RCP Client.
+ cmd_descr = []
+ for descr in self.cmd_descr:
+ cmd_descr.append({'name' : descr['name'],
+ 'help' : descr['help'],
+ 'args' : descr['args']})
+
+ # Return module description
+ return {'name': self.name,
+ 'cmd_descr': cmd_descr}
+
+ def get_cmd_descr(self, cmd: str) -> dict:
+ """Get the description for a specific command of this module"""
+ for descr in self.cmd_descr:
+ if self.name + "_" + descr['name'] == cmd:
+ return descr
+ raise ValueError("command %s not found in command description %s" % (cmd_name, str(self.cmd_descr)))
+
+ def __str__(self) -> str:
+ return self.name
+
+ def __del__(self):
+ log.debug("RCP module context destroyed: '%s'", self.name)
+
+class RuntimeState:
+ def __init__(self, rcpm_ca_ssl_context, open_observe_pars):
+ self.module_runtime_states = []
+ self.rcpm_ca_ssl_context = rcpm_ca_ssl_context
+ self.open_observe_pars = open_observe_pars
+
+ # Load JSON schema for message validation between RCP Client and RCP Server (this process)
+ self.rcpc_to_rcps_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(),
+ "rcpc_to_rcps_schema.json"))
+ self.rcps_to_rcpc_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(),
+ "rcps_to_rcpc_schema.json"))
+
+ # Load JSON schema for message validation between RCP Module and RCP Server (this process)
+ self.rcpm_to_rcps_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(),
+ "rcpm_to_rcps_schema.json"))
+ self.rcps_to_rcpm_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(),
+ "rcps_to_rcpm_schema.json"))
+
+ # Load JSON schema for message validation between RCP Module Command Server and RCP Server (this process)
+ self.rcpmcs_to_rcps_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(),
+ "rcpmcs_to_rcps_schema.json"))
+ self.rcps_to_rcpmcs_schema = load_json_schema(os.path.join(Path(__file__).parent.resolve(),
+ "rcps_to_rcpmcs_schema.json"))
+
+ log.debug("new runtime context created.")
+
+ def __log_modules_available(self) -> str:
+ if self.module_runtime_states:
+ modules_str = ""
+ for module in self.module_runtime_states:
+ modules_str += "'" + str(module) + "', "
+ return "RCP modules available: %s" % modules_str[:-2]
+ else:
+ return "RCP modules available: none"
+
+ def module_add(self, module: ModuleRuntimeState):
+ self.module_runtime_states.append(module)
+ log.info("new RCP module, %s", self.__log_modules_available())
+
+ def module_remove(self, websocket:ServerConnection):
+ for module in self.module_runtime_states:
+ if module.websocket == websocket:
+ self.module_runtime_states.remove(module)
+ log.info("RCP module removed, %s", self.__log_modules_available())
+ return
+ log.warning("cannot remove RCP module, no RCP module associated with RCPC connection: %s:%d, %s" %
+ (*websocket.remote_address, self.__log_modules_available()))
+
+ def modules_find(self, suitable_for:dict) -> list[dict]:
+ modules = []
+ for module in self.module_runtime_states:
+ if module.is_suitable(suitable_for):
+ modules.append(module.describe())
+ if modules:
+ return modules
+ # It is absolutely tolerable if no suitable RCP module can be found. If this is the case, the client should
+ # display an empty help screen and exit normally.
+ log.warning("no suitable RCP module found, %s", self.__log_modules_available())
+ return []
+
+ def module_find(self, suitable_for:dict, cmd:str) -> ModuleRuntimeState:
+ modules = self.modules_find(suitable_for)
+ for m in modules:
+ module_name = m['name']
+ cmd_descr = m['cmd_descr']
+ for c in cmd_descr:
+ cmd_name = c['name']
+ if module_name + "_" + cmd_name == cmd:
+ break
+ for module_runtime_state in self.module_runtime_states:
+ if module_runtime_state.name == module_name:
+ return module_runtime_state
+ # Normally we should find the RCP module. When this method is called, we have already called modules_find
+ # before because we had to return the command descriptions to the client. If we cannot find the RCP module
+ # now, the module have been disconnected or the client somehow called a command that does not exist. In any
+ # case, ending up here means we cannot continue.
+ raise ValueError("RCP module not found for command: %s, " % (cmd, self.__log_modules_available()))
+
+class RcpmCltConnHdlr(CltConnHdlr):
+ """
+ The RCP Module client connection handler is the dedicated client that is used by the RCP Client connection handler
+ to handle the dedicated connection towards the RCP Module (see below)
+ """
+
+class RcpcSrvConnHdlr(SrvConnHdlr):
+ """
+ The RCP Client connection handler takes care of the handling of client requests. Throughout the lifetime of a
+ connection, the client will request a description of the available commands and then request the execution of a
+ procedure. To execute the procedure, the handler will make a dedicated connection to the RCP Module and then
+ transparently pass the messages from the RCP Client to the RCP Module and vice versa.
+ """
+
+ module_client = None
+
+ async def check_version(self):
+ """
+ Check the RCP Client software and protocol version to ensure the requesting RCP Client is compatible with this
+ RCP Server version.
+ """
+
+ # Receive version info from RCP client
+ rx_json = await self._recv()
+ rcpc_version_software = Version(rx_json['rcpc_version']['software'])
+ rcpc_version_protocol = Version(rx_json['rcpc_version']['protocol'])
+ log.debug("RCP Client version: software=%s, protocol=%s",
+ rcpc_version_software, rcpc_version_protocol)
+ if self.flight_recorder:
+ self.flight_recorder.record_meta('rcpc_version_software', str(rcpc_version_software))
+ self.flight_recorder.record_meta('rcpc_version_protocol', str(rcpc_version_protocol))
+
+ # Check if the RCP Client is compatible with this RCP Server. As of now we expect that the client uses the
+ # exact same protocol version as the server.
+ rcpc_version_protocol_expected = Version(RCPS_VERSION_PROTOCOL)
+ if rcpc_version_protocol != rcpc_version_protocol_expected:
+ info = "RCP Client uses unsupported protocol version (%s != %s)" % (rcpc_version_protocol, rcpc_version_protocol_expected)
+ raise_exception = True
+ else:
+ info = None
+ raise_exception = False
+
+ # Respond with RCP Server version info. We do this before we potentially raise an exception to make sure the
+ # RCP Server version info arrives at the client.
+ tx_json = {'rcpc_version': {'software' : RCPS_VERSION_SOFTWARE,
+ 'protocol' : RCPS_VERSION_PROTOCOL}}
+ if info:
+ tx_json['rcpc_version']['info'] = info
+ await self._send(tx_json)
+
+ # Raise exception in case problems were detected. This will close the connection, but the client still has the
+ # version info (see above)
+ if raise_exception:
+ raise ValueError(info)
+
+ async def describe(self):
+ """
+ Collect the command/argument description of suitable modules and forward that definition to the RCP client. The
+ RCP client will then build an argument parser (commandline help, argument validation) from this information.
+ """
+ rx_json = await self._recv()
+ self.suitable_for = rx_json['rcpc_hello']['suitable_for']
+ if self.flight_recorder:
+ self.flight_recorder.record_meta('suitable_for', self.suitable_for)
+ modules = runtime_state.modules_find(self.suitable_for)
+ if self.flight_recorder:
+ suitable_modules = []
+ for m in modules:
+ suitable_modules.append(m['name'])
+ self.flight_recorder.record_meta('suitable_modules', suitable_modules)
+ tx_json = {'rcpc_welcome':
+ {'module_descr' : modules}
+ }
+ await self._send(tx_json)
+
+ async def _transact_apdu(self, apdu: Hexstr) -> ResTuple:
+ """Private low level method to exchange an APDU"""
+ tx_json = {'rcpc_instr': {'c_apdu' : apdu.upper()}}
+ rx_json = await self._transact(tx_json)
+ if rx_json is None:
+ raise ValueError("RCP Client vanished unexpectetly")
+ data = rx_json['rcpc_result']['r_apdu']['data']
+ sw = rx_json['rcpc_result']['r_apdu']['sw']
+ return data, sw
+
+ async def _reset(self) -> Hexstr:
+ """Private low level method to reset the UICC/eUICC"""
+ tx_json = {'rcpc_instr': {'reset' : None}}
+ rx_json = await self._transact(tx_json)
+ if rx_json is None:
+ raise ValueError("RCP Client vanished unexpectetly")
+ return rx_json['rcpc_result']['atr']
+
+ async def _read_iccid(self) -> Hexstr:
+ """Private low level method to read the EID from an UICC (or eSIM)"""
+ data, sw = await self._transact_apdu("00A40000022FE200")
+ if sw != "9000":
+ raise ValueError("Unable to select EF.ICCID, sw: %s, " % sw)
+ data, sw = await self._transact_apdu("00B000000A")
+ if sw != "9000":
+ raise ValueError("Unable to read EF.ICCID, sw: %s, " % sw)
+ return dec_iccid(data)
+
+ async def _read_eid(self) -> Hexstr:
+ """Private low level method to read the EID from an eUICC"""
+ data, sw = await self._transact_apdu("00A4040410A0000005591010FFFFFFFF890000010000")
+ if sw != "9000":
+ raise ValueError("Unable to select ISD-R, sw: %s, " % sw)
+ data, sw = await self._transact_apdu("80E2910006BF3E035C015A00")
+ if sw != "9000":
+ raise ValueError("Unable to retrieve EID, sw: %s, " % sw)
+ return data[10:]
+
+ async def print(self, message: str):
+ """ Print a message on the client side """
+ tx_json = {'rcpc_instr': {'print' : message}}
+ rx_json = await self._transact(tx_json)
+ if rx_json is None:
+ raise ValueError("RCP Client vanished unexpectedly")
+ if rx_json != {'rcpc_result': {'empty' : None}}:
+ raise ValueError("unexpected response from RCP Client: %s" % rx_json)
+
+ async def procedure(self):
+ """
+ Receive a command from the client, pick a matching module, make a dedicated connection to that module and
+ forward instruction/response messages between RCP Client and RCP Module until the procedure is done.
+ """
+ # Receive a command from the client.
+ rx_json = await self._recv()
+
+ # The procedure step is not mandatory. In case no procedure shall be executed, the client may close the
+ # connection early on his behalf. This is normal behavior and usually the case when the user instructs the
+ # RCP client to display the commandline help screens.
+ if rx_json is None:
+ log.debug(str(self) + " -- RCP client has closed the connection, no procedure executed")
+ return
+
+ # The RCP client has sent a command, so we continue with the procedure.
+ command = rx_json['rcpc_command']
+ if self.flight_recorder:
+ self.flight_recorder.record_meta('cmd', command['cmd'])
+ self.flight_recorder.record_meta('cmd_argv', command['cmd_argv'])
+
+ # Pick the matching RCP Module
+ module = runtime_state.module_find(self.suitable_for, command['cmd'])
+ if self.flight_recorder:
+ self.flight_recorder.record_meta('module', module.name)
+
+ # Retrieve keys (if the command requires them)
+ cmd_descr = module.get_cmd_descr(command['cmd'])
+ get_keys = cmd_descr.get('get_keys')
+ if get_keys:
+ keys = {}
+ get_keys_uicc = get_keys.get('uicc')
+ if get_keys_uicc:
+ iccid = await self._read_iccid()
+ if self.flight_recorder:
+ self.flight_recorder.record_meta('iccid', iccid)
+ keys_uicc = card_key_provider_get(get_keys_uicc, 'ICCID', iccid)
+ keys['uicc'] = key_value_pairs_from_dict(keys_uicc, keylabel='key', valuelabel='value')
+ get_keys_euicc = get_keys.get('euicc')
+ if get_keys_euicc:
+ eid = await self._read_eid()
+ if self.flight_recorder:
+ self.flight_recorder.record_meta('eid', eid)
+ keys_euicc = card_key_provider_get(get_keys_euicc, 'EID', eid)
+ keys['euicc'] = key_value_pairs_from_dict(keys_euicc, keylabel='key', valuelabel='value')
+ command['keys'] = keys
+
+ # Resetting card to ensure the card is in a defined state
+ await self._reset()
+
+ # Create a dedicated connection to the RCP Module and proxy the messages between RCP Client and RCP Module.
+ module_uri = "wss://%s:%d" % (module.addr, module.port)
+ log.info(str(self) + " -- executing procedure for command \"%s\" on module \"%s\" at: %s" %
+ (command['cmd'], module.name, module_uri))
+ async with websockets.connect(module_uri, ssl=runtime_state.rcpm_ca_ssl_context) as websocket:
+ # Create a connection to the RCP Module Command Server
+ json_validator = JsonValidator(runtime_state.rcpmcs_to_rcps_schema, runtime_state.rcps_to_rcpmcs_schema)
+ self.module_client = RcpmCltConnHdlr(websocket, CLIENT_TIMEOUT, json_validator, self.flight_recorder)
+
+ # Prepare initial request to be send to the RCP Module Command Server
+ module_tx_json = {'rcps_command' : command}
+
+ # Forward messages between RCP Module Command Server and RCP Client until the procedure ends.
+ while(True):
+ # Send request to the RCP Module Command Server
+ module_rx_json = await self.module_client._transact(module_tx_json)
+
+ # Forward the response to the RCP Client
+ if 'rcps_instr' in module_rx_json:
+ client_tx_json = {'rcpc_instr' : module_rx_json['rcps_instr']}
+ await self._send(client_tx_json)
+ elif 'rcps_goodbye' in module_rx_json:
+ rc = module_rx_json['rcps_goodbye']
+ log.info(str(self) + " -- command execution done, rc: %d" % rc)
+ if self.flight_recorder:
+ self.flight_recorder.record_meta('rc', rc)
+ if rc != 0:
+ self.flight_recorder.crash_report()
+ client_tx_json = {'rcpc_goodbye' : rc}
+ await self._send(client_tx_json)
+ break
+ else:
+ raise ValueError("Unexpected response from RCP Module: %s" % str(module_rx_json))
+
+ # Receive the Result from the client, prepare request (module_tx_json) for the next turn
+ client_rx_json = await self._recv()
+ if client_rx_json is None:
+ raise ValueError("RCP client vanished unexpectedly")
+ if 'rcpc_result' in client_rx_json:
+ module_tx_json = {'rcps_result' : client_rx_json['rcpc_result']}
+ else:
+ raise ValueError("Unexpected result from RCP Client: %s" % str(client_rx_json))
+
+ async def close(self):
+ """
+ Close the connection towards the RCP Module Command Server, then close the connection towards the RCP Client.
+ """
+ if self.module_client:
+ await self.module_client.close()
+ await super().close()
+
+class RcpmSrvConnHdlr(SrvConnHdlr):
+ """
+ The RCP Module connection handler is responsible to handle connect and disconnect events of RCP Modules. This
+ connection between the RCP Module and the RCP Server is used for management purposes only.
+ """
+
+ async def check_version(self):
+ """
+ Send the Protocol and Software version of this RCP Module to the RCP Server. The RCP Server and the RCP Module
+ must always use the same protocol version.
+ """
+ tx_json = {'rcpm_version': {'protocol' : RCPM_VERSION_PROTOCOL}}
+ rx_json = await self._transact(tx_json)
+ rcpm_version_protocol = Version(rx_json['rcpm_version']['protocol'])
+ if Version(RCPM_VERSION_PROTOCOL) != rcpm_version_protocol:
+ raise ValueError("Incompatible protocol version %s != %s", Version(RCPM_VERSION_PROTOCOL), rcpm_version_protocol)
+
+ async def describe(self):
+ """
+ Receive the module description from an RCP Module. This description will be stored in an internal list until
+ the module is disconnected from the server.
+ """
+ rx_json = await self._recv()
+ runtime_state.module_add(module = ModuleRuntimeState(self.websocket, **rx_json['rcpm_hello']))
+ tx_json = {'rcpm_welcome': None}
+ await self._send(tx_json)
+
+ def __del__(self):
+ """
+ Remove RCPM from internal list when the connection is closed (and the handler is deleted)
+ """
+ runtime_state.module_remove(self.websocket)
+ super().__del__()
+
+class RateLimiter():
+ """
+ Rate limiter: A rate limiter can be used to limit the amount of requests
+ per interval. Once the interval expires, the request counter is reset and
+ the requestor gets a new request budget to spend.
+ """
+
+ def __init__(self, interval:int, requests:int):
+ """
+ Args:
+ interval: reset interval after which request counter is reset.
+ requests: maximum number of requests per interval.
+ Returns:
+ True when rate limit has been exceeded, False otherwise.
+ """
+ self.table = {}
+ self.interval = interval
+ self.requests = requests
+ self.last_collect = time.time()
+ log.info("Rate-Limit: max %d requests per sec.", self.requests / self.interval)
+
+ def __collect_expired(self):
+ new_table = {}
+ for key in self.table.keys():
+ if time.time() - self.table[key]['timestamp'] <= self.interval:
+ new_table[key] = self.table[key]
+ self.table = new_table
+
+ def limit(self, address:str) -> bool:
+ """
+ Rate limit request
+
+ Args:
+ address: requestor address
+ Returns:
+ True when rate limit has been exceeded, False otherwise
+ """
+
+ timestamp = time.time()
+
+ # Collect expired entries once per minute
+ if time.time() - self.last_collect > 60:
+ self.__collect_expired()
+ self.last_collect = timestamp
+
+ # In case no entry exists yet, create a new one => don't block
+ if address not in self.table:
+ self.table[address] = {'timestamp' : timestamp, 'counter' : 1}
+ log.debug("Rate-Limit: %s (new, counter=%d, next reset in %d sec.)",
+ address, 1, self.interval)
+ return False
+
+ # We have to access multiple times, so its better to story the entry
+ # in a temporary variable.
+ entry = self.table[address]
+
+ # If the entry has expired - delete it => don't block
+ if timestamp - entry['timestamp'] > self.interval:
+ log.debug("Rate-Limit: %s (reset, counter=%d, next reset in %d sec.)",
+ address, 1, self.interval)
+ self.table[address] = {'timestamp' : timestamp, 'counter' : 1}
+ return False
+
+ # If the rate limit has been reached => block
+ if entry['counter'] >= self.requests:
+ log.warning("Rate-Limit: %s (exceeded, counter=%d, next reset in %d sec.)",
+ address, entry['counter'], self.interval - (timestamp - entry['timestamp']))
+ return True
+
+ # Increment counter, don't block
+ entry['counter'] += 1
+ log.debug("Rate-Limit: %s (incrementing, counter=%d, next reset in %d sec.)",
+ address, entry['counter'], self.interval - (timestamp - entry['timestamp']))
+ self.table[address] = entry
+ return False
+
+class OpenObserveFlightRecorder(FlightRecorder):
+ """Concrete implementation of a "flight recorder" using OpenObserve as a monitoring entity."""
+
+ def __init__(self, url: str, email: str, token: str):
+ self.service_auth = requests.auth.HTTPBasicAuth(email, token)
+ self.url = url
+ super().__init__()
+
+ def report(self):
+ report_json = json.dumps(self._gen_report())
+ rc = requests.post(self.url, auth=self.service_auth, data=report_json)
+ if rc.status_code != 200:
+ log.error("POST request to OpenObserve failed: %s", str(rc))
+
+async def rcpc_conn_hdlr(websocket: ServerConnection):
+ """
+ In this handler function we process the request from the the RCP Client. Before we perform any action we check if
+ the rate limit is not exceeded. Then we describe the available commands to the client and execute the procedure
+ the client asks for. When everything is done we close the connection normally. The client may skip executing any
+ procedure by closing the connection early on his behalf.
+
+ The interaction with the client is recorded using a "flight recorder" object. When the interaction is done, the
+ records are analyzed and a report is generated and sent to the OpenObserve monitoring entity.
+ """
+
+ # Immediately close the connection in case the rate limit has been exceeded.
+ if rate_limiter.limit(websocket.remote_address[0]):
+ await websocket.close(code=1008) # Policy Violation
+
+ # Create flight-recorder object
+ flight_recorder = None
+ if runtime_state.open_observe_pars:
+ flight_recorder = OpenObserveFlightRecorder(**runtime_state.open_observe_pars)
+
+ # Execute procedure
+ try:
+ json_validator = JsonValidator(runtime_state.rcpc_to_rcps_schema, runtime_state.rcps_to_rcpc_schema)
+ hdlr = RcpcSrvConnHdlr(websocket, CLIENT_TIMEOUT, json_validator, flight_recorder)
+ await hdlr.check_version()
+ await hdlr.describe()
+ await hdlr.procedure()
+ await hdlr.close()
+ except Exception as e:
+ backtrace("RCPC connection handler")
+ if flight_recorder:
+ flight_recorder.record_backtrace()
+ flight_recorder.crash_report()
+ await websocket.close(code=1011) # Internal Error
+
+ # Generate report from flight-recorder
+ if flight_recorder:
+ flight_recorder.report()
+
+async def rcpm_conn_hdlr(websocket: ServerConnection):
+ """
+ In this handler function we process requests from the RCP Module. We receive the description from the RCP Module.
+ We keep the connection open throughout the whole lifetime of the RCP Module process so that we can know when the
+ RCP Module becomes unavailable for some reason.
+ """
+ try:
+ json_validator = JsonValidator(runtime_state.rcpm_to_rcps_schema, runtime_state.rcps_to_rcpm_schema)
+ hdlr = RcpmSrvConnHdlr(websocket, CLIENT_TIMEOUT, json_validator)
+ await hdlr.check_version()
+ await hdlr.describe()
+ await hdlr.close()
+ except:
+ backtrace("RCPM connection handler")
+
+if __name__ == '__main__':
+ opts = option_parser.parse_args()
+ PySimLogger.setup(print, {logging.WARN: "\033[33m", logging.DEBUG: "\033[90m"}, opts.verbose)
+
+ # Load SSL/TLS certificates
+ rcpc_ssl_context = load_server_cert("RCP Client Server", opts.rcpc_server_cert)
+ rcpm_ssl_context = load_server_cert("RCP Module Server", opts.rcpm_server_cert)
+ rcpm_ca_ssl_context = load_ca_cert("RCP Module Command Server Client", opts.rcpm_module_ca_cert)
+
+ # Init card key provider for automatic card key retrieval
+ card_key_provider_init(opts)
+
+ # Prepare parameters for OpenObserve
+ if opts.open_observe_url and opts.open_observe_email and opts.open_observe_token:
+ open_observe_pars = {'url' : opts.open_observe_url,
+ 'email': opts.open_observe_email,
+ 'token' : opts.open_observe_token}
+ log.info("Reporting to OpenObserve: %s", open_observe_pars['url'])
+ else:
+ log.warning("Reporting to OpenObserve: (disabled)")
+ open_observe_pars = None
+
+ # Start RCP server
+ runtime_state = RuntimeState(rcpm_ca_ssl_context, open_observe_pars)
+ rate_limiter = RateLimiter(interval=60, requests=opts.rcpc_request_limit)
+ async def rcp_server():
+ log.info("RCP Client Server at: %s:%d" % (opts.rcpc_server_addr, opts.rcpc_server_port))
+ log.info("RCP Module server at: %s:%d" % (opts.rcpm_server_addr, opts.rcpm_server_port))
+ async with serve(rcpc_conn_hdlr, opts.rcpc_server_addr, opts.rcpc_server_port, ssl=rcpc_ssl_context), \
+ serve(rcpm_conn_hdlr, opts.rcpm_server_addr, opts.rcpm_server_port, ssl=rcpm_ssl_context):
+ await asyncio.get_running_loop().create_future()
+ try:
+ asyncio.run(rcp_server())
+ except SystemExit:
+ pass
+ except:
+ backtrace("RCP Server")
+ sys.exit(1)
+
diff --git a/contrib/rcp/rcp_utils.py b/contrib/rcp/rcp_utils.py
new file mode 100644
index 00000000..128c995b
--- /dev/null
+++ b/contrib/rcp/rcp_utils.py
@@ -0,0 +1,330 @@
+#!/usr/bin/env python3
+
+# (C) 2026 by sysmocom - s.f.m.c. GmbH
+# All Rights Reserved
+#
+# Author: Philipp Maier
+#
+# 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 sys
+import ssl
+import json
+import abc
+import asyncio
+import time
+import websockets
+import traceback
+import threading
+from copy import deepcopy
+from websockets.asyncio.server import ServerConnection
+from websockets.asyncio.client import ClientConnection
+from pathlib import Path
+from jsonschema import validate
+from pySim.log import PySimLogger
+from ssl import SSLContext
+
+log = PySimLogger.get(Path(__file__).stem)
+
+def backtrace(what: str):
+ log.error("%s failed with an exception:", what)
+ log.error("---------------------8<---------------------")
+ traceback_lines = traceback.format_exc()
+ for line in traceback_lines.split("\n"):
+ if line:
+ log.error(line)
+ log.error("---------------------8<---------------------")
+
+def key_value_pairs_from_dict(keys: dict, keylabel: str='key', valuelabel: str='value') -> list:
+ key_list = []
+ for key in keys:
+ key_list.append({keylabel : key, valuelabel : keys[key]})
+ return key_list
+
+def dict_from_key_value_pairs(keys: list, keylabel: str='key', valuelabel: str='value') -> dict:
+ key_dict = {}
+ for key in keys:
+ key_dict[key[keylabel]] = key[valuelabel]
+ return key_dict
+
+def pytype_to_type(dict_in: dict) -> dict:
+ """
+ There is no way to properly express python types in JSON. This function can be used to replace
+ each occurrence of "pytype", with "type", where the string type name is replaced with an actual
+ python type.
+ """
+ dict_out = deepcopy(dict_in)
+ if dict_out.get('pytype'):
+ if dict_out['pytype'] == "str":
+ dict_out.pop('pytype')
+ dict_out['type'] = str
+ elif dict_out['pytype'] == "int":
+ dict_out.pop('pytype')
+ dict_out['type'] = int
+ else:
+ raise ValueError("invalid type in command argument specification: %s" % arg['spec']['type'])
+ return dict_out
+
+def load_json_schema(filename: str) -> dict:
+ """Load a JSON schema from file"""
+ log.debug("loading JSON schema: %s", filename)
+ try:
+ with open(filename) as schema_file:
+ return json.load(schema_file)
+ except Exception as e:
+ backtrace("JSON schema load")
+ sys.exit(1)
+
+def load_server_cert(what: str, filename: str) -> SSLContext:
+ """Load an SSL/TLS server certificate"""
+ log.debug("loading SSL/TLS server certificate (%s): %s", what, filename)
+ ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ ssl_context.load_cert_chain(filename)
+ return ssl_context
+
+def load_ca_cert(what: str, filename: str) -> SSLContext:
+ """Load an SSL/TLS CA certificate"""
+ log.info("loading SSL/TLS CA certificate (%s): %s", what, filename)
+ ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ ssl_context.load_verify_locations(filename)
+ return ssl_context
+
+class JsonValidator():
+ """
+ JSON validator class, can be passed to any ConnHdlr object to automatically validate the JSON messages which are
+ sent and and received.
+ """
+
+ def __init__(self, rx_schema: dict, tx_schema: dict = None):
+ self.rx_schema = rx_schema
+ if tx_schema:
+ self.tx_schema = tx_schema
+ else:
+ self.tx_schema = None
+
+ def valid_rx_json(self, rx_json: dict):
+ validate(instance = rx_json, schema = self.rx_schema)
+
+ def valid_tx_json(self, tx_json: dict):
+ if self.tx_schema:
+ # We intentionally do not prevent the sending of an invalid JSON message. It is the responsibility of the
+ # receiving end to detect an invalid message and react accordingly. The purpose of this validation is to
+ # make developers/users aware of the problem.
+ try:
+ validate(instance = tx_json, schema = self.tx_schema)
+ except Exception as e:
+ backtrace("JSON schema validation (TX)")
+
+class FlightRecorder(abc.ABC):
+ """
+ Base class to create a FlightRecorder object which can be passed to any ConnHdlr object to record debug information
+ (record_comm, record_debug) and metadata (record_meta) throughout the lifetime of a ConnHdlr object. In case the
+ ConnHdlr throws an exception, the API user may call the record_backtrace method to record a backtrace. Finally,
+ the APU user may call the report method (which calls _gen_report internally) to send a report to an external
+ monitoring enitiy.
+ """
+
+ def __init__(self):
+ self.records_meta = {}
+ self.records_comm = []
+ self.records_debug = []
+ self.crash_report_flag = False
+ self.record_meta('timestamp_start', time.strftime('%Y-%m-%d %H:%M:%S'))
+
+ def record_meta(self, key: str, value):
+ """Record/Update metadata"""
+ self.records_meta[key] = value
+
+ def record_comm(self, key: str, value):
+ """Record communication (automatically called by the ConnHdlr object)"""
+ self.records_comm.append({key : value})
+
+ def record_debug(self, key: str, value):
+ """Record debug information"""
+ self.records_debug.append({key : value})
+
+ def record_backtrace(self):
+ """Record a backtrace"""
+ traceback_lines = traceback.format_exc()
+ traceback_lines_filtered = []
+ for line in traceback_lines.split("\n"):
+ if line:
+ traceback_lines_filtered.append(line)
+ self.record_debug('backtrace', traceback_lines_filtered)
+
+ def crash_report(self):
+ """Set crash_report_flag. Thie method shall be called if an unrecoverable error has occured."""
+ self.crash_report_flag = True
+
+ def _gen_report(self):
+ """
+ Generate a report from the collected data. In case the crash_report flag is set to true, the report will
+ inculde communications (records_comm) and debug information (records_debug). Otherwise only the metadata
+ (records_meta) will be included.
+ """
+ self.record_meta('timestamp_end', time.strftime('%Y-%m-%d %H:%M:%S'))
+ report = self.records_meta
+ if self.crash_report_flag:
+ report['comm'] = self.records_comm
+ report['debug'] = self.records_debug
+ report['report_type'] = 'crash'
+ log.warning("crash report: %s", str(report))
+ return report
+ else:
+ report['report_type'] = 'normal'
+ log.debug("normal report: %s", str(report))
+ return report
+
+ @abc.abstractmethod
+ def report(self):
+ """
+ To be implemented in the derived class. Shall call _gen_report and then send the report to an external
+ monitoring entity.
+ """
+ pass
+
+class ConnHdlr(abc.ABC):
+ """Base class that can be used to create a connection handler"""
+
+ def __init__(self, websocket: ServerConnection | ClientConnection, timeout: int,
+ json_validator: JsonValidator = None, flight_recorder: FlightRecorder = None):
+ self.websocket = websocket
+ self.local_address = websocket.local_address
+ self.remote_address = websocket.remote_address
+ self.timeout = timeout
+ self.json_validator = json_validator
+ self.flight_recorder = flight_recorder
+ log.debug(str(self) + " -- new handler, timeout: %d sec.", self.timeout)
+ if self.flight_recorder:
+ self.flight_recorder.record_meta(type(self).__name__ + '_remote_address',
+ str(self.remote_address[0]) + ":" + str(self.remote_address[1]))
+ self.flight_recorder.record_meta(type(self).__name__ + '_timestamp', time.strftime('%Y-%m-%d %H:%M:%S'))
+ self.flight_recorder.record_meta(type(self).__name__ + '_id', id(self))
+
+ def _log_recv_peer(self, rx_json_str: str):
+ peer = "%s:%d<-%s:%d" % (self.local_address[0],
+ self.local_address[1],
+ self.remote_address[0],
+ self.remote_address[1])
+ log.debug(str(self) + " -- RX(%s): %s", peer, rx_json_str)
+ if self.flight_recorder:
+ self.flight_recorder.record_comm(type(self).__name__ + '_rx', rx_json_str)
+
+ def _log_send_peer(self, tx_json_str: str):
+ peer = "%s:%d->%s:%d" % (self.local_address[0],
+ self.local_address[1],
+ self.remote_address[0],
+ self.remote_address[1])
+ log.debug(str(self) + " -- TX(%s): %s", peer, tx_json_str)
+ if self.flight_recorder:
+ self.flight_recorder.record_comm(type(self).__name__ + '_tx', tx_json_str)
+
+ def __str__(self) -> str:
+ return "%s(%d)" % (type(self).__name__, id(self))
+
+ def __del__(self):
+ log.debug(str(self) + " -- closed handler")
+
+class SrvConnHdlr(ConnHdlr):
+ """Base class that can be used to create a connection handler for a server"""
+
+ async def _recv(self) -> dict:
+ """Receive JSON message from client"""
+ async with asyncio.timeout(self.timeout):
+ try:
+ rx_json_str = await self.websocket.recv()
+ except websockets.exceptions.ConnectionClosedOK:
+ log.debug(str(self) + " -- no data received, connection is closed")
+ return None
+ self._log_recv_peer(rx_json_str)
+ rx_json = json.loads(rx_json_str)
+ if self.json_validator:
+ self.json_validator.valid_rx_json(rx_json)
+ return rx_json
+
+ async def _send(self, tx_json: dict):
+ """Send JSON message to client"""
+ if self.json_validator:
+ self.json_validator.valid_tx_json(tx_json)
+ tx_json_str = json.dumps(tx_json)
+ self._log_send_peer(tx_json_str)
+ await self.websocket.send(tx_json_str)
+
+ async def _transact(self, tx_json: dict) -> dict:
+ """Exchange JSON message with client"""
+ await self._send(tx_json)
+ return await self._recv()
+
+ async def close(self):
+ """Wait for a connecion to close normally"""
+ await self.websocket.wait_closed()
+ log.debug(str(self) + " -- closed connection")
+
+class SrvSyncConnHdlr(ConnHdlr):
+ """Base class that can be used to create a synchronous connection handler for a server"""
+
+ def _recv(self) -> dict:
+ """Receive JSON message from client"""
+ rx_json_str = self.websocket.recv(self.timeout)
+ self._log_recv_peer(rx_json_str)
+ rx_json = json.loads(rx_json_str)
+ if self.json_validator:
+ self.json_validator.valid_rx_json(rx_json)
+ return rx_json
+
+ def _send(self, tx_json: dict):
+ """Send JSON message to client"""
+ if self.json_validator:
+ self.json_validator.valid_tx_json(tx_json)
+ tx_json_str = json.dumps(tx_json)
+ self._log_send_peer(tx_json_str)
+ self.websocket.send(tx_json_str)
+
+ def _transact(self, tx_json: dict) -> dict:
+ """Exchange JSON message with client"""
+ self._send(tx_json)
+ return self._recv()
+
+ def close(self):
+ """Close connection normally"""
+ self.websocket.close()
+ log.debug(str(self) + " -- closed connection")
+
+class CltConnHdlr(ConnHdlr):
+ """Base class that can be used to create a connection handler for a client"""
+
+ async def _transact(self, tx_json: dict) -> dict:
+ """Exchange JSON message with server"""
+ if self.json_validator:
+ self.json_validator.valid_tx_json(tx_json)
+ tx_json_str = json.dumps(tx_json)
+ self._log_send_peer(tx_json_str)
+ async with asyncio.timeout(self.timeout):
+ await self.websocket.send(tx_json_str)
+ rx_json_str = await self.websocket.recv()
+ self._log_recv_peer(rx_json_str)
+ rx_json = json.loads(rx_json_str);
+ if self.json_validator:
+ self.json_validator.valid_rx_json(rx_json)
+ return rx_json
+
+ async def close(self):
+ """Close connection normally"""
+ await self.websocket.close()
+ log.debug(str(self) + " -- closed connection")
+
+ async def wait_close(self):
+ """Wait for a connecion to close normally"""
+ await self.websocket.wait_closed()
+ log.debug(str(self) + " -- closed connection")
diff --git a/contrib/rcp/rcpc_to_rcps_schema.json b/contrib/rcp/rcpc_to_rcps_schema.json
new file mode 100644
index 00000000..52bc5f41
--- /dev/null
+++ b/contrib/rcp/rcpc_to_rcps_schema.json
@@ -0,0 +1,99 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "RCP Client to RCP Server",
+ "type": "object",
+ "properties": {
+ "rcpc_version": {
+ "type": "object",
+ "properties": {
+ "software": {
+ "type": "string"
+ },
+ "protocol": {
+ "type": "string"
+ }
+ },
+ "required": [ "software", "protocol" ],
+ "additionalProperties": false
+ },
+ "rcpc_hello": {
+ "type": "object",
+ "properties": {
+ "suitable_for": {
+ "type": "object",
+ "properties": {
+ "atr": {
+ "type": "string",
+ "pattern": "^[0-9,A-F]{0,66}$"
+ }
+ },
+ "oneOf": [
+ { "required": [ "atr" ] }
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [ "suitable_for" ],
+ "additionalProperties": false
+ },
+ "rcpc_command": {
+ "type": "object",
+ "properties": {
+ "cmd": {
+ "type": "string",
+ "pattern": "^[0-9,A-Z,a-z,_]{0,40}$"
+ },
+ "cmd_argv": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^.{0,512}$"
+ },
+ "maxItems": 255
+ }
+ },
+ "required": [ "cmd", "cmd_argv" ],
+ "additionalProperties": false
+ },
+ "rcpc_result": {
+ "type": "object",
+ "properties": {
+ "r_apdu": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "string",
+ "pattern": "^[0-9,A-F]{0,512}$"
+ },
+ "sw": {
+ "type": "string",
+ "pattern": "^[0-9,A-F]{0,4}$"
+ }
+ },
+ "required": [ "data", "sw" ],
+ "additionalProperties": false
+ },
+ "atr": {
+ "type": "string",
+ "pattern": "^[0-9,A-F]{0,66}$"
+ },
+ "empty": {
+ "type": "null"
+ }
+ },
+ "oneOf": [
+ { "required": [ "r_apdu" ] },
+ { "required": [ "atr" ] },
+ { "required": [ "empty" ] }
+ ],
+ "additionalProperties": false
+ }
+ },
+ "oneOf": [
+ { "required": [ "rcpc_version" ] },
+ { "required": [ "rcpc_hello" ] },
+ { "required": [ "rcpc_command" ] },
+ { "required": [ "rcpc_result" ] }
+ ],
+ "additionalProperties": false
+}
diff --git a/contrib/rcp/rcpm_to_rcps_schema.json b/contrib/rcp/rcpm_to_rcps_schema.json
new file mode 100644
index 00000000..ab4d704a
--- /dev/null
+++ b/contrib/rcp/rcpm_to_rcps_schema.json
@@ -0,0 +1,127 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "RCP Module to RCP Server",
+ "type": "object",
+ "properties": {
+ "rcpm_version": {
+ "type": "object",
+ "properties": {
+ "protocol": {
+ "type": "string"
+ }
+ },
+ "required": [ "protocol" ],
+ "additionalProperties": false
+ },
+ "rcpm_hello": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "cmd_descr": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "help": {
+ "type": "string"
+ },
+ "args": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "spec": {
+ "type": "object",
+ "properties": {
+ "required" : {
+ "type": "boolean"
+ },
+ "help": {
+ "type": "string"
+ },
+ "action": {
+ "type": "string"
+ },
+ "pytype": {
+ "type": "string"
+ },
+ "default" : {
+ "type": ["string", "integer"]
+ }
+ },
+ "required": [ "help" ],
+ "additionalProperties": false
+ }
+ },
+ "required": [ "name", "spec" ],
+ "additionalProperties": false
+ }
+ },
+ "get_keys": {
+ "type": "object",
+ "properties": {
+ "uicc" : {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "euicc" : {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "oneOf": [
+ { "required": [ "uicc" ] },
+ { "required": [ "euicc" ] }
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [ "name", "help", "args" ],
+ "additionalProperties": false
+ }
+ },
+ "suitable_for": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "atr": {
+ "type": "string",
+ "pattern": "^[0-9,A-F]{0,66}$"
+ }
+ },
+ "oneOf": [
+ { "required": [ "atr" ] }
+ ],
+ "additionalProperties": false
+ }
+ },
+ "addr": {
+ "type": "string"
+ },
+ "port": {
+ "type": "integer"
+ }
+ },
+ "required": [ "name", "cmd_descr", "suitable_for", "addr", "port" ],
+ "additionalProperties": false
+ }
+ },
+ "oneOf": [
+ { "required": [ "rcpm_hello" ] },
+ { "required": [ "rcpm_version" ] }
+ ],
+ "additionalProperties": false
+}
diff --git a/contrib/rcp/rcpmcs_to_rcps_schema.json b/contrib/rcp/rcpmcs_to_rcps_schema.json
new file mode 100644
index 00000000..81a46d56
--- /dev/null
+++ b/contrib/rcp/rcpmcs_to_rcps_schema.json
@@ -0,0 +1,36 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "RCP Module Command Server to RCP Server",
+ "type": "object",
+ "properties": {
+ "rcps_instr": {
+ "type": "object",
+ "properties": {
+ "print": {
+ "type": "string"
+ },
+ "reset": {
+ "type": "null"
+ },
+ "c_apdu": {
+ "type": "string",
+ "pattern": "^[0-9,A-F]{0,512}$"
+ }
+ },
+ "oneOf": [
+ { "required": [ "print" ] },
+ { "required": [ "reset" ] },
+ { "required": [ "c_apdu" ] }
+ ],
+ "additionalProperties": false
+ },
+ "rcps_goodbye": {
+ "type": "integer"
+ }
+ },
+ "oneOf": [
+ { "required": [ "rcps_instr" ] },
+ { "required": [ "rcps_goodbye" ] }
+ ],
+ "additionalProperties": false
+}
diff --git a/contrib/rcp/rcps_to_rcpc_schema.json b/contrib/rcp/rcps_to_rcpc_schema.json
new file mode 100644
index 00000000..f88924f4
--- /dev/null
+++ b/contrib/rcp/rcps_to_rcpc_schema.json
@@ -0,0 +1,125 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "RCP Server to RCP Client",
+ "type": "object",
+ "properties": {
+ "rcpc_version": {
+ "type": "object",
+ "properties": {
+ "software": {
+ "type": "string"
+ },
+ "protocol": {
+ "type": "string"
+ },
+ "info": {
+ "type": "string"
+ }
+ },
+ "required": [ "software", "protocol" ],
+ "additionalProperties": false
+ },
+ "rcpc_welcome": {
+ "type": "object",
+ "properties": {
+ "module_descr": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "cmd_descr": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "help": {
+ "type": "string"
+ },
+ "args": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "spec": {
+ "type": "object",
+ "properties": {
+ "required" : {
+ "type": "boolean"
+ },
+ "help": {
+ "type": "string"
+ },
+ "action": {
+ "type": "string"
+ },
+ "pytype": {
+ "type": "string"
+ },
+ "default" : {
+ "type": ["string", "integer"]
+ }
+ },
+ "required": [ "help" ],
+ "additionalProperties": false
+ }
+ },
+ "required": [ "name", "spec" ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [ "name", "help", "args" ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [ "name", "cmd_descr" ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [ "module_descr" ],
+ "additionalProperties": false
+ },
+ "rcpc_instr": {
+ "type": "object",
+ "properties": {
+ "print": {
+ "type": "string"
+ },
+ "reset": {
+ "type": "null"
+ },
+ "c_apdu": {
+ "type": "string",
+ "pattern": "^[0-9,A-F]{0,512}$"
+ }
+ },
+ "oneOf": [
+ { "required": [ "print" ] },
+ { "required": [ "reset" ] },
+ { "required": [ "c_apdu" ] }
+ ],
+ "additionalProperties": false
+ },
+ "rcpc_goodbye": {
+ "type": "integer"
+ }
+ },
+ "oneOf": [
+ { "required": [ "rcpc_version" ] },
+ { "required": [ "rcpc_welcome" ] },
+ { "required": [ "rcpc_instr" ] },
+ { "required": [ "rcpc_goodbye" ] }
+ ],
+ "additionalProperties": false
+}
diff --git a/contrib/rcp/rcps_to_rcpm_schema.json b/contrib/rcp/rcps_to_rcpm_schema.json
new file mode 100644
index 00000000..5e16fb3d
--- /dev/null
+++ b/contrib/rcp/rcps_to_rcpm_schema.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "RCP Server to RCP Module",
+ "type": "object",
+ "properties": {
+ "rcpm_version": {
+ "type": "object",
+ "properties": {
+ "protocol": {
+ "type": "string"
+ }
+ },
+ "required": [ "protocol" ],
+ "additionalProperties": false
+ },
+ "rcpm_welcome": {
+ "type": "null"
+ }
+ },
+ "oneOf": [
+ { "required": [ "rcpm_version" ] },
+ { "required": [ "rcpm_welcome" ] }
+ ],
+ "additionalProperties": false
+}
diff --git a/contrib/rcp/rcps_to_rcpmcs_schema.json b/contrib/rcp/rcps_to_rcpmcs_schema.json
new file mode 100644
index 00000000..e3615242
--- /dev/null
+++ b/contrib/rcp/rcps_to_rcpmcs_schema.json
@@ -0,0 +1,106 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "RCP Server to RCP Module Command Server",
+ "type": "object",
+ "properties": {
+ "rcps_command": {
+ "type": "object",
+ "properties": {
+ "cmd": {
+ "type": "string",
+ "pattern": "^[0-9,A-Z,a-z,_]{0,40}$"
+ },
+ "cmd_argv": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^.{0,512}$"
+ },
+ "maxItems": 255
+ },
+ "keys" : {
+ "type": "object",
+ "properties": {
+ "uicc" : {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ }
+ },
+ "required": [ "key", "value" ],
+ "additionalProperties": false
+ }
+ },
+ "euicc" : {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ }
+ },
+ "required": [ "key", "value" ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "oneOf": [
+ { "required": [ "uicc" ] },
+ { "required": [ "euicc" ] }
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [ "cmd", "cmd_argv" ],
+ "additionalProperties": false
+ },
+ "rcps_result": {
+ "type": "object",
+ "properties": {
+ "r_apdu": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "string",
+ "pattern": "^[0-9,A-F]{0,512}$"
+ },
+ "sw": {
+ "type": "string",
+ "pattern": "^[0-9,A-F]{0,4}$"
+ }
+ },
+ "required": [ "data", "sw" ],
+ "additionalProperties": false
+ },
+ "atr": {
+ "type": "string",
+ "pattern": "^[0-9,A-F]{0,66}$"
+ },
+ "empty": {
+ "type": "null"
+ }
+ },
+ "oneOf": [
+ { "required": [ "r_apdu" ] },
+ { "required": [ "atr" ] },
+ { "required": [ "empty" ] }
+ ],
+ "additionalProperties": false
+ }
+ },
+ "oneOf": [
+ { "required": [ "rcps_command" ] },
+ { "required": [ "rcps_result" ] }
+ ],
+ "additionalProperties": false
+}
diff --git a/contrib/rcp/usage_example/card_data.csv b/contrib/rcp/usage_example/card_data.csv
new file mode 100644
index 00000000..132b7877
--- /dev/null
+++ b/contrib/rcp/usage_example/card_data.csv
@@ -0,0 +1,2 @@
+iccid,kic,kid,kik
+8949440000001155306,F09C43EE1A0391665CC9F05AF4E0BD10,01981F4A20999F62AF99988007BAF6CA,8F8AEE5CDCC5D361368BC45673D99195
diff --git a/contrib/rcp/usage_example/card_data.csv.encr b/contrib/rcp/usage_example/card_data.csv.encr
new file mode 100644
index 00000000..fc55dcce
--- /dev/null
+++ b/contrib/rcp/usage_example/card_data.csv.encr
@@ -0,0 +1,2 @@
+"ICCID","KIC","KID","KIK"
+"8949440000001155306","eae46224fa0a4ac1c12cba9d102f1188","3f14b978ddb38c08d832d4e4c2e0639d","9e19db4a5ed5cb8c4f5d96283eab273a"
diff --git a/contrib/rcp/usage_example/certs/example_ssl_rcp_ca_cert.crt b/contrib/rcp/usage_example/certs/example_ssl_rcp_ca_cert.crt
new file mode 100644
index 00000000..ab656d1f
--- /dev/null
+++ b/contrib/rcp/usage_example/certs/example_ssl_rcp_ca_cert.crt
@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDSzCCAjOgAwIBAgIUEv1f0yjVtkr+RNYLItZ33eTJwHMwDQYJKoZIhvcNAQEL
+BQAwFjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMjYwNDI5MTIwOTM0WhcNMzYw
+NDI2MTIwOTM0WjAWMRQwEgYDVQQDDAtFYXN5LVJTQSBDQTCCASIwDQYJKoZIhvcN
+AQEBBQADggEPADCCAQoCggEBANXdkSyQlDzuo2cJmnBmFiZpc0V9tYBcNkpZd3Ac
+R0WljazKKgXDWNmOcSO7891bi+1HZzz+nDfV0mJY776ScGkTqF43Hzpg9eZakMAx
+yC24mT4h+uyRcPWZrBwaQhpiQrvZy4MRyuUB+BEgBSmhoDiuXP44kWiuEJHuzpOq
+X6Q2dW8RIeQPDGGK6XPZIQLqx+krxkaqphd/vHgT1/yd7Ol5xxMc4x2UuPaVCj0D
+OzslFsbb0Zu77ffCtHOVVnzSCzeEGGx1MPQm6hDVW+KUXXTwke1K55fmFZhu0gKO
+HYSEjgPj6X8muDb+GvOAQX3fHmS6KvFS4fwWd2InZ3v2f3cCAwEAAaOBkDCBjTAM
+BgNVHRMEBTADAQH/MB0GA1UdDgQWBBS6zY4Dd0pJFrvWLmyjn0vDTFqVqzBRBgNV
+HSMESjBIgBS6zY4Dd0pJFrvWLmyjn0vDTFqVq6EapBgwFjEUMBIGA1UEAwwLRWFz
+eS1SU0EgQ0GCFBL9X9Mo1bZK/kTWCyLWd93kycBzMAsGA1UdDwQEAwIBBjANBgkq
+hkiG9w0BAQsFAAOCAQEAGJUXlbnVhh+xL+pyTyjwtd8nxhUcHzYZl+OT0bkGY9zT
+S3NjHkKBbdnEftuYDYqp0uBuGFQ1WIOKiM3rp4IePKe84lSivZMVh9ObtNalcEQr
+sqxBziNOMJM2mh5V2NdxiK2E1gCZ959wOQ8yzM6gGC+wW8w4zwULhv4JimQDjk+G
+kAdiGL7+WAxrNWUulvm8khFt2nOlucJg4IAYVt2SI1AFMt/YSXoA4wMwM9QcHGj0
+1A069IxX93WVhUpIL1Avwz+KJK0BPY6SM8LYUy6V50Hojp76BB7VD6SxQrSoceUo
+6cRNDtCmofOlltfeUJLr1mI4S2tM50bQVsHD92EJBA==
+-----END CERTIFICATE-----
diff --git a/contrib/rcp/usage_example/certs/example_ssl_rcpc_rcps_cert.pem b/contrib/rcp/usage_example/certs/example_ssl_rcpc_rcps_cert.pem
new file mode 100644
index 00000000..2c401392
--- /dev/null
+++ b/contrib/rcp/usage_example/certs/example_ssl_rcpc_rcps_cert.pem
@@ -0,0 +1,115 @@
+Certificate:
+ Data:
+ Version: 3 (0x2)
+ Serial Number:
+ 42:38:a5:6f:70:53:40:e4:a4:1a:2c:0f:fc:81:13:42
+ Signature Algorithm: sha256WithRSAEncryption
+ Issuer: CN=Easy-RSA CA
+ Validity
+ Not Before: Apr 29 12:09:35 2026 GMT
+ Not After : Aug 1 12:09:35 2028 GMT
+ Subject: CN=example_ssl_rcpc_rcps_cert
+ Subject Public Key Info:
+ Public Key Algorithm: rsaEncryption
+ Public-Key: (2048 bit)
+ Modulus:
+ 00:ae:0f:e1:ee:fc:f6:db:75:45:c0:f4:49:72:46:
+ 3d:e3:db:0c:c4:34:d2:9e:49:d4:86:4f:19:0d:55:
+ 70:50:81:e4:e6:64:56:a8:58:e8:e6:54:0a:16:bc:
+ f4:4b:84:cd:1d:b9:2e:ed:62:b6:cd:62:35:8b:81:
+ 18:ab:ff:63:f5:c1:dc:16:3e:a8:dc:ac:11:dd:43:
+ 12:f8:ef:f2:f1:af:84:fd:83:fe:a8:d3:46:7d:77:
+ e6:ae:95:61:a6:c9:99:6b:40:61:8d:6e:7e:66:1e:
+ 97:77:b0:e8:b7:3d:3a:d5:d7:d3:ee:66:95:62:83:
+ 14:cc:5e:32:ff:9e:bd:f1:06:e6:8d:6a:7c:0a:27:
+ 22:19:b9:06:09:cf:ef:c7:dc:e8:8f:04:4b:83:0d:
+ cc:8d:b1:c2:cf:ab:40:25:6e:f2:bf:b7:c6:1d:8f:
+ d2:fc:3d:c8:a1:be:4a:09:b9:91:e3:76:4f:c7:9b:
+ fc:2f:de:d9:bb:eb:df:d3:d8:8c:72:79:bd:bf:10:
+ 8b:01:e6:0f:7f:bb:f6:75:31:5a:40:ad:df:e1:07:
+ e6:12:12:b2:d3:99:d0:bd:24:5a:9a:ce:62:4f:da:
+ fe:0d:df:09:ae:da:04:83:54:e8:cb:68:c0:57:78:
+ c2:f4:68:42:d7:f4:81:4a:a3:b4:4e:0b:49:95:26:
+ 1d:15
+ Exponent: 65537 (0x10001)
+ X509v3 extensions:
+ X509v3 Basic Constraints:
+ CA:FALSE
+ X509v3 Subject Key Identifier:
+ 8E:99:9D:C0:70:98:57:16:08:8E:DF:6E:51:78:A6:86:18:FF:06:52
+ X509v3 Authority Key Identifier:
+ keyid:BA:CD:8E:03:77:4A:49:16:BB:D6:2E:6C:A3:9F:4B:C3:4C:5A:95:AB
+ DirName:/CN=Easy-RSA CA
+ serial:12:FD:5F:D3:28:D5:B6:4A:FE:44:D6:0B:22:D6:77:DD:E4:C9:C0:73
+ X509v3 Extended Key Usage:
+ TLS Web Server Authentication
+ X509v3 Key Usage:
+ Digital Signature, Key Encipherment
+ X509v3 Subject Alternative Name:
+ DNS:127.0.0.1, IP Address:127.0.0.1
+ Signature Algorithm: sha256WithRSAEncryption
+ Signature Value:
+ 3e:56:20:f9:3b:fa:13:6e:7e:a9:80:a6:15:18:01:82:f1:b8:
+ 4d:1b:f1:ee:da:ed:50:f7:3b:13:01:a5:14:f9:4c:0e:34:57:
+ dc:e6:d1:7e:02:30:af:3b:fd:c9:ae:18:16:c9:3b:0a:4e:20:
+ da:cd:e8:cc:05:0c:b3:7d:6f:e5:15:ff:66:59:6b:fe:ff:1a:
+ ef:ca:b5:3a:1a:ad:dd:f6:19:43:d9:2b:61:18:29:95:b4:0c:
+ 1e:b2:4a:ce:80:d3:1b:59:dc:62:ec:50:21:37:9c:2f:7a:4d:
+ c2:ac:de:1b:1d:a3:25:e0:e8:33:42:cf:77:31:2a:f2:44:36:
+ ef:59:89:da:6c:3e:9a:e8:d7:06:39:17:d5:78:82:6d:b6:63:
+ 3f:9a:40:3b:e6:12:58:52:3d:63:4e:85:0b:02:cb:40:d2:8a:
+ 59:8d:8f:ee:4a:c8:97:91:51:a9:2f:1b:15:81:9c:20:dd:94:
+ 08:6f:ac:fa:c6:28:90:6c:17:5a:23:87:9a:5b:e5:c6:2e:f3:
+ 09:66:de:76:1b:60:42:c1:5c:71:88:87:f6:7b:cb:e3:7e:14:
+ 67:c9:a0:15:98:b6:7b:75:40:9a:08:fc:77:39:3a:23:cb:e3:
+ 78:7d:57:f9:a7:66:36:b4:b5:07:de:61:3a:dd:07:58:b3:4f:
+ 41:f6:f4:d9
+-----BEGIN CERTIFICATE-----
+MIIDhDCCAmygAwIBAgIQQjilb3BTQOSkGiwP/IETQjANBgkqhkiG9w0BAQsFADAW
+MRQwEgYDVQQDDAtFYXN5LVJTQSBDQTAeFw0yNjA0MjkxMjA5MzVaFw0yODA4MDEx
+MjA5MzVaMCUxIzAhBgNVBAMMGmV4YW1wbGVfc3NsX3JjcGNfcmNwc19jZXJ0MIIB
+IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArg/h7vz223VFwPRJckY949sM
+xDTSnknUhk8ZDVVwUIHk5mRWqFjo5lQKFrz0S4TNHbku7WK2zWI1i4EYq/9j9cHc
+Fj6o3KwR3UMS+O/y8a+E/YP+qNNGfXfmrpVhpsmZa0BhjW5+Zh6Xd7Dotz061dfT
+7maVYoMUzF4y/5698QbmjWp8CiciGbkGCc/vx9zojwRLgw3MjbHCz6tAJW7yv7fG
+HY/S/D3Iob5KCbmR43ZPx5v8L97Zu+vf09iMcnm9vxCLAeYPf7v2dTFaQK3f4Qfm
+EhKy05nQvSRams5iT9r+Dd8JrtoEg1Toy2jAV3jC9GhC1/SBSqO0TgtJlSYdFQID
+AQABo4G+MIG7MAkGA1UdEwQCMAAwHQYDVR0OBBYEFI6ZncBwmFcWCI7fblF4poYY
+/wZSMFEGA1UdIwRKMEiAFLrNjgN3SkkWu9YubKOfS8NMWpWroRqkGDAWMRQwEgYD
+VQQDDAtFYXN5LVJTQSBDQYIUEv1f0yjVtkr+RNYLItZ33eTJwHMwEwYDVR0lBAww
+CgYIKwYBBQUHAwEwCwYDVR0PBAQDAgWgMBoGA1UdEQQTMBGCCTEyNy4wLjAuMYcE
+fwAAATANBgkqhkiG9w0BAQsFAAOCAQEAPlYg+Tv6E25+qYCmFRgBgvG4TRvx7trt
+UPc7EwGlFPlMDjRX3ObRfgIwrzv9ya4YFsk7Ck4g2s3ozAUMs31v5RX/Zllr/v8a
+78q1Ohqt3fYZQ9krYRgplbQMHrJKzoDTG1ncYuxQITecL3pNwqzeGx2jJeDoM0LP
+dzEq8kQ271mJ2mw+mujXBjkX1XiCbbZjP5pAO+YSWFI9Y06FCwLLQNKKWY2P7krI
+l5FRqS8bFYGcIN2UCG+s+sYokGwXWiOHmlvlxi7zCWbedhtgQsFccYiH9nvL434U
+Z8mgFZi2e3VAmgj8dzk6I8vjeH1X+admNrS1B95hOt0HWLNPQfb02Q==
+-----END CERTIFICATE-----
+-----BEGIN PRIVATE KEY-----
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCuD+Hu/PbbdUXA
+9ElyRj3j2wzENNKeSdSGTxkNVXBQgeTmZFaoWOjmVAoWvPRLhM0duS7tYrbNYjWL
+gRir/2P1wdwWPqjcrBHdQxL47/Lxr4T9g/6o00Z9d+aulWGmyZlrQGGNbn5mHpd3
+sOi3PTrV19PuZpVigxTMXjL/nr3xBuaNanwKJyIZuQYJz+/H3OiPBEuDDcyNscLP
+q0AlbvK/t8Ydj9L8PcihvkoJuZHjdk/Hm/wv3tm769/T2Ixyeb2/EIsB5g9/u/Z1
+MVpArd/hB+YSErLTmdC9JFqazmJP2v4N3wmu2gSDVOjLaMBXeML0aELX9IFKo7RO
+C0mVJh0VAgMBAAECggEAGBhmQhdeE+Cu1Ihsn2dWW3PAF2wpiNR3GVWbRfOBHf/x
+QCx9K4ZNTU8ua1niZo7edyIiuyVaYWGaQHLRR8QNoiBhN2oapZujSHInzvKmeqr9
+ubt7NgMzQ5ykwB+5OiW3uXda2cGFOV08QgspF+6ftakQMzUbslyrdSQIIscmi5Ya
+uTDvE6+lBFinxy3RHFKVCZ3UrsDwfHR4eTUmgCHRB27joB7DFXL32amv0M8HjoGz
+EZKGJgTwmRf9U4z4D4wCnOfVAPlsuthKUqMuTlBg0ZEstMZrzlP4suT2ieku0Usv
+0XbJ38VozPYYFdR7nApVVvrJgHzI9cpoUbGto4BLOQKBgQDbXjFVLffOec8hv9dN
+2VGZQmK61S9OrbvTnEOlxJd+kRid71X1pV5TuPKJJQtUJXf429bQOs40YbLeOmJt
+BiRSR5yIBH7hDDC/c0ynqunstwDlgz+QX2Oh2B4alvVaWy0rZYF6NpBiI0+R5r5V
+C2fHRS4LLPoflg83+CMubyLS6QKBgQDLIOXxlp1JQTzXhJkrkytLkafmEHAafovt
+wbRD50/s+dl16BRX12sK0gXj2vwu0FleUD6Z7afDfspmvQdg3fyDxYw9Q+vw5LYQ
+7BvoVU99o1m468yXwX/v36peCt4nOpwkJZKJfjgxjnMJByyeSUgL9uW4K+0D0LBV
+a5Iv7QklTQKBgH30BkVPIHKIE/rfyIJlXemuaTu2/fOh4y9sEJdUWluMeeLssaFa
+ct+FWJSQFYIaBVl4+E0VBqKi2e2o/ix1E1O+1ExwsF0M/8xdKk024BtPNA+TnWKK
+so0Rpq9Dr9pScYvyOzZtr9b5SU2PfAcehlavDPHTwEV0hoZvTdvyab9JAoGADMBJ
+7vp3cSvJN/Y470VTyHCiS4zonKEpA4nPWRviJowgnIgvDryVGZ7Jg94xSncFxSfg
+ZiVHDLye1Ag1uFz3BwaVoRrsarjQvQs1TUZdsRNaBIO42iXpdBNkTHb+LxQ8zQAW
+zM7BlErO6dgrctxCy416Ki+Ht1+YUiRojt2gX1kCgYBqytUy+XkPi5j3Ga29xcvP
+WI3Uc8RI2GmoAmrw5QFiSG6lNXAzfo2ZNQbFnxgxeMOG9fV9yzBdIjXWNWr0E/KH
+Fsb65R8iIrXQB9BZjuQqjz9nDm7eZZUBNGGbQ4DgSepnp194gXC5DoAElzuwOXbE
+pY/kM1KwlpUR3J3LeF3i+Q==
+-----END PRIVATE KEY-----
diff --git a/contrib/rcp/usage_example/certs/example_ssl_rcpm_rcps_cert.pem b/contrib/rcp/usage_example/certs/example_ssl_rcpm_rcps_cert.pem
new file mode 100644
index 00000000..429c09ad
--- /dev/null
+++ b/contrib/rcp/usage_example/certs/example_ssl_rcpm_rcps_cert.pem
@@ -0,0 +1,115 @@
+Certificate:
+ Data:
+ Version: 3 (0x2)
+ Serial Number:
+ e7:09:ab:70:b5:dc:1f:11:d9:2a:23:04:39:87:34:f3
+ Signature Algorithm: sha256WithRSAEncryption
+ Issuer: CN=Easy-RSA CA
+ Validity
+ Not Before: Apr 29 12:09:35 2026 GMT
+ Not After : Aug 1 12:09:35 2028 GMT
+ Subject: CN=example_ssl_rcpm_rcps_cert
+ Subject Public Key Info:
+ Public Key Algorithm: rsaEncryption
+ Public-Key: (2048 bit)
+ Modulus:
+ 00:cc:79:9b:d3:f3:1f:41:9f:00:48:cd:47:0b:ae:
+ b9:1c:4e:3e:55:e2:4e:5f:a8:cc:13:d5:dd:bd:f0:
+ 01:4c:19:ae:e3:a9:09:06:89:92:49:f7:bb:90:28:
+ fb:8c:22:69:b5:f5:a0:50:3d:97:0f:1e:1d:b1:a8:
+ 57:9b:d7:e2:0d:99:67:7f:02:82:0c:9c:8e:dd:13:
+ 03:28:93:b5:cb:7e:b5:78:06:10:bf:7b:55:c3:f7:
+ 10:8b:20:4a:1c:f9:f1:b2:fa:f1:c7:44:9d:0a:ce:
+ ef:8d:f9:e8:ff:d1:c1:69:ec:8e:5f:11:cc:c9:98:
+ d5:1c:33:e2:5b:7a:4d:34:dc:76:c3:cd:db:4c:93:
+ d1:08:78:6f:3c:9a:ee:74:39:1e:cd:65:1e:c9:35:
+ cc:3b:2b:9e:d7:49:10:8e:58:85:b0:10:5b:90:1e:
+ f1:5e:d5:92:04:93:f9:33:c6:9d:77:63:d1:33:46:
+ 5b:98:ff:9a:a8:f5:df:f7:84:21:e2:88:28:7a:a4:
+ c6:0d:9f:25:7e:0d:73:5b:d5:53:4a:90:79:94:37:
+ 14:f3:c8:75:76:d4:1c:32:51:bf:58:16:74:d5:8d:
+ 18:b6:53:f4:ab:cb:91:a8:8c:a3:ca:3c:5c:35:b6:
+ 5f:62:57:37:5a:75:28:b7:4d:26:aa:ea:50:da:a4:
+ 1c:55
+ Exponent: 65537 (0x10001)
+ X509v3 extensions:
+ X509v3 Basic Constraints:
+ CA:FALSE
+ X509v3 Subject Key Identifier:
+ 47:92:B5:81:8B:5C:14:98:B3:83:B6:EB:06:9F:43:F3:3A:7E:ED:24
+ X509v3 Authority Key Identifier:
+ keyid:BA:CD:8E:03:77:4A:49:16:BB:D6:2E:6C:A3:9F:4B:C3:4C:5A:95:AB
+ DirName:/CN=Easy-RSA CA
+ serial:12:FD:5F:D3:28:D5:B6:4A:FE:44:D6:0B:22:D6:77:DD:E4:C9:C0:73
+ X509v3 Extended Key Usage:
+ TLS Web Server Authentication
+ X509v3 Key Usage:
+ Digital Signature, Key Encipherment
+ X509v3 Subject Alternative Name:
+ DNS:127.0.0.1, IP Address:127.0.0.1
+ Signature Algorithm: sha256WithRSAEncryption
+ Signature Value:
+ 6d:31:e6:29:d2:3b:a8:90:5c:4b:ac:61:15:95:5d:70:66:a5:
+ 77:9d:88:47:49:73:75:be:70:69:d8:2f:62:82:5e:83:86:3b:
+ a8:48:3f:f1:5f:22:ae:81:23:64:c4:f2:2b:dd:4d:be:e5:6a:
+ 26:a5:ea:c7:ba:1b:3e:6a:34:03:5a:f1:49:28:5f:56:4a:a6:
+ 0e:1b:7a:07:48:76:95:b6:4b:f5:3f:b9:67:2e:e0:33:06:80:
+ d4:d6:01:a5:76:01:c0:a5:18:e5:38:8b:52:73:6e:6d:45:50:
+ b7:9a:ab:86:5d:e3:65:b4:b8:c7:ee:b2:dc:bf:e3:d5:bb:e4:
+ 91:eb:f5:0c:38:22:5e:37:54:9e:ba:96:25:10:04:18:23:f7:
+ ae:73:4d:d0:aa:03:81:b4:89:36:97:15:da:1a:60:a0:98:5f:
+ 03:f8:1b:22:83:57:41:4b:12:28:7d:8d:ea:88:74:24:28:5c:
+ 53:41:89:5e:9a:da:fd:7b:bf:60:dc:de:9b:49:ce:5c:a3:b2:
+ 01:7d:1d:cb:28:8c:ba:f4:7b:5d:2b:cb:15:5b:2a:97:1a:d1:
+ f9:e7:12:e3:43:b9:f4:2a:88:dd:6d:b6:a0:72:d3:bd:63:23:
+ e9:d7:f0:ac:b5:6d:0d:f2:d9:8b:2c:c4:35:5b:4d:83:dc:e8:
+ 7d:0b:3d:a3
+-----BEGIN CERTIFICATE-----
+MIIDhTCCAm2gAwIBAgIRAOcJq3C13B8R2SojBDmHNPMwDQYJKoZIhvcNAQELBQAw
+FjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMjYwNDI5MTIwOTM1WhcNMjgwODAx
+MTIwOTM1WjAlMSMwIQYDVQQDDBpleGFtcGxlX3NzbF9yY3BtX3JjcHNfY2VydDCC
+ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMx5m9PzH0GfAEjNRwuuuRxO
+PlXiTl+ozBPV3b3wAUwZruOpCQaJkkn3u5Ao+4wiabX1oFA9lw8eHbGoV5vX4g2Z
+Z38Cggycjt0TAyiTtct+tXgGEL97VcP3EIsgShz58bL68cdEnQrO74356P/RwWns
+jl8RzMmY1Rwz4lt6TTTcdsPN20yT0Qh4bzya7nQ5Hs1lHsk1zDsrntdJEI5YhbAQ
+W5Ae8V7VkgST+TPGnXdj0TNGW5j/mqj13/eEIeKIKHqkxg2fJX4Nc1vVU0qQeZQ3
+FPPIdXbUHDJRv1gWdNWNGLZT9KvLkaiMo8o8XDW2X2JXN1p1KLdNJqrqUNqkHFUC
+AwEAAaOBvjCBuzAJBgNVHRMEAjAAMB0GA1UdDgQWBBRHkrWBi1wUmLODtusGn0Pz
+On7tJDBRBgNVHSMESjBIgBS6zY4Dd0pJFrvWLmyjn0vDTFqVq6EapBgwFjEUMBIG
+A1UEAwwLRWFzeS1SU0EgQ0GCFBL9X9Mo1bZK/kTWCyLWd93kycBzMBMGA1UdJQQM
+MAoGCCsGAQUFBwMBMAsGA1UdDwQEAwIFoDAaBgNVHREEEzARggkxMjcuMC4wLjGH
+BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAG0x5inSO6iQXEusYRWVXXBmpXediEdJ
+c3W+cGnYL2KCXoOGO6hIP/FfIq6BI2TE8ivdTb7laial6se6Gz5qNANa8UkoX1ZK
+pg4begdIdpW2S/U/uWcu4DMGgNTWAaV2AcClGOU4i1Jzbm1FULeaq4Zd42W0uMfu
+sty/49W75JHr9Qw4Il43VJ66liUQBBgj965zTdCqA4G0iTaXFdoaYKCYXwP4GyKD
+V0FLEih9jeqIdCQoXFNBiV6a2v17v2Dc3ptJzlyjsgF9HcsojLr0e10ryxVbKpca
+0fnnEuNDufQqiN1ttqBy071jI+nX8Ky1bQ3y2YssxDVbTYPc6H0LPaM=
+-----END CERTIFICATE-----
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMeZvT8x9BnwBI
+zUcLrrkcTj5V4k5fqMwT1d298AFMGa7jqQkGiZJJ97uQKPuMImm19aBQPZcPHh2x
+qFeb1+INmWd/AoIMnI7dEwMok7XLfrV4BhC/e1XD9xCLIEoc+fGy+vHHRJ0Kzu+N
++ej/0cFp7I5fEczJmNUcM+Jbek003HbDzdtMk9EIeG88mu50OR7NZR7JNcw7K57X
+SRCOWIWwEFuQHvFe1ZIEk/kzxp13Y9EzRluY/5qo9d/3hCHiiCh6pMYNnyV+DXNb
+1VNKkHmUNxTzyHV21BwyUb9YFnTVjRi2U/Sry5GojKPKPFw1tl9iVzdadSi3TSaq
+6lDapBxVAgMBAAECggEADJFN6K9OWhYX1PcEWUgOLxqdCLd95Iccsfxot7ekcMUP
+A4WnHRyACLqor9c2V3o2//IpU2fnB2IXu6ISmRd3WKl3hm4vnZmoIJeTpQm9Iv/g
++fqkyrbIgktcHDJUySal+n+jiYFNW2B1h1xXUT/scMz+FthNJg1Azfi0vorMFjCk
+SBOSo7BQ2hiQ83FneVJU1TsxD4S4IBLx9fF6AW05norRmvm17Ip2pKk4QYzKiOI3
+NIoSwbOgU0Vp1X3MMlilM5ZZdN3a9lI6lfBZE682WOxBmH67mKMQnR8aC+nwynQ+
+45pUQIn8Fjx2hQHehbD7ZNw9Nob9AyGWqWGKFV74IQKBgQDox/j7dCHNm1mz1QRm
+Q2YyN4OGXJI97t9Gd8UdNCv5bAkdx2cR2lmP3tRc6iAzrfNIpPCGaXo1vwJx477X
+wO95W2b4hfm3j6v0cqRbsFzzVIHZUB77pXfAJfZeICpoqu0vxn5nb+yPgzgmToLX
+pbIDdqWafzzrDLTmLCfwfKDI9QKBgQDg3tgpAXo8WCL4phNuW44XQ172lPTijpn+
+wj1Z0rBrS806gL/+QvZZLS1WCym/QBV7TgGlxIJbmAfghcGyin3NliskSHAiccxG
+/9eCSQes8czfsVj6qmBMwyff5r+wmk662qV0u07UHmuykYk3Dgs/zYdwq2SsTlL4
+Y9eRjutp4QKBgQCONg0wYcR8/hmROeRULXzz1OJvZYKaf6K8RFOSAduTp6LyJG4d
+hA4PTQzkLsy5hd4JVWr0UuAskaMGvSJMYTxsIaEI16C1ufpNfvRWZ6qBpfEmOEKV
+boN4Sjj3TCNcioAZHeT/gGs/SeU10eUxpbLZVtTZTD6FQuAJdpR34UvBOQKBgFNM
+mXxPLM2vxHyhYK9PwQoDDel/8lr+gjMqFvnwHyQP911FllmEyqbsIlAuYG+VOJ/t
+nJSgf72YSsq0IbWWsdV3XFHbd5Z62zYtzdJYZTx+cesnUhPBC11EKcA6RSYRczqq
+hgIA5MmU30ZNvSukyyv+Yb6t7uQZO4kByzgDXldhAoGBALgRkAHxgbKUXp5XyCDJ
+e8dwVx0g9tfDM/DEZtU/Si5oUaunBaPV/Byov7OXOT02V8JLnA5ChUYgwUFI030x
+QL/3eK12Qh5Gb9VabvYCicDRk4GzmqZU9Wcvm1zgbUr5jY8Lou44nFjol/Y1m70n
+51WZbVkkWmBZO5m3NqN66SkJ
+-----END PRIVATE KEY-----
diff --git a/contrib/rcp/usage_example/certs/example_ssl_rcps_rcpm_cert.pem b/contrib/rcp/usage_example/certs/example_ssl_rcps_rcpm_cert.pem
new file mode 100644
index 00000000..5adc3c17
--- /dev/null
+++ b/contrib/rcp/usage_example/certs/example_ssl_rcps_rcpm_cert.pem
@@ -0,0 +1,115 @@
+Certificate:
+ Data:
+ Version: 3 (0x2)
+ Serial Number:
+ 28:96:2e:a1:40:e0:7e:f1:fb:63:1a:f4:53:6f:ce:fb
+ Signature Algorithm: sha256WithRSAEncryption
+ Issuer: CN=Easy-RSA CA
+ Validity
+ Not Before: Apr 29 12:09:35 2026 GMT
+ Not After : Aug 1 12:09:35 2028 GMT
+ Subject: CN=example_ssl_rcps_rcpm_cert
+ Subject Public Key Info:
+ Public Key Algorithm: rsaEncryption
+ Public-Key: (2048 bit)
+ Modulus:
+ 00:ae:11:46:ef:d1:81:34:dd:23:5d:54:40:f3:9c:
+ 85:35:95:a6:91:57:92:5c:bf:eb:40:34:69:eb:db:
+ c0:86:3c:7b:ff:9c:d7:ba:0e:41:57:84:15:cd:94:
+ f1:48:63:50:9c:34:97:ee:be:be:b0:27:d8:fd:cd:
+ 8a:cf:85:ff:08:1f:07:d8:28:96:0e:e4:2d:d0:8b:
+ df:a8:fa:41:47:a0:a2:80:2e:2e:58:01:cc:6f:43:
+ 5c:c2:fb:84:a7:ff:9e:97:bb:b3:a3:1f:63:64:73:
+ 8d:73:dd:f4:7e:96:d7:6b:b3:cb:e2:35:59:55:e0:
+ e7:e3:c0:41:f8:b6:0f:c5:46:4c:cd:0e:91:80:ef:
+ e3:43:f0:72:26:12:10:be:83:a2:db:23:2d:b4:b1:
+ 07:5a:b1:b3:10:9c:09:69:98:42:79:81:77:5e:22:
+ e4:71:47:70:27:15:2c:a7:13:c2:6d:44:59:b4:73:
+ c9:bb:27:7f:d6:e8:3d:85:bb:36:f6:cb:71:36:11:
+ b1:99:1a:1d:1a:15:dd:cd:65:7f:cd:cc:10:00:49:
+ ed:07:2d:7b:15:88:be:73:ba:1d:15:69:bc:d3:02:
+ 55:ea:dc:2c:3f:0b:cd:18:57:59:7a:e3:09:b2:89:
+ cd:d6:e7:f6:95:c4:2e:8a:53:2b:a8:96:82:94:53:
+ 00:77
+ Exponent: 65537 (0x10001)
+ X509v3 extensions:
+ X509v3 Basic Constraints:
+ CA:FALSE
+ X509v3 Subject Key Identifier:
+ 60:BD:48:06:68:15:D4:DC:ED:EE:E4:C7:B1:9F:C4:93:6D:50:3A:77
+ X509v3 Authority Key Identifier:
+ keyid:BA:CD:8E:03:77:4A:49:16:BB:D6:2E:6C:A3:9F:4B:C3:4C:5A:95:AB
+ DirName:/CN=Easy-RSA CA
+ serial:12:FD:5F:D3:28:D5:B6:4A:FE:44:D6:0B:22:D6:77:DD:E4:C9:C0:73
+ X509v3 Extended Key Usage:
+ TLS Web Server Authentication
+ X509v3 Key Usage:
+ Digital Signature, Key Encipherment
+ X509v3 Subject Alternative Name:
+ DNS:127.0.0.1, IP Address:127.0.0.1
+ Signature Algorithm: sha256WithRSAEncryption
+ Signature Value:
+ c5:35:61:58:23:e2:69:da:6c:d5:41:ab:a8:70:f4:dd:cc:a0:
+ a3:3d:84:89:93:b6:7f:69:7d:10:35:9d:c5:d1:0d:db:d2:d7:
+ 36:af:d4:54:30:14:a7:5d:31:ca:5c:13:92:d5:60:50:f8:56:
+ 4a:cb:16:b1:b3:b1:03:bf:96:53:77:1f:4a:0f:9c:29:2b:bf:
+ a4:e0:da:6f:ad:13:c7:2d:8e:18:c4:72:50:17:ed:1f:36:51:
+ 7a:12:9f:fc:a6:d6:c8:55:e0:db:ea:16:d6:22:0d:a2:cb:eb:
+ b2:ba:07:92:2f:db:33:d6:a2:0c:ec:89:29:f1:96:40:e5:0b:
+ e6:1f:08:50:d6:29:87:a8:20:b2:e2:17:50:25:ff:53:36:ee:
+ 7f:ce:e6:1d:ed:b3:16:61:18:42:a9:17:9e:a6:86:0d:a5:fc:
+ f9:42:c8:50:48:74:72:35:eb:8c:ff:4d:e8:98:88:a0:b4:b3:
+ d0:82:b3:2f:ea:19:d7:d5:ac:47:35:96:24:37:34:0c:7a:a2:
+ e0:4d:99:a7:55:61:85:1e:7e:6a:23:77:f5:07:13:e6:50:5c:
+ 65:00:13:f6:b5:4b:5b:8c:11:c3:5d:af:ba:41:e9:84:1d:f1:
+ a4:70:16:28:c2:be:6e:d8:67:38:c5:a0:ba:8a:64:6f:27:ce:
+ 63:a0:92:9b
+-----BEGIN CERTIFICATE-----
+MIIDhDCCAmygAwIBAgIQKJYuoUDgfvH7Yxr0U2/O+zANBgkqhkiG9w0BAQsFADAW
+MRQwEgYDVQQDDAtFYXN5LVJTQSBDQTAeFw0yNjA0MjkxMjA5MzVaFw0yODA4MDEx
+MjA5MzVaMCUxIzAhBgNVBAMMGmV4YW1wbGVfc3NsX3JjcHNfcmNwbV9jZXJ0MIIB
+IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArhFG79GBNN0jXVRA85yFNZWm
+kVeSXL/rQDRp69vAhjx7/5zXug5BV4QVzZTxSGNQnDSX7r6+sCfY/c2Kz4X/CB8H
+2CiWDuQt0IvfqPpBR6CigC4uWAHMb0NcwvuEp/+el7uzox9jZHONc930fpbXa7PL
+4jVZVeDn48BB+LYPxUZMzQ6RgO/jQ/ByJhIQvoOi2yMttLEHWrGzEJwJaZhCeYF3
+XiLkcUdwJxUspxPCbURZtHPJuyd/1ug9hbs29stxNhGxmRodGhXdzWV/zcwQAEnt
+By17FYi+c7odFWm80wJV6twsPwvNGFdZeuMJsonN1uf2lcQuilMrqJaClFMAdwID
+AQABo4G+MIG7MAkGA1UdEwQCMAAwHQYDVR0OBBYEFGC9SAZoFdTc7e7kx7GfxJNt
+UDp3MFEGA1UdIwRKMEiAFLrNjgN3SkkWu9YubKOfS8NMWpWroRqkGDAWMRQwEgYD
+VQQDDAtFYXN5LVJTQSBDQYIUEv1f0yjVtkr+RNYLItZ33eTJwHMwEwYDVR0lBAww
+CgYIKwYBBQUHAwEwCwYDVR0PBAQDAgWgMBoGA1UdEQQTMBGCCTEyNy4wLjAuMYcE
+fwAAATANBgkqhkiG9w0BAQsFAAOCAQEAxTVhWCPiadps1UGrqHD03cygoz2EiZO2
+f2l9EDWdxdEN29LXNq/UVDAUp10xylwTktVgUPhWSssWsbOxA7+WU3cfSg+cKSu/
+pODab60Txy2OGMRyUBftHzZRehKf/KbWyFXg2+oW1iINosvrsroHki/bM9aiDOyJ
+KfGWQOUL5h8IUNYph6ggsuIXUCX/Uzbuf87mHe2zFmEYQqkXnqaGDaX8+ULIUEh0
+cjXrjP9N6JiIoLSz0IKzL+oZ19WsRzWWJDc0DHqi4E2Zp1VhhR5+aiN39QcT5lBc
+ZQAT9rVLW4wRw12vukHphB3xpHAWKMK+bthnOMWguopkbyfOY6CSmw==
+-----END CERTIFICATE-----
+-----BEGIN PRIVATE KEY-----
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCuEUbv0YE03SNd
+VEDznIU1laaRV5Jcv+tANGnr28CGPHv/nNe6DkFXhBXNlPFIY1CcNJfuvr6wJ9j9
+zYrPhf8IHwfYKJYO5C3Qi9+o+kFHoKKALi5YAcxvQ1zC+4Sn/56Xu7OjH2Nkc41z
+3fR+ltdrs8viNVlV4OfjwEH4tg/FRkzNDpGA7+ND8HImEhC+g6LbIy20sQdasbMQ
+nAlpmEJ5gXdeIuRxR3AnFSynE8JtRFm0c8m7J3/W6D2Fuzb2y3E2EbGZGh0aFd3N
+ZX/NzBAASe0HLXsViL5zuh0VabzTAlXq3Cw/C80YV1l64wmyic3W5/aVxC6KUyuo
+loKUUwB3AgMBAAECggEABGfLiSZJmYeUmDgZrLtkDlx16sJx9zGkR+u2V+cn6D3U
++uiCoo2EedfjSrYKT/AI35Xf19sGrc6ptJgPJfwY3aEWFxJv5HtB7ZVHWS98QiPT
++QqHgb1fPzGlQgoQ7Bo8GVBW1joPz0Bdbsv0ntTn1CyowauNUe8Z71mzpxJ0iQ7u
+Z8LNoD7INEAZDBjHVov6pLGDHS9KFxGy20WG549mE37I4QxyxetJEgmuyhJkZOQ4
+noEWiCMQjGsSg4YuSc1GS1jAVf3p2g3/TiheD/31r1jleY/T5s2qYC6MJ4vY+7yA
+5sl7m8A47i0lHSKBdR3nWz+vXEsxB0nXK3Sulyt9AQKBgQDYRZ1nEe32CCZcWn/7
+nG+9e6XNOe9E25skuvY/uEpikt92kdnyZOgHFfwM9Nv+w2IWGLZ8MmDDJ6/4hGvj
+fJjcOUb3d/SJiivPvdRC9GYMrDUZe5AJ3p6fPqi4IeZOw7bMTcVB6aHAr9KoO//J
+2t0WNvwzkOyl6KlhaQEIDK8bOQKBgQDOCvWvUEg8nHaKcmN0uQaKuPvCorhnyqUT
+VFqLlSrYC79ffHCwp2y8nkFUnxpeHXENrtHtcrdnKBNkgGEn2l4xs63CjhKrBuIG
+bDjrtp3vKlHRhQjX6HkrEEuUk52wYzuX7CfU8nTZnrtV74MocOEewJVW+84Rtwiw
+vIUcgfgJLwKBgAbgj9TLOSntsGqXZiJ2Iwd/exI/mWAzK4fLejEkhxkDWp/Gm4ud
+sdMn28/9qVE8nU3ek073uyP5ixr3+wZM2/+EwsDzy47kGeiNPMa0Rtp4T2f0CeyG
+a7zcnTjduxkeGB3/CxrBdydNcAFxhvzAPO+L6BErtpq//0Ldt+6tmJPhAoGAFxEh
+Cjx5qdd2ae9+dO3V7qfg/5xJ+sy0CGL0NBZCEqfWB/GdiBlmUgOBmuCpCgpPwtFk
+jSm/oJva9/BrcBPBYd0Uweg37M+7dC6ffLwYGFNrj4JOSCWtkwWjAII6MCob3NlC
+aFOwg0CDBo7m5xskCNZUocVU/6S3I1onqNZgF18CgYAczBf1NS4VPZvebrutTBEH
+zyUX3XU9mR+dy0ncCGNVS8zYMtz7cweZInzNB2cTOfisIHzzdOnazm7D9uzPsREi
+pKgaL+ErWYDlGiDTxMtGSRPTWGocYBYdU6y/0bobhZb0qyvyRhpGvPK0ReMUuvqu
+FkNgoQ1lo0n6vawvxWW8Mw==
+-----END PRIVATE KEY-----
diff --git a/contrib/rcp/usage_example/certs/make_certs.sh b/contrib/rcp/usage_example/certs/make_certs.sh
new file mode 100755
index 00000000..a3d91644
--- /dev/null
+++ b/contrib/rcp/usage_example/certs/make_certs.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+EASYRSA=/usr/share/easy-rsa/easyrsa
+CA_NAME="example_ssl_rcp_ca_cert"
+
+export EASYRSA_PASSIN=pass:test
+export EASYRSA_PASSOUT=pass:test
+
+echo "Cleaning up..."
+rm -rf ./ca
+rm -rf ./*.pem
+rm -rf ./*.key
+rm -rf ./*.crt
+
+echo "Creating CA cert..."
+mkdir -p ./ca
+cd ./ca
+$EASYRSA init-pki
+cp ../vars ./pki/
+$EASYRSA --batch build-ca
+cp ./pki/ca.crt ../$CA_NAME.crt
+
+echo "Creating server certs..."
+# Secures connection between RCP-Client and RCP-Server:
+$EASYRSA --batch --subject-alt-name="DNS:127.0.0.1,IP:127.0.0.1" build-server-full example_ssl_rcpc_rcps_cert nopass
+
+# Secures connection between RCP-Module and RCP-Server (module description):
+$EASYRSA --batch --subject-alt-name="DNS:127.0.0.1,IP:127.0.0.1" build-server-full example_ssl_rcpm_rcps_cert nopass
+
+# Secures connection between RCP-Server and RCP-Module (command execution):
+$EASYRSA --batch --subject-alt-name="DNS:127.0.0.1,IP:127.0.0.1" build-server-full example_ssl_rcps_rcpm_cert nopass
+
+echo "Collecting server certs..."
+cp ./pki/issued/* ../
+cp ./pki/private/* ../
+cd ..
+rm ./ca.key
+
+echo "Merging server certs..."
+for CRT in ./*.crt; do
+ CRT_NAME=`basename ${CRT%.*}`
+ if [ -f $CRT_NAME.key ]; then
+ cat $CRT_NAME.crt $CRT_NAME.key > $CRT_NAME.pem
+ rm $CRT_NAME.key
+ rm $CRT_NAME.crt
+ fi
+done
+
+echo "Finalizing..."
+rm -rf ./ca
diff --git a/contrib/rcp/usage_example/encrypt_card_data.sh b/contrib/rcp/usage_example/encrypt_card_data.sh
new file mode 100755
index 00000000..7f7b635b
--- /dev/null
+++ b/contrib/rcp/usage_example/encrypt_card_data.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+. ./params.cfg
+
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/csv-encrypt-columns.py \
+ --csv-column-key kic:$CSV_COLUMN_KEY \
+ --csv-column-key kid:$CSV_COLUMN_KEY \
+ --csv-column-key kik:$CSV_COLUMN_KEY \
+ card_data.csv
diff --git a/contrib/rcp/usage_example/params.cfg b/contrib/rcp/usage_example/params.cfg
new file mode 100644
index 00000000..0463a3d6
--- /dev/null
+++ b/contrib/rcp/usage_example/params.cfg
@@ -0,0 +1,36 @@
+# Verbosity switch passed to all components (comment-out to disable verbose mode)
+#VERBOSE="--verbose"
+
+# PYSIM_DIR passed to all components
+PYSIM_DIR=../../../ # Points to the psyim top directory
+
+# CSV column key to decrypt KIC, KID and KIK in csv_data.csv.encr
+# (use encrypt_card_data.sh to regenerate csv_data.csv.encr from csv_data.csv)
+CSV_COLUMN_KEY="00112233445566778899AABBCCDDEEFF"
+
+# PCSC reader that the RCP Client shall use
+PCSC_READER=0
+
+# CA of the certificates used in this example
+CERT_DIR="./certs"
+CA_CERT="$CERT_DIR/example_ssl_rcp_ca_cert.crt"
+
+# Network interface where RCP Clients connect
+RCPC_SERVER_PORT=8000
+RCPC_SERVER_ADDR="127.0.0.1"
+RCPC_SERVER_CERT="$CERT_DIR/example_ssl_rcpc_rcps_cert.pem"
+RCPC_SERVER_URI="wss://$RCPC_SERVER_ADDR:$RCPC_SERVER_PORT"
+
+# Network interface where RCP Modules connect
+RCPM_SERVER_PORT=8010
+RCPM_SERVER_ADDR="127.0.0.1"
+RCPM_SERVER_CERT="$CERT_DIR/example_ssl_rcpm_rcps_cert.pem"
+RCPM_SERVER_URI="wss://$RCPM_SERVER_ADDR:$RCPM_SERVER_PORT"
+
+# Network interface where the (example) RCP Module binds its Command Server to.
+# The command server is used by the RCP Server to run the command requested
+# by the user. Each module needs a dedicated port. The address and port is
+# automatically forwarded to the RCP Server.
+RCPM_CMD_SERVER_PORT=8020
+RCPM_CMD_SERVER_ADDR="127.0.0.1"
+RCPM_CMD_SERVER_CERT="$CERT_DIR/example_ssl_rcps_rcpm_cert.pem"
diff --git a/contrib/rcp/usage_example/rcp_module.py b/contrib/rcp/usage_example/rcp_module.py
new file mode 100755
index 00000000..6bc36dad
--- /dev/null
+++ b/contrib/rcp/usage_example/rcp_module.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+
+# (C) 2026 by sysmocom - s.f.m.c. GmbH
+# All Rights Reserved
+#
+# Author: Philipp Maier
+#
+# 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 logging
+from pathlib import Path
+from pySim.log import PySimLogger
+from argparse import Namespace
+from pySim.global_platform import GpCardKeyset, SCP02, ADF_SD
+from Cryptodome.Random import get_random_bytes
+from osmocom.utils import h2b, b2h
+from rcp_module_utils import rcpm_setup_argparse, rcpm_run_module, RcpModule, RcpModuleHdlr
+
+log = PySimLogger.get(Path(__file__).stem)
+option_parser = rcpm_setup_argparse("Example Module")
+
+class ExmpleModule(RcpModule):
+
+ def __init__(self, *args, **kwargs):
+ log.info("rcpm_run_module was called with the following additional arguments:")
+ log.info("%s, %s", str(args), str(kwargs))
+
+ name = 'rcp_module'
+ cmd_descr = [{'name' : 'reset',
+ 'help': 'reset the card',
+ 'args' : []},
+ {'name' : 'read_binary',
+ 'help': 'read binary data from a transparent file.',
+ 'args' : [{ 'name' : '--fid',
+ 'spec' : {'required' : True,
+ 'help' : 'File identifier to of the file to read',
+ 'action' : 'append',
+ 'pytype' : 'str'},
+ }
+ ]},
+ {'name' : 'read_record',
+ 'help': 'read binary data from a transparent file.',
+ 'args' : [{ 'name' : '--fid',
+ 'spec' : {'required' : True,
+ 'help' : 'File identifier to of the file to read',
+ 'action' : 'append',
+ 'pytype' : 'str'},
+ },
+ { 'name' : '--record',
+ 'spec' : {'required' : True,
+ 'help' : 'File record to read',
+ 'default' : 1,
+ 'pytype' : 'int'},
+ }
+ ]},
+ {'name' : 'unlock_aram',
+ 'help': 'unlock a locked ARA-M applet on a sysmoISIM-SJA5',
+ 'args' : [],
+ 'get_keys' : {'uicc' : ['KIC', 'KID', 'KIK']}}
+ ]
+ suitable_for = [{'atr' : '3b9f96801f878031e073fe211b674a357530350265f8'}]
+
+ def cmd_reset(self, hdlr: RcpModuleHdlr) -> int:
+ hdlr.print("resetting UICC/eUICC ...")
+ hdlr.card._scc.reset_card()
+ hdlr.print("ATR is: %s" % hdlr.card._scc.get_atr())
+ return 0
+
+ def cmd_read_binary(self, hdlr: RcpModuleHdlr) -> int:
+ fid = hdlr.cmd_args.fid
+ hdlr.print("reading transparent file: %s ..." % fid)
+ (res, _) = hdlr.card._scc.read_binary(fid)
+ hdlr.print("file content is: %s" % res)
+ return 0
+
+ def cmd_read_record(self, hdlr: RcpModuleHdlr) -> int:
+ fid = hdlr.cmd_args.fid
+ record = hdlr.cmd_args.record
+ hdlr.print("reading linear-fixed file: %s ..." % fid)
+ (res, _) = hdlr.card._scc.read_record(fid, record)
+ hdlr.print("file content is: %s" % res)
+ return 0
+
+ def cmd_unlock_aram(self, hdlr: RcpModuleHdlr) -> int:
+ # Select ADF.ISD
+ hdlr.print("Selecting ADF.ISD ...")
+ hdlr.lchan.scc.send_apdu_checksw("00a4040408a00000000300000000")
+
+ # Establish secure channel
+ hdlr.print("Establishing secure channel ...")
+ key_ver = 112
+ key_enc = hdlr.keys_uicc['KIC']
+ key_mac = hdlr.keys_uicc['KID']
+ key_dek = hdlr.keys_uicc['KIK']
+ security_level = 3
+ host_challenge_len = 8
+ host_challenge = get_random_bytes(host_challenge_len)
+ kset = GpCardKeyset(key_ver, h2b(key_enc), h2b(key_mac), h2b(key_dek))
+ scp = SCP02(card_keys=kset)
+ ADF_SD.establish_scp(hdlr.lchan.scc, scp, host_challenge, security_level)
+
+ # To prove that it works, we need to do something that actually requires to be authenticated
+ # via a secure channel. In this example we will send an unlock command to the ARA-M applet
+ # found on any sysmoISIM-SJA5 card. (see also: https://gitea.osmocom.org/sim-card/aram-applet)
+ hdlr.print("Unlocking ARA-M applet ...")
+ ara_m_aid = "a00000015141434c00"
+ ADF_SD.install(hdlr.lchan.scc, 0x20, 0x00, "0000%02x%s000000" % (len(ara_m_aid) // 2, ara_m_aid))
+ ADF_SD.store_data(hdlr.lchan.scc, h2b("A2"), structure = 'ber_tlv')
+
+ # Release the secure channel
+ hdlr.print("Done, releasing secure channel ...")
+ ADF_SD.release_scp(hdlr.lchan.scc)
+ return 0
+
+if __name__ == '__main__':
+ opts = option_parser.parse_args()
+ rcpm_run_module(opts, ExmpleModule,
+ "arg1", "arg2", "arg3",
+ kwarg1="kwarg1", kwarg2="kwarg2", kwarg3="kwarg3")
diff --git a/contrib/rcp/usage_example/readme.txt b/contrib/rcp/usage_example/readme.txt
new file mode 100644
index 00000000..4bef46df
--- /dev/null
+++ b/contrib/rcp/usage_example/readme.txt
@@ -0,0 +1,16 @@
+How to try:
+
+Go to the directory that contains the usage example:
+cd pysim/contrib/rcp/usage_example
+
+Edit card_data.csv to fill in the SCP02 keys for the ISD of your sysmoISIM-SJA5
+
+Start the RCP Server:
+./start_rcp_server.sh
+
+Start the RCP Module:
+ ./start_rcp_module.sh
+
+Run the exmple scripts:
+./run_rcp_client.sh
+(it is also possible to call the run_rcp_client_*.sh scripts individually)
diff --git a/contrib/rcp/usage_example/run_rcp_client.sh b/contrib/rcp/usage_example/run_rcp_client.sh
new file mode 100755
index 00000000..4d32be24
--- /dev/null
+++ b/contrib/rcp/usage_example/run_rcp_client.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+
+echo "basic help"
+echo "===================================================================================="
+./run_rcp_client_help.sh
+echo "===================================================================================="
+echo ""
+echo ""
+
+echo "help for which commands are available"
+echo "===================================================================================="
+./run_rcp_client_help_cmd.sh
+echo "===================================================================================="
+echo ""
+echo ""
+
+echo "help for specific commands"
+echo "===================================================================================="
+./run_rcp_client_help_cmd_specific.sh
+echo "===================================================================================="
+echo ""
+echo ""
+
+echo "run specific RCP commands"
+echo "===================================================================================="
+./run_rcp_client_cmd.sh
+echo "===================================================================================="
+echo ""
+echo ""
diff --git a/contrib/rcp/usage_example/run_rcp_client_cmd.sh b/contrib/rcp/usage_example/run_rcp_client_cmd.sh
new file mode 100755
index 00000000..747dfdcc
--- /dev/null
+++ b/contrib/rcp/usage_example/run_rcp_client_cmd.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+. ./params.cfg
+
+set -x
+
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/rcp/rcp_client.py $VERBOSE \
+ --uri $RCPC_SERVER_URI\
+ --ca-cert $CA_CERT \
+ -p $PCSC_READER \
+ rcp_module_reset
+
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/rcp/rcp_client.py $VERBOSE \
+ --uri $RCPC_SERVER_URI \
+ --ca-cert $CA_CERT \
+ -p $PCSC_READER \
+ rcp_module_read_binary --fid 3f00 --fid 2fe2
+
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/rcp/rcp_client.py $VERBOSE \
+ --uri $RCPC_SERVER_URI \
+ --ca-cert $CA_CERT \
+ -p $PCSC_READER \
+ rcp_module_read_record --fid 3f00 --fid 2f00 --record 1
+
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/rcp/rcp_client.py $VERBOSE \
+ --uri $RCPC_SERVER_URI \
+ --ca-cert $CA_CERT \
+ -p $PCSC_READER \
+ rcp_module_unlock_aram
diff --git a/contrib/rcp/usage_example/run_rcp_client_help.sh b/contrib/rcp/usage_example/run_rcp_client_help.sh
new file mode 100755
index 00000000..69877296
--- /dev/null
+++ b/contrib/rcp/usage_example/run_rcp_client_help.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+. ./params.cfg
+
+set -x
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/rcp/rcp_client.py $VERBOSE \
+ -h
diff --git a/contrib/rcp/usage_example/run_rcp_client_help_cmd.sh b/contrib/rcp/usage_example/run_rcp_client_help_cmd.sh
new file mode 100755
index 00000000..20bde597
--- /dev/null
+++ b/contrib/rcp/usage_example/run_rcp_client_help_cmd.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+. ./params.cfg
+
+set -x
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/rcp/rcp_client.py $VERBOSE \
+ --uri $RCPC_SERVER_URI \
+ --ca-cert $CA_CERT \
+ -p $PCSC_READER \
+ -h
diff --git a/contrib/rcp/usage_example/run_rcp_client_help_cmd_specific.sh b/contrib/rcp/usage_example/run_rcp_client_help_cmd_specific.sh
new file mode 100755
index 00000000..a7198388
--- /dev/null
+++ b/contrib/rcp/usage_example/run_rcp_client_help_cmd_specific.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+. ./params.cfg
+
+set -x
+
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/rcp/rcp_client.py $VERBOSE \
+ --uri $RCPC_SERVER_URI \
+ --ca-cert $CA_CERT \
+ -p $PCSC_READER \
+ rcp_module_reset --help
+
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/rcp/rcp_client.py $VERBOSE \
+ --uri $RCPC_SERVER_URI \
+ --ca-cert $CA_CERT \
+ -p $PCSC_READER \
+ rcp_module_read_binary --help
+
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/rcp/rcp_client.py $VERBOSE \
+ --uri $RCPC_SERVER_URI \
+ --ca-cert $CA_CERT \
+ -p $PCSC_READER \
+ rcp_module_read_record --help
+
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/rcp/rcp_client.py $VERBOSE \
+ --uri $RCPC_SERVER_URI \
+ --ca-cert $CA_CERT \
+ -p $PCSC_READER \
+ rcp_module_unlock_aram --help
diff --git a/contrib/rcp/usage_example/start_rcp_module.sh b/contrib/rcp/usage_example/start_rcp_module.sh
new file mode 100755
index 00000000..7ac7f80d
--- /dev/null
+++ b/contrib/rcp/usage_example/start_rcp_module.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+. ./params.cfg
+
+set -x
+PYTHONPATH=$PYSIM_DIR:$PYSIM_DIR/contrib/rcp ./rcp_module.py $VERBOSE \
+ --uri $RCPM_SERVER_URI \
+ --rcps-ca-cert $CA_CERT \
+ --rcpm-cmd-server-addr $RCPM_CMD_SERVER_ADDR \
+ --rcpm-cmd-server-port $RCPM_CMD_SERVER_PORT \
+ --rcpm-cmd-server-cert $RCPM_CMD_SERVER_CERT \
+ --column-key kic:$CSV_COLUMN_KEY \
+ --column-key kid:$CSV_COLUMN_KEY \
+ --column-key kik:$CSV_COLUMN_KEY
+
diff --git a/contrib/rcp/usage_example/start_rcp_server.sh b/contrib/rcp/usage_example/start_rcp_server.sh
new file mode 100755
index 00000000..a7f61148
--- /dev/null
+++ b/contrib/rcp/usage_example/start_rcp_server.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+. ./params.cfg
+
+set -x
+PYTHONPATH=$PYSIM_DIR $PYSIM_DIR/contrib/rcp/rcp_server.py $VERBOSE \
+ --rcpc-server-addr $RCPC_SERVER_ADDR \
+ --rcpc-server-port $RCPC_SERVER_PORT \
+ --rcpc-server-cert $RCPC_SERVER_CERT \
+ --rcpm-server-addr $RCPM_SERVER_ADDR \
+ --rcpm-server-port $RCPM_SERVER_PORT \
+ --rcpm-server-cert $RCPM_SERVER_CERT \
+ --rcpm-module-ca-cert $CA_CERT \
+ --csv ./card_data.csv.encr
diff --git a/docs/conf.py b/docs/conf.py
index 2e23faea..ec4407fa 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -14,6 +14,8 @@ import os
import sys
sys.path.insert(0, os.path.abspath('..'))
sys.path.insert(0, os.path.abspath('.')) # for local extensions (pysim_fs_sphinx, ...)
+sys.path.insert(0, os.path.abspath('../contrib/rcp')) # for argparse
+sys.path.insert(0, os.path.abspath('../contrib/rcp/usage_example')) # for argparse
# -- Project information -----------------------------------------------------
@@ -42,6 +44,7 @@ extensions = [
"sphinx.ext.autosectionlabel",
"sphinx.ext.napoleon",
"pysim_fs_sphinx",
+ "sphinx.ext.graphviz",
]
# Add any paths that contain templates here, relative to this directory.
diff --git a/docs/index.rst b/docs/index.rst
index 8908c4ed..2396aa9d 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -50,6 +50,7 @@ pySim consists of several parts:
suci-keytool
saip-tool
smpp-ota-tool
+ rcpf
Indices and tables
diff --git a/docs/rcpf.rst b/docs/rcpf.rst
new file mode 100644
index 00000000..fb21a11c
--- /dev/null
+++ b/docs/rcpf.rst
@@ -0,0 +1,690 @@
+Remote Card Procedure Framework
+===============================
+
+The Remote Card Procedure Framework `(RCPF)` is a modular system to provide custom,
+remote controlled, procedures to card `(UICC or eUICC)` holders. The card holder
+uses a minimal client program `(RCP Client)` together with a PC/SC reader. The
+client program will then connect to a remote server `(RCP Server)`. The remote
+server maintains a list and connections to custom modules `(RCP Modules)`, where
+each module implements a set of procedures (commands). Based on its internal list,
+the remote server will offer a set of suitable commands to the client. The card
+holder may then chose a command to request the execution of a specific remote
+card procedure. The server will make the connection to the matching module and act
+as a proxy between the module and the client program.
+
+.. graphviz::
+
+ digraph foo {
+
+ subgraph cluster_server {
+ label = "server (card issuer)"
+ RCPS [label = "RCP Server"];
+ RCPM [label = "RCP Module"];
+ CKP [label = "CardKeyProvider"];
+ }
+
+ subgraph cluster_field {
+ label = "field (card holder)"
+ ICC [label = "UICC/eUICC"];
+ RCPC [label = "RCP Client"];
+ }
+
+ RCPC -> ICC [label="PC/SC, APDU"];
+ RCPC -> RCPS [label="WS, JSON"];
+ RCPS -> CKP [label="pgSQL or CSV"];
+ RCPS -> RCPM [label="WS, JSON", headlabel="n", taillabel="1", dir=both];
+
+ }
+
+in case the procedure requires a secure channel, the key material is retrieved
+using a `CardKeyProvider` [1]. Since the retrieval of the key material
+as well as the secure channel establishment happens internally, the related
+key material is never disclosed to the client side.
+
+This solves a major problem many card deployments suffer from: Due to security
+reasons it is not always be possible to disclose key material to the card
+holder. This becomes a problem in case card contents have to be modified after
+the card had been deployed. This often means that the card issuer has to
+physically replace the already deployed cards. With `RCPF`, the card issuer can
+replace this process by deploying a suitable RCP Module on his server to offer
+a fix-up procedure that the card holder can call remotely.
+
+[1] :ref:`Retrieving card-individual keys via CardKeyProvider`
+
+In the following we will describe the system components in further detail. We
+will also give an introduction on how users can implement custom `RCP Modules`
+
+RCP Server
+~~~~~~~~~~
+
+The `RCP Server` is the core component in the overall system. It acts as a proxy
+between the `RCP Modules` (see below) and the `RCP Client` (see below). The
+`RCP Server` is permanently aware of which `RCP Modules` are available and knows
+their properties. With this knowledge, the `RCP Server` is able to check which
+module provides suitable procedures for specific card type.
+
+Another responsibility of the `RCP Server` is to retrieve the key material using
+the `CardKeyProvider`. As far as the `CardKeyProvider` is concerned, the RCP
+Server takes the exact same commandline options as pySim-shell.py. However, in
+case column encryption is used. The decryption key shall be passed to the
+`RCP Module` instead to the `RCP Server`. This moves the decryption to the point
+where the key material is actually needed.
+
+To ensure the privacy of the traffic exchanged between `RCP Client`,
+`RCP Server` and the `RCP Modules`, all links use SSL/TLS encrypted channels.
+This is in particular relevant for the `RCP Client` which usually connects to
+the `RCP Server` via the public internet.
+
+Since the `RCP Server` is exposed to the public internet, it also requires some
+level of protection against malicious requests. To minimize the risk arising
+from malformed requests, each incoming and outgoing message is validated against
+a JSON schema (also on the internal interfaces). Incoming requests from the
+RCP Client side are also rate-limited to guard against excessive requests (DoS)
+
+To monitor the `RCP Client` requests, the `RCP Server` support logging to an
+`OpenObserve` monitoring entity. For each request exactly one report es
+generated and sent to `OpenObserve`. For successful request, this report will
+only contain metadata. In case of crashes or when the return code of the
+`RCP Module` procedure is not 0, a full debug log is included as well.
+
+.. argparse::
+ :module: contrib.rcp.rcp_server
+ :func: option_parser
+ :prog: contrib/rcp/rcp_server.py
+
+
+RCP Client
+~~~~~~~~~~
+
+The `RCP Client` is used in the field by the card holder to request command
+lists and to request the execution of procedures from the `RCP Server`.
+
+The execution of a procedure is usually done in two steps. In the first step,
+the card holder will request a list with available commands using the `--help`
+option. The command list is then requested from the `RCP Server` displayed as
+a regular commandline help-screen. The list will only contain commands, which
+are actually suitable for the specific card type/model that card holder owns.
+
+In the second step, the card holder will choose a command to request the
+execution of the related procedure. In case the user already knows exactly
+which command to execute, the first step may also be skipped. The request of
+command lists for the purpose of displaying commandline help-screens is
+entirely optional.
+
+To avoid having to upgrade the RCP Client too often, the implementation is kept
+as simple as possible. Technically, the RCP Client is not much more than a
+proxy between a PC/SC-Reader and the RCP Server. All higher level tasks, like
+requesting the ICCID (UICC or eSIM) or the EID (eUICC) are implemented on the
+server side.
+
+.. argparse::
+ :module: contrib.rcp.rcp_client
+ :func: option_parser
+ :prog: contrib/rcp/rcp_client.py
+
+
+RCP Module
+~~~~~~~~~~
+
+The processing chain terminates in one of multiple `RCP Modules`. The `RCP Module`
+is the custom implementation that implements one or more procedures. The
+framework is designed in such a way that `RCP Modules` have minimal boilerplate
+code. The implementation is kept simple. Users, which are familiar with
+`pySim-shell` and its API will find the implementation of custom `RCP Modules`
+as simple as implementing a new `pySim-shell` command.
+
+From inside a procedure, the API user has access to the same objects (rs, card,
+lchan) that are also usually available in `pySim-shell` environment.
+
+To reset the card, retrieve the ATR and to exchange APDUs, the `pySim.transport`
+API together with a custom `LinkBase (RcpsSimLink)` object is used. This means
+that all modules which depend on the `pySim.transport` API can be used right
+away without modification.
+
+A procedure always runs in a dedicated thread, which means no special
+precautions are necessary. A procedure may wait or sleep without disturbing
+other requests.
+
+Even though there are similarities to `pySim-shell` one has to keep in mind that
+`RCP Modules` are intended to run non-interactively, which means they naturally
+do not provide any support for `cmd2` API calls. This means that before code
+from `pySim-shell` commands can be re-used, any `cmd2` entanglement must be
+removed or separated otherwise.
+
+.. argparse::
+ :module: contrib.rcp.usage_example.rcp_module
+ :func: option_parser
+ :prog: contrib/rcp/usage_example/rcp_module.py
+
+
+Usage Example
+~~~~~~~~~~~~~
+
+All system components and related modules can be found in `contrib/rcp`. The
+sub directory `usage_example` contains an example `RCP Module` and scripts to
+make it easier to get started. The following steps explain in detail how to get
+the `usage_example` running.
+
+Parameters
+----------
+
+The `usage_example` contains a file `params.cfg`. This file contains variables,
+which hold the parameters for the shell-scripts included in the example. The
+parameters set up the system in such a way that everything runs locally.
+Normally no changes are required, but it is strongly advised to review the
+parameters to verify there are no clashes with other services.
+
+Preparing Card Keys
+-------------------
+
+The example assumes a PC/SC reader and a `sysmoISIM-SJA5` or similar. To run
+the `usage_example`, no modification to the card itself are required, but the
+example key material (SCP02) in `card_data.csv` must match the test card.
+
+The following example assumes that the card has the ICCID ``8949440000001155306``
+and the following SCP02 keys:
+
++---------+----------------------------------+
+| Keyname | Keyvalue |
++=========+==================================+
+| ENC/KIC | F09C43EE1A0391665CC9F05AF4E0BD10 |
++---------+----------------------------------+
+| MAC/KID | 01981F4A20999F62AF99988007BAF6CA |
++---------+----------------------------------+
+| DEK/KIK | 8F8AEE5CDCC5D361368BC45673D99195 |
++---------+----------------------------------+
+
+This would result into a `card_data.csv` file with the following content:
+
+::
+
+ iccid,kic,kid,kik
+ 8949440000001155306,F09C43EE1A0391665CC9F05AF4E0BD10,01981F4A20999F62AF99988007BAF6CA,8F8AEE5CDCC5D361368BC45673D99195
+
+
+See also: :ref:`Retrieving card-individual keys via CardKeyProvider` and :ref:`Guide: Managing GP Keys`
+
+When `card_data.csv` is re-aligned, the columns containing key material need to
+be encrypted. This is done by running `encrypt_card_data.sh`. This script will
+output a file `card_data.csv.encr` which contains the encrypted key material.
+
+Running the RCP Server
+----------------------
+
+The `RCP Server` can be started using the included `start_rcp_server.sh` script.
+
+::
+
+ $ ./start_rcp_server.sh
+ + PYTHONPATH=../../../
+ + ../../..//contrib/rcp/rcp_server.py --rcpc-server-addr 127.0.0.1 --rcpc-server-port 8000 --rcpc-server-cert ./certs/example_ssl_rcpc_rcps_cert.pem --rcpm-server-addr 127.0.0.1 --rcpm-server-port 8010 --rcpm-server-cert ./certs/example_ssl_rcpm_rcps_cert.pem --rcpm-module-ca-cert ./certs/example_ssl_rcp_ca_cert.crt --csv ./card_data.csv.encr
+ INFO: loading SSL/TLS CA certificate (RCP Module Command Server Client): ./certs/example_ssl_rcp_ca_cert.crt
+ INFO: Using CSV file as card key data source: ./card_data.csv.encr
+ WARNING: Reporting to OpenObserve: (disabled)
+ INFO: Rate-Limit: max 10 requests per sec.
+ INFO: RCP Client Server at: 127.0.0.1:8000
+ INFO: RCP Module server at: 127.0.0.1:8010
+
+We can see that now to ports have been opened. `127.0.0.1:8000` is the port
+where `RCP Clients` can connect. In a productive setup, this port would
+normally be reachable from outside. The other port on `127.0.0.1:8010` is
+accepting connections from `RCP Modules` This port should not be reachable
+from the outside. It is intended to be used for the interprocess communication
+between the `RCP Server` and the `RCP Modules`
+
+In this state, the `RCP Server` waits for requests from both `RCP Clients` and
+`RCP Modules`. However, there are not `RCP Modules` registered yet, so any
+request from an `RCP Client` would be quilted with an error message.
+
+Running the RCP Module
+----------------------
+
+For a functioning setup a suitable `RCP Module` is needed. The provided
+`rcp_module.py` python program implements a few procedures which are suitable
+for a `sysmoISIM-SJA5` card.
+
+We can start the `RCP Module` with the provided start script
+`start_rcp_module.sh`
+
+::
+
+ $ ./start_rcp_module.sh
+ + PYTHONPATH=../../../:../../..//contrib/rcp
+ + ./rcp_module.py --uri wss://127.0.0.1:8010 --rcps-ca-cert ./certs/example_ssl_rcp_ca_cert.crt --rcpm-cmd-server-addr 127.0.0.1 --rcpm-cmd-server-port 8020 --rcpm-cmd-server-cert ./certs/example_ssl_rcps_rcpm_cert.pem --column-key kic:00112233445566778899AABBCCDDEEFF --column-key kid:00112233445566778899AABBCCDDEEFF --column-key kik:00112233445566778899AABBCCDDEEFF
+ INFO: RCP Module startup: rcp_module
+ INFO: loading SSL/TLS CA certificate (RCPM Server Client): ./certs/example_ssl_rcp_ca_cert.crt
+ INFO: RCPC command server at: 127.0.0.1:8020
+
+The `RCP Module` is now connected to the `RCP Server`. The log output of the
+`RCP Server` also confirms that there is a new `RCP Module` available.
+
+::
+
+ INFO: new RCP module, RCP modules available: 'rcp_module'
+
+On the output of the `RCP Module` we can see that the `RCP Module` has
+opened another port on `127.0.0.1:8020`. This is where the `RCP Module` accepts
+dedicated connections from the `RCP Server` when an `RCP Client` requests a
+procedure. In an installation with multiple `RCP Modules`, each `RCP Module`
+must use a dedicated port number.
+
+Note that we also pass the column key for the key material using the
+`--column-key` parameter. This parameter works exactly as in `pySim-shell`.
+We supply the column key to the `RCP Module` and not to the `RCP Server`
+move the decryption as close as possible to where it is needed.
+
+
+Running the RCP Client
+----------------------
+
+The `usage_example` provides a shello-script `run_rcp_client.sh` that which
+requests commandline help and requests procedures by calling other scripts.
+However to get an understanding how the `RCP Client` is supposed to be used, it
+makes more sense to call the sub scripts individually. We will now go through
+step by step.
+
+The first shell-script `./run_rcp_client_help.sh` assumes that the card holder
+uses the `RCP Client` for the first time. He does not know which commandline
+arguments are available, so he just calls `rcp_client.py` with the option `-h`.
+
+::
+
+ $ ./run_rcp_client_help.sh
+ + PYTHONPATH=../../../
+ + ../../..//contrib/rcp/rcp_client.py -h
+ usage: rcp_client.py [-h] [-d DEV] [-b BAUD] [--pcsc-shared] [-p PCSC | --pcsc-regex REGEX] [--modem-device DEV] [--modem-baud BAUD] [--osmocon PATH]
+ [--apdu-trace] [--verbose] [--uri URI] [--ca-cert CA_CERT]
+
+ RCP Client
+
+ options:
+ -h, --help show this help message and exit
+ --apdu-trace Trace the command/response APDUs exchanged with the card (default: False)
+ --verbose Enable verbose logging (default: False)
+ --uri URI URI of the RCP-Server (default: None)
+ --ca-cert CA_CERT SSL/TLS CA-Certificate of the RCP-Server (default: None)
+ ...
+
+ PC/SC Reader:
+ Use a PC/SC card reader to talk to the SIM card. PC/SC is a standard API for how applications access smart card readers, and is available on a variety of
+ operating systems, such as Microsoft Windows, MacOS X and Linux. Most vendors of smart card readers provide drivers that offer a PC/SC interface, if not even
+ a generic USB CCID driver is used. You can use a tool like ``pcsc_scan -r`` to obtain a list of readers available on your system.
+
+ --pcsc-shared Open PC/SC reaer in SHARED access (default: EXCLUSIVE) (default: False)
+ -p, --pcsc-device PCSC
+ Number of PC/SC reader to use for SIM access (default: None)
+ --pcsc-regex REGEX Regex matching PC/SC reader to use for SIM access (default: None)
+ ...
+
+
+From the output the card holder learns that there is an `--uri` parameter and
+that the same PC/SC options like in `pySim-shell.py` are supported. There is
+also a `--ca-cert` parameter where a CA certificate can be supplied in case the
+`RCP Server` uses a self-signed CA (which applies to this example)
+
+The second script `run_rcp_client_help_cmd.sh` assumes that the card holder now
+knows that the minimum required parameters are the `--uri` of the `RCP Server`,
+the `--ca-cert` of the `RCP Server` and `-p` to tell the `RCP Client` which PC/SC
+reader to use.
+
+::
+
+ $ ./run_rcp_client_help_cmd.sh
+ + PYTHONPATH=../../../
+ + ../../..//contrib/rcp/rcp_client.py --uri wss://127.0.0.1:8000 --ca-cert ./certs/example_ssl_rcp_ca_cert.crt -p 0 -h
+ INFO: loading SSL/TLS CA certificate (RCP Server CA): ./certs/example_ssl_rcp_ca_cert.crt
+ INFO: Using reader PCSC[Alcor Micro AU9540 00 00]
+ INFO: Detected Card with ATR: 3B9F96801F878031E073FE211B674A357530350265F8
+ INFO: RCP Server URI: wss://127.0.0.1:8000
+ INFO: Checking version ...
+ INFO: RCP Client version: software=1.0.0, protocol=1.0.0
+ INFO: RCP Server version: software=1.0.0, protocol=1.0.0
+ INFO: Requesting module descriptions from RCP Server ...
+ usage: rcp_client.py [-h] [-d DEV] [-b BAUD] [--pcsc-shared] [-p PCSC | --pcsc-regex REGEX] [--modem-device DEV] [--modem-baud BAUD] [--osmocon PATH]
+ [--apdu-trace] [--verbose] [--uri URI] [--ca-cert CA_CERT]
+ {rcp_module_reset,rcp_module_read_binary,rcp_module_read_record,rcp_module_unlock_aram} ...
+
+ RCP Client
+
+ positional arguments:
+ {rcp_module_reset,rcp_module_read_binary,rcp_module_read_record,rcp_module_unlock_aram}
+ RCP command to use
+ rcp_module_reset reset the card
+ rcp_module_read_binary
+ read binary data from a transparent file.
+ rcp_module_read_record
+ read binary data from a transparent file.
+ rcp_module_unlock_aram
+ unlock a locked ARA-M applet on a sysmoISIM-SJA5
+ ...
+
+The help screen now shows additional positional arguments. Those positional
+arguments are the commands which the card holder can use to request a
+procedure. In this example we have four procedures we can call:
+`rcp_module_reset`, `rcp_module_read_binary`, `rcp_module_read_record`,
+and `rcp_module_unlock_aram`
+
+In the log output above the help screen, we can also see that a connection was
+made and that the `RCP Client` has requested module descriptions from the
+server. The `RCP Client` has sent the ATR of the card to the `RCP Server`. The
+`RCP Server` has used this information to look through its internal list to
+find modules which offer procedures suitable for this specific card.
+
+The card holder now knows which commands or procedures are available, but he
+still does not know if arguments are required and what those arguments are.
+The third script `run_rcp_client_help_cmd_specific.sh` shows how the card
+holder can request a dedicated help-screen for each of the commands.
+
+::
+
+ $ ./run_rcp_client_help_cmd_specific.sh
+ ...
+ + PYTHONPATH=../../../
+ + ../../..//contrib/rcp/rcp_client.py --uri wss://127.0.0.1:8000 --ca-cert ./certs/example_ssl_rcp_ca_cert.crt -p 0 rcp_module_read_record --help
+ INFO: loading SSL/TLS CA certificate (RCP Server CA): ./certs/example_ssl_rcp_ca_cert.crt
+ INFO: Using reader PCSC[Alcor Micro AU9540 00 00]
+ INFO: Detected Card with ATR: 3B9F96801F878031E073FE211B674A357530350265F8
+ INFO: RCP Server URI: wss://127.0.0.1:8000
+ INFO: Checking version ...
+ INFO: RCP Client version: software=1.0.0, protocol=1.0.0
+ INFO: RCP Server version: software=1.0.0, protocol=1.0.0
+ INFO: Requesting module descriptions from RCP Server ...
+ usage: rcp_client.py rcp_module_read_record [-h] --fid FID --record RECORD
+
+ options:
+ -h, --help show this help message and exit
+ --fid FID File identifier to of the file to read
+ --record RECORD File record to read
+ ...
+
+We can see in the log that the `RCP Client` again sends a request to the
+`RCP Server` and retrieves the `RCP Module` descriptions. Then a dedicated
+help-screen for the `rcp_module_read_record` command is displayed. Now the card
+holder knows which parameters are required to perform the related procedure.
+
+Until this point there was only interaction with the `RCP Client` and the
+`RCP Server`. The `RCP Module` has not seen any requests yet. The provided
+script `run_rcp_client_cmd.sh` illustrates how the card holder can run an
+command that performs an actual procedure with the `RCP Module`.
+
+::
+
+ $ ./run_rcp_client_cmd.sh
+ ...
+ + PYTHONPATH=../../../
+ + ../../..//contrib/rcp/rcp_client.py --uri wss://127.0.0.1:8000 --ca-cert ./certs/example_ssl_rcp_ca_cert.crt -p 0 rcp_module_read_record --fid 3f00 --fid 2f00 --record 1
+ INFO: loading SSL/TLS CA certificate (RCP Server CA): ./certs/example_ssl_rcp_ca_cert.crt
+ INFO: Using reader PCSC[Alcor Micro AU9540 00 00]
+ INFO: Detected Card with ATR: 3B9F96801F878031E073FE211B674A357530350265F8
+ INFO: RCP Server URI: wss://127.0.0.1:8000
+ INFO: Checking version ...
+ INFO: RCP Client version: software=1.0.0, protocol=1.0.0
+ INFO: RCP Server version: software=1.0.0, protocol=1.0.0
+ INFO: Requesting module descriptions from RCP Server ...
+ INFO: Executing command with RCP Server ...
+ INFO: RcpcCltConnHdlr(140335960510480) -- reading linear-fixed file: ['3f00', '2f00'] ...
+ INFO: RcpcCltConnHdlr(140335960510480) -- file content is: 61294F10A0000000871002FFFFFFFF890709000050055553696D31730EA00C80011781025F608203454150
+ INFO: Command execution done, rc: 0
+
+The example reads record 1 from the file ``3F00/2F00`` and returns the file
+content. We also can see by the return code that the procedure was successful.
+The return code is also passed to `sys.exit()`, so that the card holder can
+use it in a script.
+
+The APDUs required to perform this action were entirely generated under the
+control of the `RCP Module`. In the log of the `RCP Server` we can see which
+command was executed on which `RCP Module` was used. We also see the return
+code here as well.
+
+::
+
+ ...
+ INFO: RcpcSrvConnHdlr(140093766623552) -- executing procedure for command "rcp_module_read_record" on module "rcp_module" at: wss://127.0.0.1:8020
+ INFO: RcpcSrvConnHdlr(140093766623552) -- command execution done, rc: 0
+ ...
+
+In the log of the `RCP Module` we can follow up on how the procedure was
+carried out.
+
+::
+
+ ...
+ INFO: RcpmCmdSrvConnHdlr(140156091028880) -- executing command: rcp_module_read_record ['--fid', '3f00', '--fid', '2f00', '--record', '1']
+ INFO: Waiting for card...
+ INFO: Card is of type: UICC
+ INFO: Detected UICC Add-on "SIM"
+ INFO: Detected UICC Add-on "GSM-R"
+ INFO: Detected UICC Add-on "RUIM"
+ WARNING: EF.DIR seems to be empty!
+ INFO: ADF.ISD: a000000003000000
+ INFO: ARA-M: a00000015141434c00
+ INFO: ISIM: a0000000871004
+ INFO: USIM: a0000000871002
+ INFO: Detected CardModel: SysmocomSJA5
+ INFO: RcpmCmdSrvConnHdlr(140156091028880) -- reading linear-fixed file: ['3f00', '2f00'] ...
+ INFO: RcpmCmdSrvConnHdlr(140156091028880) -- file content is: 61294F10A0000000871002FFFFFFFF890709000050055553696D31730EA00C80011781025F608203454150
+ INFO: RcpmCmdSrvConnHdlr(140156091028880) -- command execution done, rc: 0
+ ...
+
+In first line we see the command and its parameters. The lines that follow will
+look familiar to `pySim-shell` users. The last three log lines carry the print
+statements which we also see in the log messages on the `RCP Client`. The last
+line informs about the conclusion of the procedure and also shows the return
+code.
+
+
+Implementing an RCP Module
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To make use of the Remote Card Procedure Framework, it is eventually necessary
+to implement a custom `RCP Module`. In the following section, we will go
+through the implementation of the `RCP Module` that is provided with the
+`usage_example`.
+
+NOTE: much of the following is explained in greater detail in the comments
+found in `rcp_module_utils.py`.
+
+Overview
+~~~~~~~~
+
+`RCP Modules` are normal python programs that can started directly from the
+command prompt. However, due to the location of the file it is necessary that
+`PYTHONPATH` points to the location of the `pySim` modules as well as to the
+modules found in `contrib/rcp` (see `start_rcp_module.sh` for reference).
+
+As mentioned earlier `RCP Modules` may use the `pySim` API like any other
+`pySim` program, given that there is no dependency to `cmd2`. So it is no
+surprise that we find some `pySim` modules in the import section of the
+provided example.
+
+The utilities required to implement an `RCP Module` are imported from
+`rcp_module_utils.py`. From this module we import two functions
+`rcpm_setup_argparse` and `rcpm_run_module` and the two classes `RCP Module`
+and `RcpModuleHdlr`.
+
+Function: rcpm_setup_argparse
+------------------------------
+
+The first function `rcpm_setup_argparse` returns an argument parser that is already
+equipped with the basic commandline arguments that an `RCP Module` needs. In
+case the specific `RCP Module` implementation requires additional arguments,
+those can be added using normal `argparse` API calls.
+
+Function: rcpm_run_module
+-------------------------
+
+The second function `rcpm_run_module` is used to run the `RCP Module`. This
+function gets the parsed commandline options (`opts`) and the `RcpModule` class
+(`module`) as parameters. In addition to that, `rcpm_run_module` also accepts
+custom `*args` and `**kwargs` arguments, which are passed to the constructor of
+the `RcpModule` class.
+
+When `rcpm_run_module` is called. It registers the `RCP Module` and starts the
+RCP Client command server. It also takes care of the proper instantiation of the
+`RcpModule` class, which were passed with the `module` parameter.
+
+Class: RcpModule
+----------------
+
+The Class `RcpModule` is the base class that is used to create a concrete
+`RCP Module` implementaion. Through this class, the API user defines the
+properties of the `RCP Module` as well as the command methods, which implement
+the related `Remote Card Procedures`.
+
+Class: RcpModuleHdlr
+--------------------
+
+The class `RcpModuleHdlr` is used by the framework to instantiate a handler
+object (`hdlr`), which is passed to each of the aforementioned command methods.
+The handler object is used as a vehicle to provide access the resources we need
+to send APDUs, print messages on the `RCP Client`, etc.
+
+Module Properties
+~~~~~~~~~~~~~~~~~
+
+Before we can define any module properties, we first need to create a derived
+class from the `RcpModule` class we have imported from `rcp_module_utils.py`.
+In that class, we then define the basic properties of the `RCP Module`.
+
+name
+----
+
+Each `RCP Module` needs a distinct name. The name must not collide with the
+names of other `RCP Modules`. The name uniquely identifies the `RCP Module` and
+is used as a prefix for the command names used with the user interface of the
+`RCP Client`. Therefore a short name is desirable.
+
+cmd_descr
+---------
+
+The `cmd_descr` property defines the command properties. Since an `RCP Module`
+may offer multiple commands (procedures), this property is an array, where
+each item holds the definition for one specific command.
+
+The command definitions are formatted as a python dict. Like the `RCP Module`
+itself, each command has a `name`. As mentioned before. This name is concatenated
+with the name of the `RCP Module`.
+
+Each command definition also gets a `help` string. The help string will show up
+in the commandline help of the `RCP Client`. It should be short and concise.
+
+Command definitions also need to define commandline arguments. For this an
+`args` array is added to the command definition as well. In case no arguments
+are provided. The array is empty. Otherwise it will contain one or more dict
+members, where each specifies a `name` and a `spec`. The `name` sets the
+argument name (e.g. --fid), and the `spec` specifies the properties of the
+argument. The concept is borrowed from `argparse` and works very similar. API
+users can specify `required`, `help`, `default` and a type. However, to avoid
+name-space collisions, the type field is called `pytype` and the type identifier
+must be passed as a string (e.g. 'int').
+
+In case a procedure requires key material from the `CardKeyProvider`, the API
+user may add a `get_keys` field to the command definition. In case eUICC keys
+are needed. The API user will add a dict member with key `euicc` and populate
+the value with an array that holds the column names of the columns where the
+keys are found. The same also works for UICC keys by using 'uicc' as dict key.
+When `get_keys` is correctly populated and the correct column keys are supplied
+to the `RCP Module` at runtime. The `RCP Framework` will automatically retrieve
+the key material, decrypt it and make it available to the related command
+method.
+
+suitable_for
+------------
+
+`suitable_for` is the third and last property, the API user must define. This
+property holds an array where each member is a dict that defines a distinct
+property of the card for which the module is suitable for. The `RCP Server`
+uses this information to see which modules are suitable for a specific request.
+As of now, the only property we can use to make the distinction, is the ATR of
+the card.
+
+custom resources
+----------------
+
+In case an RCP Module requires custom resources, those may be initialized using
+a custom constructor in the derived class. This constructor receives the
+`*args` and `**kwargs` arguments passed to `rcpm_run_module`. However, this is
+an optional step. In case no constructor is defined, the default constructor
+is used.
+
+In addition to that, the API user may also define additional properties and
+methods, provided they do not collide with existing methods of the base class
+`RcpModule`.
+
+Command Methods
+~~~~~~~~~~~~~~~
+
+Command methods are essentially normal python methods. However, since those
+methods are called by the `RCP Framework`, they must follow a distinct scheme,
+which we will go through in the following.
+
+Each command defined in `cmd_descr` requires a corresponding command method. A
+command method is always prefixed with `cmd_`. Then the exact name of the
+command follows as defined in `cmd_descr`. For example if we have defined a
+command with the name `read_record`, we must also define a method with the name
+`cmd_read_record`.
+
+The parameter list of a command method always contains only `self` and `hdlr`.
+The `hdlr` parameter is the handler object (`RcpModuleHdlr`) through which we
+access the resources provided by the `RCP Framework`.
+
+Inside a command method, the API user is free to perform any task he wants.
+Command Methods always run in a dedicated thread and may sleep or wait at any
+time without disturbing running procedures from other requestors.
+
+A command method should always return an integer as return code. In case the
+procedure ends successfully, the return code shall be `0`. The return code is
+passed through to the `RCP Client`, which returns it on exit to the operating
+system
+
+
+Handler Resources
+~~~~~~~~~~~~~~~~~
+
+As mentioned earlier, a commend method receives a handler object via
+the `hdlr` parameter. This object is of type `RcpModuleHdlr` and vaguely
+comparable to the `app` (`PysimApp`) object found in `pySim-shell.py`.
+
+The handler object provides the command method with the resources it needs to
+perform the card procedure.
+
+rs, card, lchan
+---------------
+
+The Runtime State (`rs`), the Card (`card`) and the Lchan (`lchan`) Object
+have the same objectives asn in `pySim-shell.py`. Those objects work and are
+used the same way as they would in `pySim-shell.py`. It is assumed that the
+API user is already familiar with those objects.
+
+cmd_args
+--------
+
+The command arguments (`cmd_args`) contains the command line arguments as they
+were passed by the card holder on the `RCP Client` commandline in the form of
+a `Namespace` object.
+
+Even though the command arguments are syntax-checked against the `args`
+description given in `cmd_descr`, caution is required to avoid security
+problems arising from malicious input.
+
+keys_uicc and keys_euicc
+------------------------
+
+In case key material was requested via the `get_keys` in `cmd_descr`,
+`keys_uicc` and `keys_euicc` will contain those keys in the form of a dict. The
+dict key is the is the `CardKeyProvider` column name and the related dict value
+is the key material in its decrypted form.
+
+When accessing `keys_uicc` and `keys_euicc`, extra care should be taken. It may
+make sense to delete/overwrite those dictionaries as soon as the keys were used
+for the intended purpose. However, due to python's internal memory management
+key material may remain longer in the system memory as expected.
+
+print
+-----
+
+The `hdlr` object also provides a `print` method. This method accepts a string
+as the only parameter and can be used to display custom messages in the log
+output of the `RCP Client`. The method can be used to inform the card holder
+about the progress of a procedure or to print error messages in case a
+procedure fails.