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')