Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add optional SSL support #46

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Remote Procedure Call tool

This tool is the preferred way of handling simple RPC servers.
Instead of writing a client for simple cases, you can simply use this tool
to call remote functions of an RPC server.
to call remote functions of an RPC server. For secure connections, see `SSL Setup`_.

* Listing existing targets

Expand Down Expand Up @@ -127,3 +127,57 @@ Command-line details:
.. argparse::
:ref: sipyco.sipyco_rpctool.get_argparser
:prog: sipyco_rpctool


SSL Setup
=========

SiPyCo supports SSL/TLS encryption with mutual authentication for secure communication, but it is disabled by default. To enable and use SSL, follow these steps:

**Generate server certificate:**

.. code-block:: bash

openssl req -x509 -newkey rsa -keyout server.key -nodes -out server.pem -sha256 -subj "/"

**Generate client certificate:**

.. code-block:: bash

openssl req -x509 -newkey rsa -keyout client.key -nodes -out client.pem -sha256 -subj "/"

.. note::
.. note::
The ``-subj "/"`` parameter bypasses the interactive prompts for certificate information.

This creates:

- A server certificate (``server.pem``) and key (``server.key``)
- A client certificate (``client.pem``) and key (``client.key``)


Enabling SSL
------------

To enable SSL, the server needs its certificate/key and trusts the client's certificate, while the client needs its certificate/key and trusts the server's certificate:

**For servers:**

.. code-block:: python

simple_server_loop(targets, host, port,
local_cert="path/to/server.pem",
local_key="path/to/server.key",
peer_cert="path/to/client.pem")

**For clients:**

.. code-block:: python

client = Client(host, port,
local_cert="path/to/client.pem",
local_key="path/to/client.key",
peer_cert="path/to/server.pem")

.. note::
When SSL is enabled, mutual TLS authentication is mandatory. Both server and client must provide valid certificates and each must trust the other's certificate for the connection to be established.
8 changes: 7 additions & 1 deletion sipyco/asyncio_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from copy import copy

from sipyco import keepalive
from sipyco.ssl_tools import create_ssl_context

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -47,7 +48,7 @@ class AsyncioServer:
def __init__(self):
self._client_tasks = set()

async def start(self, host, port):
async def start(self, host, port, local_cert=None, local_key=None, peer_cert=None):
"""Starts the server.

The user must call :meth:`stop`
Expand All @@ -58,9 +59,14 @@ async def start(self, host, port):
:param host: Bind address of the server (see ``asyncio.start_server``
from the Python standard library).
:param port: TCP port to bind to.
:param local_cert: Server's SSL certificate file. Providing this enables SSL.
:param local_key: Server's private key file. Required when cert is provided.
:param peer_cert: Client's SSL certificate file to trust. Required when SSL is enabled.
"""
self.ssl_context = create_ssl_context(local_cert, local_key, peer_cert, server_mode=True)
self.server = await asyncio.start_server(self._handle_connection,
host, port,
ssl=self.ssl_context,
limit=4*1024*1024)

async def stop(self):
Expand Down
38 changes: 30 additions & 8 deletions sipyco/pc_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from operator import itemgetter

from sipyco import keepalive, pyon
from sipyco.ssl_tools import create_ssl_context
from sipyco.asyncio_tools import SignalHandler, AsyncioServer as _AsyncioServer
from sipyco.packed_exceptions import *

Expand Down Expand Up @@ -97,6 +98,9 @@ class Client:
Use ``None`` to skip selecting a target. The list of targets can then
be retrieved using :meth:`~sipyco.pc_rpc.Client.get_rpc_id`
and then one can be selected later using :meth:`~sipyco.pc_rpc.Client.select_rpc_target`.
:param local_cert: Client's certificate file. Providing this enables SSL.
:param local_key: Client's private key file. Required when local_cert is provided.
:param peer_cert: Server's SSL certificate file to trust. Required when SSL is enabled.
:param timeout: Socket operation timeout. Use ``None`` for blocking
(default), ``0`` for non-blocking, and a finite value to raise
``socket.timeout`` if an operation does not complete within the
Expand All @@ -106,9 +110,12 @@ class Client:
client).
"""

def __init__(self, host, port, target_name=AutoTarget, timeout=None):
def __init__(self, host, port, target_name=AutoTarget,
local_cert=None, local_key=None, peer_cert=None, timeout=None):
self.__socket = socket.create_connection((host, port), timeout)

ssl_context = create_ssl_context(local_cert, local_key, peer_cert)
if ssl_context is not None:
self.__socket = ssl_context.wrap_socket(self.__socket)
try:
self.__socket.sendall(_init_string)

Expand Down Expand Up @@ -206,12 +213,14 @@ def __init__(self):
self.__description = None
self.__valid_methods = set()

async def connect_rpc(self, host, port, target_name=AutoTarget):
async def connect_rpc(self, host, port, target_name=AutoTarget,
local_cert=None, local_key=None, peer_cert=None):
"""Connects to the server. This cannot be done in __init__ because
this method is a coroutine. See :class:`sipyco.pc_rpc.Client` for a description of the
parameters."""
ssl_context = create_ssl_context(local_cert, local_key, peer_cert)
self.__reader, self.__writer = \
await keepalive.async_open_connection(host, port, limit=100 * 1024 * 1024)
await keepalive.async_open_connection(host, port, ssl=ssl_context, limit=100 * 1024 * 1024)
try:
self.__writer.write(_init_string)
server_identification = await self.__recv()
Expand Down Expand Up @@ -303,17 +312,22 @@ class BestEffortClient:
RPC calls that failed because of network errors return ``None``. Other RPC
calls are blocking and return the correct value.

See :class:`sipyco.pc_rpc.Client` for a description of the other parameters.

:param firstcon_timeout: Timeout to use during the first (blocking)
connection attempt at object initialization.
:param retry: Amount of time to wait between retries when reconnecting
in the background.
"""

def __init__(self, host, port, target_name,
firstcon_timeout=1.0, retry=5.0):
def __init__(self, host, port, target_name, local_cert=None,
local_key=None, peer_cert=None, firstcon_timeout=1.0, retry=5.0):
self.__host = host
self.__port = port
self.__target_name = target_name
self.__local_cert = local_cert
self.__local_key = local_key
self.__peer_cert = peer_cert
self.__retry = retry

self.__conretry_terminate = False
Expand All @@ -337,6 +351,13 @@ def __coninit(self, timeout):
else:
self.__socket = socket.create_connection(
(self.__host, self.__port), timeout)
ssl_context = create_ssl_context(
local_cert=self.__local_cert,
local_key=self.__local_key,
peer_cert=self.__peer_cert
)
if ssl_context is not None:
self.__socket = ssl_context.wrap_socket(self.__socket)
self.__socket.sendall(_init_string)
server_identification = self.__recv()
target_name = _validate_target_name(self.__target_name,
Expand Down Expand Up @@ -635,7 +656,8 @@ async def wait_terminate(self):
await self._terminate_request.wait()


def simple_server_loop(targets, host, port, description=None, allow_parallel=False, *, loop=None):
def simple_server_loop(targets, host, port, description=None, allow_parallel=False, *, loop=None,
local_cert=None, local_key=None, peer_cert=None):
"""Runs a server until an exception is raised (e.g. the user hits Ctrl-C)
or termination is requested by a client.

Expand All @@ -651,7 +673,7 @@ def simple_server_loop(targets, host, port, description=None, allow_parallel=Fal
signal_handler.setup()
try:
server = Server(targets, description, True, allow_parallel)
used_loop.run_until_complete(server.start(host, port))
used_loop.run_until_complete(server.start(host, port, local_cert, local_key, peer_cert))
try:
_, pending = used_loop.run_until_complete(asyncio.wait(
[used_loop.create_task(signal_handler.wait_terminate()),
Expand Down
11 changes: 10 additions & 1 deletion sipyco/sipyco_rpctool.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ def get_argparser():
help="hostname or IP of the controller to connect to")
parser.add_argument("port", metavar="PORT", type=int,
help="TCP port to use to connect to the controller")
parser.add_argument("--ssl", nargs=3, metavar=('CERT', 'KEY', 'PEER'),
help="Enable SSL authentication: "
"CERT: client certificate file, "
"KEY: client private key, "
"PEER: server certificate to trust")
subparsers = parser.add_subparsers(dest="action")
subparsers.add_parser("list-targets", help="list existing targets")
parser_list_methods = subparsers.add_parser("list-methods",
Expand Down Expand Up @@ -97,8 +102,12 @@ def main():
args = get_argparser().parse_args()
if not args.action:
args.target = None
if args.ssl:
cert, key, peer = args.ssl
else:
cert, key, peer = None, None, None

remote = Client(args.server, args.port, None)
remote = Client(args.server, args.port, None, local_cert=cert, local_key=key, peer_cert=peer)
targets, description = remote.get_rpc_id()
if args.action != "list-targets":
if not args.target:
Expand Down
31 changes: 31 additions & 0 deletions sipyco/ssl_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import ssl
import logging

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)

def create_ssl_context(local_cert=None, local_key=None, peer_cert=None, server_mode=False):
"""Create an SSL context with mutual authentication.

:param local_cert: Certificate file. Providing this enables SSL.
:param local_key: Private key file. Required when local_cert is provided.
:param peer_cert: Peer's certificate file to trust. Required when SSL is enabled.
:param server_mode: If True, create a server context, otherwise client context.
"""
if local_cert is None:
return None
if local_key is None:
raise ValueError("local_key is required when local_cert is provided")
if peer_cert is None:
raise ValueError("peer_cert is required when SSL is enabled")

if server_mode:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.verify_mode = ssl.CERT_REQUIRED
else:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still actually checking the server certificate? Did you test and how?

Copy link
Contributor Author

@fsagbuya fsagbuya Dec 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upon testing again.. it is not. A fix is having the client context as PROTOCOL_TLS_CLIENT and have load_verify_locations. Will add this to the commit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add some tests as well with failing cert validations.


context.load_verify_locations(cafile=peer_cert)
context.load_cert_chain(local_cert, local_key)
return context