541 lines
20 KiB
Python
541 lines
20 KiB
Python
import hvac
|
|
import json
|
|
|
|
from subprocess import check_output, CalledProcessError
|
|
from tempfile import NamedTemporaryFile
|
|
|
|
import charmhelpers.contrib.network.ip as ch_ip
|
|
import charmhelpers.core.hookenv as hookenv
|
|
|
|
from . import vault
|
|
|
|
CHARM_PKI_MP = "charm-pki-local"
|
|
CHARM_PKI_ROLE = "local"
|
|
CHARM_PKI_ROLE_CLIENT = "local-client"
|
|
|
|
PKI_CACHE_KEY = "pki"
|
|
TOP_LEVEL_CERT_KEY = "top_level"
|
|
|
|
|
|
def configure_pki_backend(client, name, ttl=None, max_ttl=None):
|
|
"""Ensure a pki backend is enabled
|
|
|
|
:param client: Vault client
|
|
:type client: hvac.Client
|
|
:param name: Name of backend to enable
|
|
:type name: str
|
|
:param ttl: TTL
|
|
:type ttl: str
|
|
"""
|
|
if not vault.is_backend_mounted(client, name):
|
|
client.enable_secret_backend(
|
|
backend_type='pki',
|
|
description='Charm created PKI backend',
|
|
mount_point=name,
|
|
# Default ttl to 10 years
|
|
config={
|
|
'default_lease_ttl': ttl or '8759h',
|
|
'max_lease_ttl': max_ttl or '87600h'})
|
|
|
|
|
|
def disable_pki_backend():
|
|
"""Ensure a pki backend is disabled
|
|
"""
|
|
client = vault.get_local_client()
|
|
if vault.is_backend_mounted(client, CHARM_PKI_MP):
|
|
client.delete('{}/root'.format(CHARM_PKI_MP))
|
|
client.delete('{}/roles/{}'.format(CHARM_PKI_MP,
|
|
CHARM_PKI_ROLE_CLIENT))
|
|
client.delete('{}/roles/{}'.format(CHARM_PKI_MP,
|
|
CHARM_PKI_ROLE))
|
|
client.disable_secret_backend(CHARM_PKI_MP)
|
|
|
|
|
|
def tune_pki_backend(ttl=None, max_ttl=None):
|
|
"""Assert tuning options for Charm PKI backend
|
|
|
|
:param ttl: TTL
|
|
:type ttl: str
|
|
"""
|
|
client = vault.get_local_client()
|
|
if vault.is_backend_mounted(client, CHARM_PKI_MP):
|
|
client.tune_secret_backend(
|
|
backend_type='pki',
|
|
mount_point=CHARM_PKI_MP,
|
|
default_lease_ttl=ttl or '8759h',
|
|
max_lease_ttl=max_ttl or '87600h')
|
|
|
|
|
|
def is_ca_ready(client, name, role):
|
|
"""Check if CA is ready for use
|
|
|
|
:returns: Whether CA is ready
|
|
:rtype: bool
|
|
"""
|
|
return client.read('{}/roles/{}'.format(name, role)) is not None
|
|
|
|
|
|
def get_chain(name=None):
|
|
"""Check if CA is ready for use
|
|
|
|
:returns: Whether CA is ready
|
|
:rtype: bool
|
|
"""
|
|
client = vault.get_local_client()
|
|
if not name:
|
|
name = CHARM_PKI_MP
|
|
return client.read('{}/cert/ca_chain'.format(name))['data']['certificate']
|
|
|
|
|
|
def get_ca():
|
|
"""Get the root CA certificate.
|
|
|
|
:returns: Root CA certificate
|
|
:rtype: str
|
|
"""
|
|
return hookenv.leader_get('root-ca')
|
|
|
|
|
|
def generate_certificate(cert_type, common_name, sans, ttl, max_ttl):
|
|
"""
|
|
Create a certificate and key for the given CN and SANs, if requested.
|
|
|
|
May raise VaultNotReady if called too early, or VaultInvalidRequest if
|
|
something is wrong with the request.
|
|
|
|
:param request: Certificate request from the tls-certificates interface.
|
|
:type request: CertificateRequest
|
|
:returns: The newly created cert, issuing ca and key
|
|
:rtype: tuple
|
|
"""
|
|
client = vault.get_local_client()
|
|
configure_pki_backend(client, CHARM_PKI_MP, ttl, max_ttl)
|
|
if not is_ca_ready(client, CHARM_PKI_MP, CHARM_PKI_ROLE):
|
|
raise vault.VaultNotReady("CA not ready")
|
|
role = None
|
|
if cert_type == 'server':
|
|
role = CHARM_PKI_ROLE
|
|
elif cert_type == 'client':
|
|
role = CHARM_PKI_ROLE_CLIENT
|
|
else:
|
|
raise vault.VaultInvalidRequest('Unsupported cert_type: '
|
|
'{}'.format(cert_type))
|
|
config = {
|
|
'common_name': common_name,
|
|
}
|
|
if sans:
|
|
ip_sans, alt_names = sort_sans(sans)
|
|
if ip_sans:
|
|
config['ip_sans'] = ','.join(ip_sans)
|
|
if alt_names:
|
|
config['alt_names'] = ','.join(alt_names)
|
|
try:
|
|
response = client.write('{}/issue/{}'.format(CHARM_PKI_MP, role),
|
|
**config)
|
|
if not response['data']:
|
|
raise vault.VaultError(response.get('warnings', 'unknown error'))
|
|
except hvac.exceptions.InvalidRequest as e:
|
|
raise vault.VaultInvalidRequest(str(e)) from e
|
|
return response['data']
|
|
|
|
|
|
def get_csr(ttl=None, common_name=None, locality=None,
|
|
country=None, province=None,
|
|
organization=None, organizational_unit=None):
|
|
"""Generate a csr for the vault Intermediate Authority
|
|
|
|
Depending on the configuration of the CA signing this CR some of the
|
|
fields embedded in the CSR may have to match the CA.
|
|
|
|
:param ttl: TTL
|
|
:type ttl: string
|
|
:param country: The C (Country) values in the subject field of the CSR
|
|
:type country: string
|
|
:param province: The ST (Province) values in the subject field of the CSR.
|
|
:type province: string
|
|
:param organization: The O (Organization) values in the subject field of
|
|
the CSR
|
|
:type organization: string
|
|
:param organizational_unit: The OU (OrganizationalUnit) values in the
|
|
subject field of the CSR.
|
|
:type organizational_unit: string
|
|
:param common_name: The CN (Common_Name) values in the
|
|
subject field of the CSR.
|
|
:param locality: The L (Locality) values in the
|
|
subject field of the CSR.
|
|
:returns: Certificate signing request
|
|
:rtype: string
|
|
"""
|
|
client = vault.get_local_client()
|
|
configure_pki_backend(client, CHARM_PKI_MP)
|
|
config = {
|
|
# Year - 1 hour
|
|
'ttl': ttl or '87599h',
|
|
'country': country,
|
|
'province': province,
|
|
'ou': organizational_unit,
|
|
'organization': organization,
|
|
'common_name': common_name or ("Vault Intermediate Certificate "
|
|
"Authority " "({})".format(CHARM_PKI_MP)
|
|
),
|
|
'locality': locality}
|
|
config = {k: v for k, v in config.items() if v}
|
|
csr_info = client.write(
|
|
'{}/intermediate/generate/internal'.format(CHARM_PKI_MP),
|
|
**config)
|
|
if not csr_info['data']:
|
|
raise vault.VaultError(csr_info.get('warnings', 'unknown error'))
|
|
return csr_info['data']['csr']
|
|
|
|
|
|
def upload_signed_csr(pem, allowed_domains, allow_subdomains=True,
|
|
enforce_hostnames=False, allow_any_name=True,
|
|
max_ttl=None):
|
|
"""Upload signed csr to intermediate pki
|
|
|
|
:param pem: signed csr in pem format
|
|
:type pem: string
|
|
:param allow_subdomains: Specifies if clients can request certificates with
|
|
CNs that are subdomains of the CNs:
|
|
:type allow_subdomains: bool
|
|
:param enforce_hostnames: Specifies if only valid host names are allowed
|
|
for CNs, DNS SANs, and the host part of email
|
|
addresses.
|
|
:type enforce_hostnames: bool
|
|
:param allow_any_name: Specifies if clients can request any CN
|
|
:type allow_any_name: bool
|
|
:param max_ttl: Specifies the maximum Time To Live
|
|
:type max_ttl: str
|
|
"""
|
|
client = vault.get_local_client()
|
|
# Set the intermediate certificate authorities signing certificate to the
|
|
# signed certificate.
|
|
# (hvac module doesn't expose a method for this, hence the _post call)
|
|
client._post(
|
|
'v1/{}/intermediate/set-signed'.format(CHARM_PKI_MP),
|
|
json={'certificate': pem.rstrip()})
|
|
# Generated certificates can have the CRL location and the location of the
|
|
# issuing certificate encoded.
|
|
addr = vault.get_access_address()
|
|
client.write(
|
|
'{}/config/urls'.format(CHARM_PKI_MP),
|
|
issuing_certificates="{}/v1/{}/ca".format(addr, CHARM_PKI_MP),
|
|
crl_distribution_points="{}/v1/{}/crl".format(addr, CHARM_PKI_MP)
|
|
)
|
|
# Configure a role which maps to a policy for accessing this pki
|
|
if not max_ttl:
|
|
max_ttl = '87598h'
|
|
write_roles(client,
|
|
allow_any_name=allow_any_name,
|
|
allowed_domains=allowed_domains,
|
|
allow_subdomains=allow_subdomains,
|
|
enforce_hostnames=enforce_hostnames,
|
|
max_ttl=max_ttl,
|
|
client_flag=True)
|
|
|
|
|
|
def generate_root_ca(ttl='87599h', allow_any_name=True, allowed_domains=None,
|
|
allow_bare_domains=False, allow_subdomains=False,
|
|
allow_glob_domains=True, enforce_hostnames=False,
|
|
max_ttl='87598h'):
|
|
"""Configure Vault to generate a self-signed root CA.
|
|
|
|
:param ttl: TTL of the root CA certificate
|
|
:type ttl: string
|
|
:param allow_any_name: Specifies if clients can request certs for any CN.
|
|
:type allow_any_name: bool
|
|
:param allow_any_name: List of CNs for which clients can request certs.
|
|
:type allowed_domains: list
|
|
:param allow_bare_domains: Specifies if clients can request certs for CNs
|
|
exactly matching those in allowed_domains.
|
|
:type allow_bare_domains: bool
|
|
:param allow_subdomains: Specifies if clients can request certificates with
|
|
CNs that are subdomains of those in
|
|
allowed_domains, including wildcard subdomains.
|
|
:type allow_subdomains: bool
|
|
:param allow_glob_domains: Specifies whether CNs in allowed-domains can
|
|
contain glob patterns (e.g.,
|
|
'ftp*.example.com'), in which case clients will
|
|
be able to request certificates for any CN
|
|
matching the glob pattern.
|
|
:type allow_glob_domains: bool
|
|
:param enforce_hostnames: Specifies if only valid host names are allowed
|
|
for CNs, DNS SANs, and the host part of email
|
|
addresses.
|
|
:type enforce_hostnames: bool
|
|
:param max_ttl: Specifies the maximum Time To Live for generated certs.
|
|
:type max_ttl: str
|
|
"""
|
|
client = vault.get_local_client()
|
|
configure_pki_backend(client, CHARM_PKI_MP)
|
|
if is_ca_ready(client, CHARM_PKI_MP, CHARM_PKI_ROLE):
|
|
raise vault.VaultError('PKI CA already configured')
|
|
config = {
|
|
'common_name': ("Vault Root Certificate Authority "
|
|
"({})".format(CHARM_PKI_MP)),
|
|
'ttl': ttl,
|
|
}
|
|
csr_info = client.write(
|
|
'{}/root/generate/internal'.format(CHARM_PKI_MP),
|
|
**config)
|
|
if not csr_info['data']:
|
|
raise vault.VaultError(csr_info.get('warnings', 'unknown error'))
|
|
cert = csr_info['data']['certificate']
|
|
# Generated certificates can have the CRL location and the location of the
|
|
# issuing certificate encoded.
|
|
addr = vault.get_access_address()
|
|
client.write(
|
|
'{}/config/urls'.format(CHARM_PKI_MP),
|
|
issuing_certificates="{}/v1/{}/ca".format(addr, CHARM_PKI_MP),
|
|
crl_distribution_points="{}/v1/{}/crl".format(addr, CHARM_PKI_MP)
|
|
)
|
|
|
|
write_roles(client,
|
|
allow_any_name=allow_any_name,
|
|
allowed_domains=allowed_domains,
|
|
allow_bare_domains=allow_bare_domains,
|
|
allow_subdomains=allow_subdomains,
|
|
allow_glob_domains=allow_glob_domains,
|
|
enforce_hostnames=enforce_hostnames,
|
|
max_ttl=max_ttl,
|
|
client_flag=True)
|
|
return cert
|
|
|
|
|
|
def sort_sans(sans):
|
|
"""
|
|
Split SANs into IP SANs and name SANs
|
|
|
|
:param sans: List of SANs
|
|
:type sans: list
|
|
:returns: List of IP SANs and list of name SANs
|
|
:rtype: ([], [])
|
|
"""
|
|
ip_sans = {s for s in sans if ch_ip.is_ip(s)}
|
|
alt_names = set(sans).difference(ip_sans)
|
|
return sorted(list(ip_sans)), sorted(list(alt_names))
|
|
|
|
|
|
def write_roles(client, **kwargs):
|
|
# Configure a role for using this PKI to issue server certs
|
|
client.write(
|
|
'{}/roles/{}'.format(CHARM_PKI_MP, CHARM_PKI_ROLE),
|
|
server_flag=True,
|
|
**kwargs)
|
|
# Configure a role for using this PKI to issue client-only certs
|
|
client.write(
|
|
'{}/roles/{}'.format(CHARM_PKI_MP, CHARM_PKI_ROLE_CLIENT),
|
|
server_flag=False, # client certs cannot be used as server certs
|
|
**kwargs)
|
|
|
|
|
|
def update_roles(**kwargs):
|
|
client = vault.get_local_client()
|
|
# local and local-client contain the same data except for server_flag,
|
|
# so we only need to read one, but update both
|
|
local = client.read(
|
|
'{}/roles/{}'.format(CHARM_PKI_MP, CHARM_PKI_ROLE))['data']
|
|
# the reason we handle as kwargs here is because updating n-1 fields
|
|
# causes all the others to reset. Therefore we always need to read what
|
|
# the current values of all fields are, and apply all of them as well
|
|
# so they are not reset. In case of new fields are added in the future,
|
|
# this code makes sure that they are not reset automatically (if set
|
|
# somewhere else in code) when this function is invoked.
|
|
local.update(**kwargs)
|
|
del local['server_flag']
|
|
write_roles(client, **local)
|
|
|
|
|
|
def verify_cert(ca_cert, untrusted_cert):
|
|
"""Verify that the 'untrusted_cert' is signed by the 'ca_cert'.
|
|
|
|
:param ca_cert: CA certificate that should sign the untrusted cert.
|
|
:param untrusted_cert: Certificate that is verified by the CA cert.
|
|
:return: True if CA cert can verify the untrusted cert
|
|
:rtype: bool
|
|
"""
|
|
with NamedTemporaryFile() as ca_file, NamedTemporaryFile() as cert_file:
|
|
ca_file.write(ca_cert.encode("UTF-8"))
|
|
ca_file.flush()
|
|
|
|
cert_file.write(untrusted_cert.encode("UTF-8"))
|
|
cert_file.flush()
|
|
|
|
try:
|
|
verify_cmd = ['openssl', 'verify', '-CAfile',
|
|
ca_file.name, cert_file.name]
|
|
check_output(verify_cmd)
|
|
except CalledProcessError as exc:
|
|
hookenv.log(
|
|
"Certificate verification failed: {}".format(exc.output),
|
|
hookenv.WARNING
|
|
)
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
|
|
def get_pki_cache():
|
|
"""Fetch and parse PKI from the leader storage.
|
|
|
|
Returned dictionary contains certificates and keys issued by the vault
|
|
leader unit as a response to requests from other charms. The structure
|
|
loosely matches the format in which the certificates are shared via data
|
|
in the `tls-certificates` relation.
|
|
See `tls_certificates_common.CertificateRequest.set_cert()` for more info
|
|
on the structure.
|
|
|
|
:return: Dictionary containing certs and keys generated by the leader unit
|
|
:rtype: dict
|
|
"""
|
|
raw_cache = hookenv.leader_get(PKI_CACHE_KEY) or '{}'
|
|
return json.loads(raw_cache)
|
|
|
|
|
|
def find_cert_in_cache(request):
|
|
"""Return certificate and key from cache that match the request.
|
|
|
|
Returned certificate is validated against the current CA cert. If CA cert
|
|
is missing, or certificate fails validation or it's simply not found,
|
|
returned value is None, None
|
|
|
|
:param request: Request for certificate from "client" unit.
|
|
:type request: tls_certificates_common.CertificateRequest
|
|
:return: Certificate and private key from cache
|
|
:rtype: Union[(str, str), (None, None)]
|
|
"""
|
|
try:
|
|
ca_chain = get_chain()
|
|
except (hvac.exceptions.VaultDown, TypeError):
|
|
# Fetching CA chain may fail
|
|
ca_chain = None
|
|
|
|
ca_cert = ca_chain or get_ca()
|
|
if not ca_cert:
|
|
hookenv.log('CA cert not found. Skipping certificate cache lookup.',
|
|
hookenv.DEBUG)
|
|
return None, None
|
|
|
|
pki_cache = get_pki_cache()
|
|
unit_data = pki_cache.get(request.unit_name, {})
|
|
|
|
try:
|
|
if request._is_top_level_server_cert:
|
|
cert = unit_data[TOP_LEVEL_CERT_KEY][request._server_cert_key]
|
|
key = unit_data[TOP_LEVEL_CERT_KEY][request._server_key_key]
|
|
else:
|
|
cert = unit_data[request._publish_key][request.common_name]['cert']
|
|
key = unit_data[request._publish_key][request.common_name]['key']
|
|
except (KeyError, TypeError):
|
|
hookenv.log('Certificate for "{}" (cn: "{}") not found in '
|
|
'cache.'.format(request.unit_name, request.common_name),
|
|
hookenv.DEBUG)
|
|
return None, None
|
|
|
|
if verify_cert(ca_cert, cert):
|
|
return cert, key
|
|
else:
|
|
hookenv.log('Certificate from cache for "{}" (cn: "{}") is no longer'
|
|
'valid and wont be reused.'.format(request.unit_name,
|
|
request.common_name))
|
|
return None, None
|
|
|
|
|
|
def update_cert_cache(request, cert, key):
|
|
"""Store certificate and key in the cache.
|
|
|
|
Stored values are associated with the request from "client" unit,
|
|
so it can be later retrieved when the request is handled again.
|
|
|
|
:param request: Request for certificate from "client" unit.
|
|
:type request: tls_certificates_common.CertificateRequest
|
|
:param cert: Issued certificate for the "client" request (in PEM format)
|
|
:type cert: str
|
|
:param key: Issued private key from the "client" request (in PEM format)
|
|
:type key: str
|
|
:return: None
|
|
"""
|
|
pki_cache = get_pki_cache()
|
|
unit_cache = pki_cache.get(request.unit_name, {})
|
|
|
|
if request._is_top_level_server_cert:
|
|
unit_cache[TOP_LEVEL_CERT_KEY] = {
|
|
request._server_cert_key: cert,
|
|
request._server_key_key: key,
|
|
}
|
|
else:
|
|
structured_certs = unit_cache.get(request._publish_key, {})
|
|
structured_certs[request.common_name] = {
|
|
'cert': cert,
|
|
'key': key,
|
|
}
|
|
unit_cache[request._publish_key] = structured_certs
|
|
|
|
hookenv.log('Saving certificate for "{}" '
|
|
'(cn: "{}") into cache.'.format(request.unit_name,
|
|
request.common_name),
|
|
hookenv.DEBUG)
|
|
pki_cache[request.unit_name] = unit_cache
|
|
hookenv.leader_set({PKI_CACHE_KEY: json.dumps(pki_cache)})
|
|
|
|
|
|
def remove_unit_from_cache(unit_name):
|
|
"""Clear certificates and keys related to the unit from the cache.
|
|
|
|
:param unit_name: Name of the unit to be removed from the cache.
|
|
:type unit_name: str
|
|
:return: None
|
|
"""
|
|
hookenv.log('Removing certificates for unit "{}" from '
|
|
'cache.'.format(unit_name), hookenv.DEBUG)
|
|
pki_cache = get_pki_cache()
|
|
pki_cache.pop(unit_name, None)
|
|
hookenv.leader_set({PKI_CACHE_KEY: json.dumps(pki_cache)})
|
|
|
|
|
|
def populate_cert_cache(tls_endpoint):
|
|
"""Store previously issued certificates in the cache.
|
|
|
|
This function is used when vault charm is upgraded from older version
|
|
that may not have a certificate cache to a version that has it. It
|
|
goes through all previously issued certificates and stores them in
|
|
cache.
|
|
|
|
:param tls_endpoint: Endpoint of "certificates" relation
|
|
:type tls_endpoint: interface_tls_certificates.provides.TlsProvides
|
|
:return: None
|
|
"""
|
|
hookenv.log(
|
|
"Populating certificate cache with data from relations", hookenv.INFO
|
|
)
|
|
|
|
for request in tls_endpoint.all_requests:
|
|
try:
|
|
if request._is_top_level_server_cert:
|
|
relation_data = request._unit.relation.to_publish_raw
|
|
cert = relation_data[request._server_cert_key]
|
|
key = relation_data[request._server_key_key]
|
|
else:
|
|
relation_data = request._unit.relation.to_publish
|
|
cert = relation_data[request._publish_key][
|
|
request.common_name
|
|
]['cert']
|
|
key = relation_data[request._publish_key][
|
|
request.common_name
|
|
]['key']
|
|
except (KeyError, TypeError):
|
|
if request._is_top_level_server_cert:
|
|
cert_id = request._server_cert_key
|
|
else:
|
|
cert_id = request.common_name
|
|
hookenv.log(
|
|
'Certificate "{}" (or associated key) issued for unit "{}" '
|
|
'not found in relation data.'.format(
|
|
cert_id, request._unit.unit_name
|
|
),
|
|
hookenv.WARNING
|
|
)
|
|
continue
|
|
|
|
update_cert_cache(request, cert, key)
|