2
0
mirror of https://github.com/xcat2/confluent.git synced 2026-01-11 02:22:31 +00:00

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.
This commit is contained in:
Jarrod Johnson
2025-10-24 20:02:51 -04:00
parent 762adb882a
commit 3125f4171b
4 changed files with 172 additions and 41 deletions

View File

@@ -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
#<Name(C=US,ST=NC,L=RTP,O=Lenovo,CN=XCC-7D9D-J102MM2T)>
#>>> b.subject
#<Name(C=US,ST=NC,L=RTP,O=Lenovo,CN=XCC-7D9D-J102MM2T)>
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)

View File

@@ -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 /<prefixlen> 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. '

View File

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

View File

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