Merge "Validate X509 certificate inputs"

This commit is contained in:
Zuul 2022-05-10 14:10:56 +00:00 committed by Gerrit Code Review
commit b27c840785
3 changed files with 123 additions and 14 deletions

View File

@ -24,6 +24,8 @@
import os
from base64 import b64decode
from charmhelpers.core import host
from charmhelpers.core.hookenv import (
config as config_get,
@ -31,8 +33,13 @@ from charmhelpers.core.hookenv import (
relation_ids,
related_units as relation_list,
log,
ERROR,
INFO,
)
from charmhelpers.contrib.openstack.cert_utils import (
x509_get_pubkey,
x509_validate_cert,
)
# This file contains the CA cert from the charms ssl_ca configuration
# option, in future the file name should be updated reflect that.
@ -61,6 +68,11 @@ def get_cert(cn=None):
if not key:
key = relation_get(ssl_key_attr,
rid=r_id, unit=unit)
# this likely fails too quietly, raise?
if not x509_validate_cert(b64decode(cert), ssl_key=b64decode(key)):
return (None, None)
return (cert, key)
@ -75,6 +87,11 @@ def get_ca_cert():
if ca_cert is None:
ca_cert = relation_get('ca_cert',
rid=r_id, unit=unit)
# this likely fails too quietly, raise?
if not x509_get_pubkey(b64decode(ca_cert)):
return None
return ca_cert

View File

@ -16,8 +16,12 @@
import os
import json
import subprocess
import tempfile
from base64 import b64decode
import six
from charmhelpers.contrib.network.ip import (
get_hostname,
resolve_network_cidr,
@ -34,6 +38,7 @@ from charmhelpers.core.hookenv import (
log,
WARNING,
INFO,
ERROR,
)
from charmhelpers.contrib.openstack.ip import (
resolve_address,
@ -53,9 +58,9 @@ from charmhelpers.core.host import (
write_file,
)
from charmhelpers.contrib.hahelpers.apache import (
CONFIG_CA_CERT_FILE,
)
# This file contains the CA cert from the charms ssl_ca configuration
# option, in future the file name should be updated reflect that.
CONFIG_CA_CERT_FILE = 'keystone_juju_ca_cert'
class CertRequest(object):
@ -337,7 +342,7 @@ def _manage_ca_certs(ca, cert_relation_id):
"""
config_ssl_ca = config('ssl_ca')
config_cert_file = ca_cert_absolute_path(CONFIG_CA_CERT_FILE)
if config_ssl_ca:
if config_ssl_ca and x509_get_pubkey(b64decode(config_ssl_ca)):
log("Installing CA certificate from charm ssl_ca config to {}".format(
config_cert_file), INFO)
install_ca_cert(
@ -346,10 +351,12 @@ def _manage_ca_certs(ca, cert_relation_id):
elif os.path.exists(config_cert_file):
log("Removing CA certificate {}".format(config_cert_file), INFO)
os.remove(config_cert_file)
log("Installing CA certificate from certificate relation", INFO)
install_ca_cert(
ca.encode(),
name=get_cert_relation_ca_name(cert_relation_id))
if x509_get_pubkey(ca.encode()):
log("Installing CA certificate from certificate relation", INFO)
install_ca_cert(
ca.encode(),
name=get_cert_relation_ca_name(cert_relation_id))
def process_certificates(service_name, relation_id, unit,
@ -441,3 +448,79 @@ def get_bundle_for_cn(cn, relation_name=None):
if cert_bundle:
break
return cert_bundle
def _x509_normalize_input(input_):
if isinstance(input_, six.text_type):
return input_.encode()
if isinstance(input_, bytes):
return input_
return bytes()
def x509_validate_cert_chain(ssl_cert, ssl_ca=None):
cmd = ['openssl', 'verify']
with tempfile.TemporaryFile() as cert_fd, tempfile.NamedTemporaryFile() as ca_fd:
cert_fd.write(ssl_cert); cert_fd.seek(0) # noqa: E702
if ssl_ca:
ca_fd.write(ssl_ca); ca_fd.seek(0) # noqa: E702
cmd.extend(['-CAfile', ca_fd.name])
try:
subprocess.check_output(cmd, stdin=cert_fd)
return True
except subprocess.CalledProcessError as e:
log('Chain verification exited with code {} and output:\n{}'.format(
e.returncode, e.output.decode()), level=ERROR)
return False
raise Exception('Error during certificate chain validation')
def x509_get_pubkey(input_, private=False):
# since we don't want to leak private key material and generally wouldn't
# want to loiter on the filesystem more than it's necessary, we can't use
# `ssl.SSLContext.load_cert_chain` to confirm cert-key parity without
# dropping `tempfile.NamedTemporaryFile`s and passing their paths in
#
# issue with this approach is that any sort of exception condition may let
# these files linger, posing a leakage risk, so the best we can afford is
# `tempfile.TemporaryFile` which immediately removes filesystem-mappable
# paths, while keeping a file descriptor, sufficient for passing as stdin
# to subprocess calls
input_ = _x509_normalize_input(input_)
if not input_:
return bytes()
cmd = ['openssl']
cmd += ['pkey', '-pubout'] if private else ['x509', '-pubkey', '-noout']
cmd += ['-in', '/dev/stdin']
with tempfile.TemporaryFile() as fd:
fd.write(input_); fd.seek(0) # noqa: E702
try:
return subprocess.check_output(cmd, stdin=fd).strip()
except subprocess.CalledProcessError as e:
log('Getting public key exited with code {} and output:\n{}'.format(
e.returncode, e.output.decode()), level=ERROR)
return bytes()
def x509_validate_cert_parity(ssl_cert, ssl_key):
priv = x509_get_pubkey(ssl_key, private=True)
pub = x509_get_pubkey(ssl_cert)
return all([priv, pub, priv == pub])
def x509_validate_cert(ssl_cert, ssl_key=None, ssl_ca=None, validate_chain=False):
# normalize string input types
ssl_cert = _x509_normalize_input(ssl_cert)
ssl_key = _x509_normalize_input(ssl_key)
ssl_ca = _x509_normalize_input(ssl_ca)
return all([
x509_validate_cert_parity(
ssl_cert, ssl_key) if ssl_cert and ssl_key else True,
x509_validate_cert_chain(ssl_cert, ssl_ca) if validate_chain else True
])

View File

@ -87,6 +87,10 @@ from charmhelpers.contrib.hahelpers.apache import (
get_ca_cert,
install_ca_cert,
)
from charmhelpers.contrib.openstack.cert_utils import (
x509_get_pubkey,
x509_validate_cert,
)
from charmhelpers.contrib.openstack.neutron import (
neutron_plugin_attribute,
parse_data_port_mappings,
@ -313,17 +317,22 @@ class PostgresqlDBContext(OSContextGenerator):
def db_ssl(rdata, ctxt, ssl_dir):
if 'ssl_ca' in rdata and ssl_dir:
ssl_ca = b64decode(rdata.get('ssl_ca', bytes()))
if 'ssl_ca' in rdata and x509_get_pubkey(ssl_ca) and ssl_dir:
ca_path = os.path.join(ssl_dir, 'db-client.ca')
with open(ca_path, 'wb') as fh:
fh.write(b64decode(rdata['ssl_ca']))
fh.write(ssl_ca)
ctxt['database_ssl_ca'] = ca_path
elif 'ssl_ca' in rdata:
log("Charm not setup for ssl support but ssl ca found", level=INFO)
return ctxt
if 'ssl_cert' in rdata:
ssl_cert = b64decode(rdata.get('ssl_cert', bytes()))
ssl_key = b64decode(rdata.get('ssl_key', bytes()))
if 'ssl_cert' in rdata and x509_validate_cert(
ssl_cert, ssl_key, ssl_ca.decode() if ssl_ca else None
):
cert_path = os.path.join(
ssl_dir, 'db-client.cert')
if not os.path.exists(cert_path):
@ -331,12 +340,12 @@ def db_ssl(rdata, ctxt, ssl_dir):
time.sleep(60)
with open(cert_path, 'wb') as fh:
fh.write(b64decode(rdata['ssl_cert']))
fh.write(ssl_cert)
ctxt['database_ssl_cert'] = cert_path
key_path = os.path.join(ssl_dir, 'db-client.key')
with open(key_path, 'wb') as fh:
fh.write(b64decode(rdata['ssl_key']))
fh.write(ssl_key)
ctxt['database_ssl_key'] = key_path
@ -697,7 +706,7 @@ class AMQPContext(OSContextGenerator):
rabbitmq_port = ssl_port
ssl_ca = relation_get('ssl_ca', rid=rid, unit=unit)
if ssl_ca:
if ssl_ca and x509_get_pubkey(b64decode(ssl_ca)):
ctxt['rabbit_ssl_ca'] = ssl_ca
if relation_get('ha_queues', rid=rid, unit=unit) is not None: