WIP: Remote Card Procedure Framework

Problem: When UICC/eUICC cards are deployed into the field it is often
difficult to perform modifications to those cards. One important factor
that makes after-deployment modifications often difficult is that the
key material needed to perform the task must not be handed to the card
holder due to security requirements.

The presented Remote Card Procedure Framework solves this problem. It
provides a so called Remote Card Procedure Client (RCPC), which is a
lightwight software client which can be run by the card holder on the
remote machine.

With the RCPC, the card holder can access a so called Remote Card
Procedure Server (RCPC), to which so called Remote Card Procedure
Modules (RCPM) can subscribe and publish their functionality. With
the RCPC, the card holder can browse the functionality offered by
those connected modules and eventually the card holder may execute
a certain procedure by passing a command to the RCPS.

When a procedure is carried out, the RCPS automatically retrieves the
required key material from a database or CSV file and passes those
keys on to the selected RCPM. The RCPM can then use the key material
to establish a secure channel to carry out the procedure. The procedure
is then protected by a secure channel and the key material is never
disclosed towards the card holder on the remote end.

The framework is desinged in such a way that existing pySim APIs and
functions can be used from the RCPM API user code. Also only minimal
boilerplate code is required. The implementation also ships with a
comprehensive example.

Related: SYS#6959
This commit is contained in:
Philipp Maier
2026-04-09 14:09:52 +02:00
parent 35ca46d8fe
commit d9eef6fce7
28 changed files with 2504 additions and 0 deletions

208
contrib/rcp/rcp_client.py Executable file
View File

@@ -0,0 +1,208 @@
#!/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 <http://www.gnu.org/licenses/>.
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
SERVER_TIMEOUT = 10
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 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)
# Retrieve module description
module_descrs = await client.describe({"atr" : card_atr})
# Complete the commandlie 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 helpscreen 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 helpscreen.
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)