charm-vault/src/lib/charm/vault_pki.py

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)