Skip to content

Commit

Permalink
Merge pull request #1624 from pbiering/ssl-config
Browse files Browse the repository at this point in the history
SSL socket protocol + ciphersuite option
  • Loading branch information
pbiering authored Nov 14, 2024
2 parents 1d07d72 + df5ca97 commit 0f9bf4c
Show file tree
Hide file tree
Showing 7 changed files with 188 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Fix: expand does not take timezones into account
* Fix: expand does not support overridden recurring events
* Fix: expand does not honor start and end times
* Add: option [server] protocol + ciphersuite for optional restrictions on SSL socket

## 3.3.0

Expand Down
16 changes: 16 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,22 @@ authentication plugin that extracts the username from the certificate.

Default:

##### protocol

Accepted SSL protocol (maybe not all supported by underlying OpenSSL version)
Example for secure configuration: ALL -SSLv3 -TLSv1 -TLSv1.1
Format: Apache SSLProtocol list (from "mod_ssl")

Default: (system default)

##### ciphersuite

Accepted SSL ciphersuite (maybe not all supported by underlying OpenSSL version)
Example for secure configuration: DHE:ECDHE:-NULL:-SHA
Format: OpenSSL cipher list (see also "man openssl-ciphers")

Default: (system-default)

#### encoding

##### request
Expand Down
6 changes: 6 additions & 0 deletions config
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
# TCP traffic between Radicale and a reverse proxy
#certificate_authority =

# SSL protocol, secure configuration: ALL -SSLv3 -TLSv1 -TLSv1.1
#protocol = (default)

# SSL ciphersuite, secure configuration: DHE:ECDHE:-NULL:-SHA (see also "man openssl-ciphers")
#ciphersuite = (default)


[encoding]

Expand Down
8 changes: 8 additions & 0 deletions radicale/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@ def json_str(value: Any) -> dict:
"aliases": ("-s", "--ssl",),
"opposite_aliases": ("-S", "--no-ssl",),
"type": bool}),
("protocol", {
"value": "",
"help": "SSL/TLS protocol (Apache SSLProtocol format)",
"type": str}),
("ciphersuite", {
"value": "",
"help": "SSL/TLS Cipher Suite (OpenSSL cipher list format)",
"type": str}),
("certificate", {
"value": "/etc/ssl/radicale.cert.pem",
"help": "set certificate file",
Expand Down
29 changes: 28 additions & 1 deletion radicale/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
Tuple, Union)
from urllib.parse import unquote

from radicale import Application, config
from radicale import Application, config, utils
from radicale.log import logger

COMPAT_EAI_ADDRFAMILY: int
Expand Down Expand Up @@ -167,6 +167,8 @@ def server_bind(self) -> None:
certfile: str = self.configuration.get("server", "certificate")
keyfile: str = self.configuration.get("server", "key")
cafile: str = self.configuration.get("server", "certificate_authority")
protocol: str = self.configuration.get("server", "protocol")
ciphersuite: str = self.configuration.get("server", "ciphersuite")
# Test if the files can be read
for name, filename in [("certificate", certfile), ("key", keyfile),
("certificate_authority", cafile)]:
Expand All @@ -183,8 +185,33 @@ def server_bind(self) -> None:
"(%s)" % (type_name, name, "server", source, filename,
e)) from e
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
logger.info("SSL load files certificate='%s' key='%s'", certfile, keyfile)
context.load_cert_chain(certfile=certfile, keyfile=keyfile)
if protocol:
logger.info("SSL set explicit protocols (maybe not all supported by underlying OpenSSL): '%s'", protocol)
context.options = utils.ssl_context_options_by_protocol(protocol, context.options)
context.minimum_version = utils.ssl_context_minimum_version_by_options(context.options)
if (context.minimum_version == 0):
raise RuntimeError("No SSL minimum protocol active")
context.maximum_version = utils.ssl_context_maximum_version_by_options(context.options)
if (context.maximum_version == 0):
raise RuntimeError("No SSL maximum protocol active")
else:
logger.info("SSL active protocols: (system-default)")
logger.debug("SSL minimum acceptable protocol: %s", context.minimum_version)
logger.debug("SSL maximum acceptable protocol: %s", context.maximum_version)
logger.info("SSL accepted protocols: %s", ' '.join(utils.ssl_get_protocols(context)))
if ciphersuite:
logger.info("SSL set explicit ciphersuite (maybe not all supported by underlying OpenSSL): '%s'", ciphersuite)
context.set_ciphers(ciphersuite)
else:
logger.info("SSL active ciphersuite: (system-default)")
cipherlist = []
for entry in context.get_ciphers():
cipherlist.append(entry["name"])
logger.info("SSL accepted ciphers: %s", ' '.join(cipherlist))
if cafile:
logger.info("SSL enable mandatory client certificate verification using CA file='%s'", cafile)
context.load_verify_locations(cafile=cafile)
context.verify_mode = ssl.CERT_REQUIRED
self.socket = context.wrap_socket(
Expand Down
128 changes: 128 additions & 0 deletions radicale/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Copyright © 2014 Jean-Marc Martins
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <[email protected]>
# Copyright © 2024-2024 Peter Bieringer <[email protected]>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
Expand All @@ -16,6 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.

import ssl
from importlib import import_module, metadata
from typing import Callable, Sequence, Type, TypeVar, Union

Expand Down Expand Up @@ -47,3 +49,129 @@ def load_plugin(internal_types: Sequence[str], module_name: str,

def package_version(name):
return metadata.version(name)


def ssl_context_options_by_protocol(protocol: str, ssl_context_options):
logger.debug("SSL protocol string: '%s' and current SSL context options: '0x%x'", protocol, ssl_context_options)
# disable any protocol by default
logger.debug("SSL context options, disable ALL by default")
ssl_context_options |= ssl.OP_NO_SSLv2
ssl_context_options |= ssl.OP_NO_SSLv3
ssl_context_options |= ssl.OP_NO_TLSv1
ssl_context_options |= ssl.OP_NO_TLSv1_1
ssl_context_options |= ssl.OP_NO_TLSv1_2
ssl_context_options |= ssl.OP_NO_TLSv1_3
logger.debug("SSL cleared SSL context options: '0x%x'", ssl_context_options)
for entry in protocol.split():
entry = entry.strip('+') # remove trailing '+'
if entry == "ALL":
logger.debug("SSL context options, enable ALL (some maybe not supported by underlying OpenSSL, SSLv2 not enabled at all)")
ssl_context_options &= ~ssl.OP_NO_SSLv3
ssl_context_options &= ~ssl.OP_NO_TLSv1
ssl_context_options &= ~ssl.OP_NO_TLSv1_1
ssl_context_options &= ~ssl.OP_NO_TLSv1_2
ssl_context_options &= ~ssl.OP_NO_TLSv1_3
elif entry == "SSLv2":
logger.warning("SSL context options, ignore SSLv2 (totally insecure)")
elif entry == "SSLv3":
ssl_context_options &= ~ssl.OP_NO_SSLv3
logger.debug("SSL context options, enable SSLv3 (maybe not supported by underlying OpenSSL)")
elif entry == "TLSv1":
ssl_context_options &= ~ssl.OP_NO_TLSv1
logger.debug("SSL context options, enable TLSv1 (maybe not supported by underlying OpenSSL)")
elif entry == "TLSv1.1":
logger.debug("SSL context options, enable TLSv1.1 (maybe not supported by underlying OpenSSL)")
ssl_context_options &= ~ssl.OP_NO_TLSv1_1
elif entry == "TLSv1.2":
logger.debug("SSL context options, enable TLSv1.2")
ssl_context_options &= ~ssl.OP_NO_TLSv1_2
elif entry == "TLSv1.3":
logger.debug("SSL context options, enable TLSv1.3")
ssl_context_options &= ~ssl.OP_NO_TLSv1_3
elif entry == "-ALL":
logger.debug("SSL context options, disable ALL")
ssl_context_options |= ssl.OP_NO_SSLv2
ssl_context_options |= ssl.OP_NO_SSLv3
ssl_context_options |= ssl.OP_NO_TLSv1
ssl_context_options |= ssl.OP_NO_TLSv1_1
ssl_context_options |= ssl.OP_NO_TLSv1_2
ssl_context_options |= ssl.OP_NO_TLSv1_3
elif entry == "-SSLv2":
ssl_context_options |= ssl.OP_NO_SSLv2
logger.debug("SSL context options, disable SSLv2")
elif entry == "-SSLv3":
ssl_context_options |= ssl.OP_NO_SSLv3
logger.debug("SSL context options, disable SSLv3")
elif entry == "-TLSv1":
logger.debug("SSL context options, disable TLSv1")
ssl_context_options |= ssl.OP_NO_TLSv1
elif entry == "-TLSv1.1":
logger.debug("SSL context options, disable TLSv1.1")
ssl_context_options |= ssl.OP_NO_TLSv1_1
elif entry == "-TLSv1.2":
logger.debug("SSL context options, disable TLSv1.2")
ssl_context_options |= ssl.OP_NO_TLSv1_2
elif entry == "-TLSv1.3":
logger.debug("SSL context options, disable TLSv1.3")
ssl_context_options |= ssl.OP_NO_TLSv1_3
else:
raise RuntimeError("SSL protocol config contains unsupported entry '%s'" % (entry))

logger.debug("SSL resulting context options: '0x%x'", ssl_context_options)
return ssl_context_options


def ssl_context_minimum_version_by_options(ssl_context_options):
logger.debug("SSL calculate minimum version by context options: '0x%x'", ssl_context_options)
ssl_context_minimum_version = ssl.TLSVersion.SSLv3 # default
if ((ssl_context_options & ssl.OP_NO_SSLv3) and (ssl_context_minimum_version == ssl.TLSVersion.SSLv3)):
ssl_context_minimum_version = ssl.TLSVersion.TLSv1
if ((ssl_context_options & ssl.OP_NO_TLSv1) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1)):
ssl_context_minimum_version = ssl.TLSVersion.TLSv1_1
if ((ssl_context_options & ssl.OP_NO_TLSv1_1) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1_1)):
ssl_context_minimum_version = ssl.TLSVersion.TLSv1_2
if ((ssl_context_options & ssl.OP_NO_TLSv1_2) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1_2)):
ssl_context_minimum_version = ssl.TLSVersion.TLSv1_3
if ((ssl_context_options & ssl.OP_NO_TLSv1_3) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1_3)):
ssl_context_minimum_version = 0 # all disabled

logger.debug("SSL context options: '0x%x' results in minimum version: %s", ssl_context_options, ssl_context_minimum_version)
return ssl_context_minimum_version


def ssl_context_maximum_version_by_options(ssl_context_options):
logger.debug("SSL calculate maximum version by context options: '0x%x'", ssl_context_options)
ssl_context_maximum_version = ssl.TLSVersion.TLSv1_3 # default
if ((ssl_context_options & ssl.OP_NO_TLSv1_3) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1_3)):
ssl_context_maximum_version = ssl.TLSVersion.TLSv1_2
if ((ssl_context_options & ssl.OP_NO_TLSv1_2) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1_2)):
ssl_context_maximum_version = ssl.TLSVersion.TLSv1_1
if ((ssl_context_options & ssl.OP_NO_TLSv1_1) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1_1)):
ssl_context_maximum_version = ssl.TLSVersion.TLSv1
if ((ssl_context_options & ssl.OP_NO_TLSv1) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1)):
ssl_context_maximum_version = ssl.TLSVersion.SSLv3
if ((ssl_context_options & ssl.OP_NO_SSLv3) and (ssl_context_maximum_version == ssl.TLSVersion.SSLv3)):
ssl_context_maximum_version = 0

logger.debug("SSL context options: '0x%x' results in maximum version: %s", ssl_context_options, ssl_context_maximum_version)
return ssl_context_maximum_version


def ssl_get_protocols(context):
protocols = []
if not (context.options & ssl.OP_NO_SSLv3):
if (context.minimum_version < ssl.TLSVersion.TLSv1):
protocols.append("SSLv3")
if not (context.options & ssl.OP_NO_TLSv1):
if (context.minimum_version < ssl.TLSVersion.TLSv1_1) and (context.maximum_version >= ssl.TLSVersion.TLSv1):
protocols.append("TLSv1")
if not (context.options & ssl.OP_NO_TLSv1_1):
if (context.minimum_version < ssl.TLSVersion.TLSv1_2) and (context.maximum_version >= ssl.TLSVersion.TLSv1_1):
protocols.append("TLSv1.1")
if not (context.options & ssl.OP_NO_TLSv1_2):
if (context.minimum_version <= ssl.TLSVersion.TLSv1_2) and (context.maximum_version >= ssl.TLSVersion.TLSv1_2):
protocols.append("TLSv1.2")
if not (context.options & ssl.OP_NO_TLSv1_3):
if (context.minimum_version <= ssl.TLSVersion.TLSv1_3) and (context.maximum_version >= ssl.TLSVersion.TLSv1_3):
protocols.append("TLSv1.3")
return protocols
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
# Only enable default tests (https://github.com/PyCQA/flake8/issues/790#issuecomment-812823398)
# DNE: DOES-NOT-EXIST
select = E,F,W,C90,DNE000
ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501
ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501,E261
extend-exclude = build

0 comments on commit 0f9bf4c

Please sign in to comment.