diff --git a/plist b/plist index 5f68b5d72..73bddef9d 100644 --- a/plist +++ b/plist @@ -1417,6 +1417,7 @@ /usr/local/opnsense/site-python/log_helper.py /usr/local/opnsense/site-python/params.py /usr/local/opnsense/site-python/sqlite3_helper.py +/usr/local/opnsense/site-python/tls_helper.py /usr/local/opnsense/site-python/watchers/__init__.py /usr/local/opnsense/site-python/watchers/dhcpd.py /usr/local/opnsense/version/core diff --git a/src/opnsense/scripts/system/update-crl-fetch.py b/src/opnsense/scripts/system/update-crl-fetch.py index 60d9fb70d..53fef42f3 100755 --- a/src/opnsense/scripts/system/update-crl-fetch.py +++ b/src/opnsense/scripts/system/update-crl-fetch.py @@ -31,11 +31,13 @@ import ipaddress import sys import os import time -import requests +sys.path.insert(0, "/usr/local/opnsense/site-python") +import tls_helper from cryptography import x509 from cryptography.hazmat.primitives import serialization from cryptography.x509.extensions import CRLDistributionPoints + def fetch_certs(domains): result = [] for domain in domains: @@ -50,7 +52,7 @@ def fetch_certs(domains): url = 'https://%s' % domain try: print('# [i] fetch certificate for %s' % url) - with requests.get(url, timeout=30, stream=True) as response: + with tls_helper.RequestsWrapper().get(url, timeout=30, stream=True) as response: # XXX: in python > 3.13, replace with sock.get_verified_chain() for cert in response.raw.connection.sock._sslobj.get_verified_chain(): result.append({'domain': domain, 'depth': depth, 'pem': cert.public_bytes(1).encode()}) # _ssl.ENCODING_PEM @@ -61,6 +63,7 @@ def fetch_certs(domains): return result + def main(domains, target, lifetime): crl_index = target + 'index' crl_bundle = [] @@ -83,7 +86,7 @@ def main(domains, target, lifetime): dp_uri = Distributionpoint.full_name[0].value print("# [i] fetch CRL from %s" % dp_uri) # XXX: only support http for now - response = requests.get(dp_uri) + response = tls_helper.RequestsWrapper().get(dp_uri) if 200 <= response.status_code <= 299: crl = x509.load_der_x509_crl(response.content) crl_bundle.append({"domain": fetched['domain'], "depth": fetched['depth'], "name": str(cert.subject), "data": crl.public_bytes(serialization.Encoding.PEM).decode().strip()}) diff --git a/src/opnsense/site-python/tls_helper.py b/src/opnsense/site-python/tls_helper.py new file mode 100644 index 000000000..a4cf7680b --- /dev/null +++ b/src/opnsense/site-python/tls_helper.py @@ -0,0 +1,111 @@ +""" + Copyright (c) 2024 Ad Schellevis + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. +""" + +import os +import requests +import ssl +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.ssl_ import create_urllib3_context +from collections import OrderedDict +from configparser import ConfigParser + + +class IniDict(OrderedDict): + def __setitem__(self, key, value): + if isinstance(value, list) and key in self: + self[key].extend(value) + else: + super().__setitem__(key, value) + + +class PlatformTLSAdaptor(HTTPAdapter): + ssl_context = None + @classmethod + def get_sslcontext(cls): + if cls.ssl_context is None: + openssl_conf = { + 'cipherstring' : None, + 'minprotocol': None, + # XXX: Curves (very) limited supported + 'groups' : None, + # XXX: not supported, but openssl.cfg settings do seem to apply here. + 'signaturealgorithms' : None, + # XXX: TLS1.3 modifications not supported, openssl.cnf defaults should apply + # https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers + 'ciphersuites' : None, + } + conf_file = '/usr/local/openssl/openssl.cnf' + conf_section = 'system_default_sect' + if os.path.isfile(conf_file): + cnf = ConfigParser(strict=False, dict_type=IniDict) + data = "[openssl]\n%s" % open(conf_file, 'r').read() + cnf.read_string(data) + if cnf.has_section(conf_section): + for option in cnf.options(conf_section): + if option in openssl_conf: + openssl_conf[option] = cnf.get(conf_section, option) + + ctx_args = {} + if openssl_conf['cipherstring']: + ctx_args['ciphers'] = openssl_conf['cipherstring'] + + cls.ssl_context = create_urllib3_context(**ctx_args) + cls.ssl_context.load_verify_locations('/etc/ssl/cert.pem') + if openssl_conf['minprotocol']: + for item in openssl_conf['minprotocol'].split("\n"): + if item == 'TLSv1': + cls.ssl_context.minimum_version = ssl.TLSVersion.TLSv1 + elif item == 'TLSv1.1': + cls.ssl_context.minimum_version = ssl.TLSVersion.TLSv1_1 + elif item == 'TLSv1.2': + cls.ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 + elif item == 'TLSv1.3': + cls.ssl_context.minimum_version = ssl.TLSVersion.TLSv1_3 + if openssl_conf['groups']: + # XXX: We can only select a single curve here instead of a group + # although this is very flaky, we should choose one to prevent accepting ffdhe2048 + # which is enabled by default in openssl. + cls.ssl_context.set_ecdh_curve(openssl_conf['groups'].split(':')[0]) + + return cls.ssl_context + + def init_poolmanager(self, *args, **kwargs): + kwargs['ssl_context'] = self.get_sslcontext() + return super().init_poolmanager(*args, **kwargs) + def proxy_manager_for(self, *args, **kwargs): + kwargs['ssl_context'] = self.get_sslcontext() + return super().proxy_manager_for(*args, **kwargs) + +class RequestsWrapper: + def get(self, *args, **kwargs): + s = requests.Session() + s.mount('https://', PlatformTLSAdaptor()) + return s.get(*args, **kwargs) + + def post(self, *args, **kwargs): + s = requests.Session() + s.mount('https://', PlatformTLSAdaptor()) + return s.post(*args, **kwargs)