From 3125f4171b29f9cf645aaaf6a627e7207698bf93 Mon Sep 17 00:00:00 2001 From: Jarrod Johnson Date: Fri, 24 Oct 2025 20:02:51 -0400 Subject: [PATCH] Begin overhaul of TLS cert management Begin expanding certutil to sign other certificates from external CSRs more easily. Have certutil make the CA constraint critical. Have the fingerprint based validator have a mechanism to check for properly signed certificate in lieu of exact match, and update the stored fingerprint on match. Provide a means to request a custom subject when evaluating a target. Change redfish plugin to set that subject in the verifier. --- confluent_server/confluent/certutil.py | 101 +++++++++++------- .../confluent/config/attributes.py | 8 +- .../plugins/hardwaremanagement/redfish.py | 6 +- confluent_server/confluent/util.py | 98 ++++++++++++++++- 4 files changed, 172 insertions(+), 41 deletions(-) diff --git a/confluent_server/confluent/certutil.py b/confluent_server/confluent/certutil.py index 46ae2f69..06831e8e 100644 --- a/confluent_server/confluent/certutil.py +++ b/confluent_server/confluent/certutil.py @@ -179,6 +179,16 @@ def assure_tls_ca(): finally: os.seteuid(ouid) +#def is_self_signed(pem): +# cert = ssl.PEM_cert_to_DER_cert(pem) +# return cert.get('subjectAltName', []) == cert.get('issuer', []) +# x509 certificate issuer subject comparison.. +#>>> b.issuer +# +#>>> b.subject +# + + def substitute_cfg(setting, key, val, newval, cfgfile, line): if key.strip() == setting: cfgfile.write(line.replace(val, newval) + '\n') @@ -266,8 +276,9 @@ def create_simple_ca(keyout, certout): finally: os.remove(tmpconfig) -def create_certificate(keyout=None, certout=None, csrout=None): - if not keyout: +def create_certificate(keyout=None, certout=None, csrfile=None, subj=None, san=None): + tlsmateriallocation = {} + if not certout: tlsmateriallocation = get_certificate_paths() keyout = tlsmateriallocation.get('keys', [None])[0] certout = tlsmateriallocation.get('certs', [None])[0] @@ -276,60 +287,64 @@ def create_certificate(keyout=None, certout=None, csrout=None): if not keyout or not certout: raise Exception('Unable to locate TLS certificate path automatically') assure_tls_ca() - shortname = socket.gethostname().split('.')[0] - longname = shortname # socket.getfqdn() - if not csrout: + if not subj: + shortname = socket.gethostname().split('.')[0] + longname = shortname # socket.getfqdn() + subj = '/CN={0}'.format(longname) + elif '/CN=' not in subj: + subj = '/CN={0}'.format(subj) + if not csrfile: subprocess.check_call( ['openssl', 'ecparam', '-name', 'secp384r1', '-genkey', '-out', keyout]) - ipaddrs = list(get_ip_addresses()) - san = ['IP:{0}'.format(x) for x in ipaddrs] - # It is incorrect to put IP addresses as DNS type. However - # there exists non-compliant clients that fail with them as IP - # san.extend(['DNS:{0}'.format(x) for x in ipaddrs]) - dnsnames = set(ipaddrs) - dnsnames.add(shortname) - for currip in ipaddrs: - dnsnames.add(socket.getnameinfo((currip, 0), 0)[0]) - for currname in dnsnames: - san.append('DNS:{0}'.format(currname)) - #san.append('DNS:{0}'.format(longname)) - san = ','.join(san) + if not san: + ipaddrs = list(get_ip_addresses()) + san = ['IP:{0}'.format(x) for x in ipaddrs] + # It is incorrect to put IP addresses as DNS type. However + # there exists non-compliant clients that fail with them as IP + # san.extend(['DNS:{0}'.format(x) for x in ipaddrs]) + dnsnames = set(ipaddrs) + dnsnames.add(shortname) + for currip in ipaddrs: + dnsnames.add(socket.getnameinfo((currip, 0), 0)[0]) + for currname in dnsnames: + san.append('DNS:{0}'.format(currname)) + #san.append('DNS:{0}'.format(longname)) + san = ','.join(san) sslcfg = get_openssl_conf_location() tmphdl, tmpconfig = tempfile.mkstemp() os.close(tmphdl) tmphdl, extconfig = tempfile.mkstemp() os.close(tmphdl) needcsr = False - if csrout is None: + if csrfile is None: needcsr = True - tmphdl, csrout = tempfile.mkstemp() + tmphdl, csrfile = tempfile.mkstemp() os.close(tmphdl) shutil.copy2(sslcfg, tmpconfig) try: + with open(extconfig, 'a') as cfgfile: + cfgfile.write('\nbasicConstraints=critical,CA:false\nsubjectAltName={0}'.format(san)) if needcsr: with open(tmpconfig, 'a') as cfgfile: cfgfile.write('\n[SAN]\nsubjectAltName={0}'.format(san)) - with open(extconfig, 'a') as cfgfile: - cfgfile.write('\nbasicConstraints=CA:false\nsubjectAltName={0}'.format(san)) subprocess.check_call([ - 'openssl', 'req', '-new', '-key', keyout, '-out', csrout, '-subj', - '/CN={0}'.format(longname), - '-extensions', 'SAN', '-config', tmpconfig + 'openssl', 'req', '-new', '-key', keyout, '-out', csrfile, '-subj', + subj, '-extensions', 'SAN', '-config', tmpconfig ]) - else: - # when used manually, allow the csr SAN to stand - # may add explicit subj/SAN argument, in which case we would skip copy - with open(tmpconfig, 'a') as cfgfile: - cfgfile.write('\ncopy_extensions=copy\n') - with open(extconfig, 'a') as cfgfile: - cfgfile.write('\nbasicConstraints=CA:false\n') + #else: + # # when used manually, allow the csr SAN to stand + # # may add explicit subj/SAN argument, in which case we would skip copy + # #with open(tmpconfig, 'a') as cfgfile: + # # cfgfile.write('\ncopy_extensions=copy\n') + # with open(extconfig, 'a') as cfgfile: + # cfgfile.write('\nbasicConstraints=CA:false\n') if os.path.exists('/etc/confluent/tls/cakey.pem'): # simple style CA in effect, make a random serial number and # hope for the best, and accept inability to backdate the cert serialnum = '0x' + ''.join(['{:02x}'.format(x) for x in bytearray(os.urandom(20))]) subprocess.check_call([ - 'openssl', 'x509', '-req', '-in', csrout, + 'openssl', 'x509', '-req', '-in', csrfile, '-CA', '/etc/confluent/tls/cacert.pem', '-CAkey', '/etc/confluent/tls/cakey.pem', '-set_serial', serialnum, '-out', certout, '-days', '27300', @@ -351,9 +366,9 @@ def create_certificate(keyout=None, certout=None, csrout=None): # with realcalock: # if we put it in server, we must lock it subprocess.check_call([ 'openssl', 'ca', '-config', cacfgfile, - '-in', csrout, '-out', certout, '-batch', '-notext', + '-in', csrfile, '-out', certout, '-batch', '-notext', '-startdate', '19700101010101Z', '-enddate', '21000101010101Z', - '-extfile', extconfig + '-extfile', extconfig, '-subj', subj ]) for keycopy in tlsmateriallocation.get('keys', []): if keycopy != keyout: @@ -381,7 +396,7 @@ def create_certificate(keyout=None, certout=None, csrout=None): finally: os.remove(tmpconfig) if needcsr: - os.remove(csrout) + os.remove(csrfile) print(extconfig) # os.remove(extconfig) @@ -389,10 +404,20 @@ if __name__ == '__main__': import sys outdir = os.getcwd() keyout = os.path.join(outdir, 'key.pem') - certout = os.path.join(outdir, sys.argv[2] + 'cert.pem') + certout = os.path.join(outdir, 'cert.pem') csrout = None + subj, san = (None, None) + try: + bindex = sys.argv.index('-b') + bmcnode = sys.argv.pop(bindex + 1) # Remove bmcnode argument + sys.argv.pop(bindex) # Remove -b flag + import confluent.config.configmanager as cfm + c = cfm.ConfigManager(None) + subj, san = util.get_bmc_subject_san(c, bmcnode) + except ValueError: + bindex = None try: csrout = sys.argv[1] except IndexError: csrout = None - create_certificate(keyout, certout, csrout) + create_certificate(keyout, certout, csrout, subj, san) diff --git a/confluent_server/confluent/config/attributes.py b/confluent_server/confluent/config/attributes.py index 72518ab2..26f77732 100644 --- a/confluent_server/confluent/config/attributes.py +++ b/confluent_server/confluent/config/attributes.py @@ -1,7 +1,7 @@ # vim: tabstop=4 shiftwidth=4 softtabstop=4 # Copyright 2014 IBM Corporation -# Copyright 2015-2019 Lenovo +# Copyright 2015-2025 Lenovo # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -408,6 +408,12 @@ node = { 'include / CIDR suffix to indicate subnet length, which is ' 'autodetected by default where possible.', }, + 'hardwaremanagement.manager_tls_name': { + 'description': 'A name to use in lieu of the value in hardwaremanagement.manager for ' + 'TLS certificate verification purposes. Some strategies involve a non-IP, ' + 'non-resolvable name, or this can be used to access by IP while using name-based ' + 'validation', + }, 'hardwaremanagement.method': { 'description': 'The method used to perform operations such as power ' 'control, get sensor data, get inventory, and so on. ' diff --git a/confluent_server/confluent/plugins/hardwaremanagement/redfish.py b/confluent_server/confluent/plugins/hardwaremanagement/redfish.py index 2158e629..1c7e211a 100644 --- a/confluent_server/confluent/plugins/hardwaremanagement/redfish.py +++ b/confluent_server/confluent/plugins/hardwaremanagement/redfish.py @@ -184,8 +184,12 @@ class IpmiCommandWrapper(ipmicommand.Command): (node,), ('secret.hardwaremanagementuser', 'collective.manager', 'secret.hardwaremanagementpassword', 'hardwaremanagement.manager'), self._attribschanged) + htn = cfm.get_node_attributes(node, 'hardwaremanagement.manager_tls_name') + subject = htn.get(node, {}).get('hardwaremanagement.manager_tls_name', {}).get('value', None) + if not subject: + subject = kwargs['bmc'] kv = util.TLSCertVerifier(cfm, node, - 'pubkeys.tls_hardwaremanager').verify_cert + 'pubkeys.tls_hardwaremanager', subject).verify_cert kwargs['verifycallback'] = kv try: super(IpmiCommandWrapper, self).__init__(**kwargs) diff --git a/confluent_server/confluent/util.py b/confluent_server/confluent/util.py index b4aaf1e3..4d46ee60 100644 --- a/confluent_server/confluent/util.py +++ b/confluent_server/confluent/util.py @@ -19,7 +19,9 @@ import base64 import confluent.exceptions as cexc import confluent.log as log +import glob import hashlib +import ipaddress try: import psutil except ImportError: @@ -31,6 +33,9 @@ import socket import ssl import struct import eventlet.green.subprocess as subprocess +import cryptography.x509 as x509 +import cryptography.x509.verification as verification + def mkdirp(path, mode=0o777): @@ -86,6 +91,49 @@ def list_interface_indexes(): return +def get_bmc_subject_san(configmanager, nodename, addip=None): + bmc_san = [] + subject = '' + ipas = set([]) + if addip: + ipas.add(addip) + dnsnames = set([]) + nodecfg = configmanager.get_node_attributes(nodename, + ('dns.domain', 'hardwaremanagement.manager', 'hardwaremanagement.manager_tls_name')) + bmcaddr = nodecfg.get(nodename, {}).get('hardwaremanagement.manager', {}).get('value', '') + domain = nodecfg.get(nodename, {}).get('dns.domain', {}).get('value', '') + isipv4 = False + if bmcaddr: + bmcaddr = bmcaddr.split('/', 1)[0] + bmcaddr = bmcaddr.split('%', 1)[0] + dnsnames.add(bmcaddr) + subject = bmcaddr + if ':' in bmcaddr: + ipas.add(bmcaddr) + dnsnames.add('{0}.ipv6-literal.net'.format(bmcaddr.replace(':', '-'))) + else: + try: + socket.inet_aton(bmcaddr) + isipv4 = True + ipas.add(bmcaddr) + except socket.error: + pass + if not isipv4: # neither ipv6 nor ipv4, should be a name + if domain and domain not in bmcaddr: + dnsnames.add('{0}.{1}'.format(bmcaddr, domain)) + bmcname = nodecfg.get(nodename, {}).get('hardwaremanagement.manager_tls_name', {}).get('value', '') + if bmcname: + subject = bmcname + dnsnames.add(bmcname) + if domain and domain not in bmcname: + dnsnames.add('{0}.{1}'.format(bmcname, domain)) + for dns in dnsnames: + bmc_san.append('DNS:{0}'.format(dns)) + for ip in ipas: + bmc_san.append('IP:{0}'.format(ip)) + return subject, ','.join(bmc_san) + + def list_ips(): # Used for getting addresses to indicate the multicast address # as well as getting all the broadcast addresses @@ -184,15 +232,51 @@ def cert_matches(fingerprint, certificate): return newfp and fingerprint == newfp +_polbuilder = None + + class TLSCertVerifier(object): - def __init__(self, configmanager, node, fieldname): + def __init__(self, configmanager, node, fieldname, subject=None): self.cfm = configmanager self.node = node self.fieldname = fieldname + self.subject = subject + + def verify_by_ca(self, certificate): + global _polbuilder + _polbuilder = None + if not _polbuilder: + certs = [] + for cert in glob.glob('/var/lib/confluent/public/site/tls/*.pem'): + with open(cert, 'rb') as certfile: + certs.extend(x509.load_pem_x509_certificates(certfile.read())) + if not certs: + return False + castore = verification.Store(certs) + _polbuilder = verification.PolicyBuilder() + eep = verification.ExtensionPolicy.permit_all().require_present( + x509.SubjectAlternativeName, verification.Criticality.AGNOSTIC, None).may_be_present( + x509.KeyUsage, verification.Criticality.AGNOSTIC, None) + cap = verification.ExtensionPolicy.webpki_defaults_ca().require_present( + x509.BasicConstraints, verification.Criticality.AGNOSTIC, None).may_be_present( + x509.KeyUsage, verification.Criticality.AGNOSTIC, None) + _polbuilder = _polbuilder.store(castore).extension_policies( + ee_policy=eep, ca_policy=cap) + try: + addr = ipaddress.ip_address(self.subject) + subject = x509.IPAddress(addr) + except ValueError: + subject = x509.DNSName(self.subject) + cert = x509.load_der_x509_certificate(certificate) + _polbuilder.build_server_verifier(subject).verify(cert, []) + return True + + def verify_cert(self, certificate): storedprint = self.cfm.get_node_attributes(self.node, (self.fieldname,) ) + if (self.fieldname not in storedprint[self.node] or storedprint[self.node][self.fieldname]['value'] == ''): # no stored value, check policy for next action @@ -220,6 +304,18 @@ class TLSCertVerifier(object): certificate): return True fingerprint = get_fingerprint(certificate, 'sha256') + # Mismatches, but try more traditional validation using the site CAs + if self.subject: + try: + if self.verify_by_ca(certificate): + auditlog = log.Logger('audit') + auditlog.log({'node': self.node, 'event': 'certautoupdate', + 'fingerprint': fingerprint}) + self.cfm.set_node_attributes( + {self.node: {self.fieldname: fingerprint}}) + return True + except Exception: + pass raise cexc.PubkeyInvalid( 'Mismatched certificate detected', certificate, fingerprint, self.fieldname, 'mismatch')