From b9aaec7fa6b4d8443e2fb515256fd55efab07429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lauri=20V=C3=B5sandi?= Date: Sun, 15 Apr 2018 19:27:22 +0000 Subject: [PATCH] Migrate renewal to mutually authenticated TLS connection --- certidude/api/request.py | 50 ++++------ certidude/authority.py | 10 +- certidude/cli.py | 126 ++++++++++++++----------- certidude/config.py | 3 +- certidude/const.py | 1 + certidude/templates/server/nginx.conf | 11 ++- certidude/templates/server/server.conf | 11 ++- 7 files changed, 111 insertions(+), 101 deletions(-) diff --git a/certidude/api/request.py b/certidude/api/request.py index 956fbfb..a6db1ce 100644 --- a/certidude/api/request.py +++ b/certidude/api/request.py @@ -4,7 +4,7 @@ import logging import json import os import hashlib -from asn1crypto import pem +from asn1crypto import pem, x509 from asn1crypto.csr import CertificationRequest from base64 import b64decode from certidude import config, push, errors @@ -89,43 +89,29 @@ class RequestListResource(AuthorityHandler): cert_pk = cert["tbs_certificate"]["subject_public_key_info"].native csr_pk = csr["certification_request_info"]["subject_pk_info"].native - if cert_pk == csr_pk: # Same public key, assume renewal - expires = cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None) - renewal_header = req.get_header("X-Renewal-Signature") + try: + buf = req.get_header("X-SSL-CERT") + header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii")) + handshake_cert = x509.Certificate.load(der_bytes) + except: + raise + else: + # Same public key + if cert_pk == csr_pk: + # Used mutually authenticated TLS handshake, assume renewal + if handshake_cert.native == cert.native: + for subnet in config.RENEWAL_SUBNETS: + if req.context.get("remote_addr") in subnet: + resp.set_header("Content-Type", "application/x-x509-user-cert") + _, resp.body = self.authority._sign(csr, body, overwrite=True) + logger.info("Renewing certificate for %s as %s is whitelisted", common_name, req.context.get("remote_addr")) + return - if not renewal_header: # No header supplied, redirect to signed API call resp.status = falcon.HTTP_SEE_OTHER resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", common_name) return - try: - renewal_signature = b64decode(renewal_header) - except (TypeError, ValueError): - logger.error("Renewal failed, bad signature supplied for %s", common_name) - reasons.append("Renewal failed, bad signature supplied") - else: - try: - asymmetric.rsa_pss_verify( - asymmetric.load_certificate(cert), - renewal_signature, buf + body, "sha512") - except SignatureError: - logger.error("Renewal failed, invalid signature supplied for %s", common_name) - reasons.append("Renewal failed, invalid signature supplied") - else: - # At this point renewal signature was valid but we need to perform some extra checks - if datetime.utcnow() > expires: - logger.error("Renewal failed, current certificate for %s has expired", common_name) - reasons.append("Renewal failed, current certificate expired") - elif not config.CERTIFICATE_RENEWAL_ALLOWED: - logger.error("Renewal requested for %s, but not allowed by authority settings", common_name) - reasons.append("Renewal requested, but not allowed by authority settings") - else: - resp.set_header("Content-Type", "application/x-x509-user-cert") - _, resp.body = self.authority._sign(csr, body, overwrite=True) - logger.info("Renewed certificate for %s", common_name) - return - """ Process automatic signing if the IP address is whitelisted, diff --git a/certidude/authority.py b/certidude/authority.py index d50dd63..19f93f3 100644 --- a/certidude/authority.py +++ b/certidude/authority.py @@ -21,8 +21,6 @@ from xattr import getxattr, listxattr, setxattr random = SystemRandom() -RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])(@(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]))?$" - # https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/ # https://jamielinux.com/docs/openssl-certificate-authority/ # http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py @@ -84,7 +82,7 @@ def self_enroll(): def get_request(common_name): - if not re.match(RE_HOSTNAME, common_name): + if not re.match(const.RE_HOSTNAME, common_name): raise ValueError("Invalid common name %s" % repr(common_name)) path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") try: @@ -97,7 +95,7 @@ def get_request(common_name): raise errors.RequestDoesNotExist("Certificate signing request file %s does not exist" % path) def get_signed(common_name): - if not re.match(RE_HOSTNAME, common_name): + if not re.match(const.RE_HOSTNAME, common_name): raise ValueError("Invalid common name %s" % repr(common_name)) path = os.path.join(config.SIGNED_DIR, common_name + ".pem") with open(path, "rb") as fh: @@ -160,7 +158,7 @@ def store_request(buf, overwrite=False, address="", user=""): common_name = csr["certification_request_info"]["subject"].native["common_name"] - if not re.match(RE_HOSTNAME, common_name): + if not re.match(const.RE_HOSTNAME, common_name): raise ValueError("Invalid common name") request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") @@ -298,7 +296,7 @@ def export_crl(pem=True): def delete_request(common_name): # Validate CN - if not re.match(RE_HOSTNAME, common_name): + if not re.match(const.RE_HOSTNAME, common_name): raise ValueError("Invalid common name") path, buf, csr, submitted = get_request(common_name) diff --git a/certidude/cli.py b/certidude/cli.py index 347e291..f2f6bc6 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -5,11 +5,13 @@ import hashlib import logging import os import random +import re import signal import string import subprocess import sys from asn1crypto import pem, x509 +from asn1crypto.csr import CertificationRequest from base64 import b64encode from certbuilder import CertificateBuilder, pem_armor_certificate from certidude import const @@ -177,17 +179,6 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): with open(pid_path, "w") as fh: fh.write("%d\n" % os.getpid()) - try: - scheme = "http" if clients.getboolean(authority_name, "insecure") else "https" - except NoOptionError: - scheme = "https" - - # Expand ca.example.com - authority_url = "%s://%s/api/certificate/" % (scheme, authority_name) - request_url = "%s://%s/api/request/" % (scheme, authority_name) - revoked_url = "%s://%s/api/revoked/" % (scheme, authority_name) - - try: authority_path = clients.get(authority_name, "authority path") except NoOptionError: @@ -201,6 +192,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): else: if not os.path.exists(os.path.dirname(authority_path)): os.makedirs(os.path.dirname(authority_path)) + authority_url = "http://%s/api/certificate/" % authority_name click.echo("Attempting to fetch authority certificate from %s" % authority_url) try: r = requests.get(authority_url, @@ -260,15 +252,21 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): revocations_path = None else: # Fetch certificate revocation list + revoked_url = "http://%s/api/revoked/" % authority_name click.echo("Fetching CRL from %s to %s" % (revoked_url, revocations_path)) r = requests.get(revoked_url, headers={'accept': 'application/x-pem-file'}) - assert r.status_code == 200, "Failed to fetch CRL from %s, got %s" % (revoked_url, r.text) - #revocations = crl.CertificateList.load(pem.unarmor(r.content)) - # TODO: check signature, parse reasons, remove keys if revoked - revocations_partial = revocations_path + ".part" - with open(revocations_partial, 'wb') as f: - f.write(r.content) + if r.status_code == 200: + revocations = crl.CertificateList.load(pem.unarmor(r.content)) + # TODO: check signature, parse reasons, remove keys if revoked + revocations_partial = revocations_path + ".part" + with open(revocations_partial, 'wb') as f: + f.write(r.content) + elif r.status_code == 404: + click.echo("CRL disabled, server said 404") + else: + click.echo("Failed to fetch CRL from %s, got %s" % (revoked_url, r.text)) + try: common_name = clients.get(authority_name, "common name") @@ -279,6 +277,13 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): # If deriving common name from *current* hostname is preferred if common_name == "$HOSTNAME": common_name = const.HOSTNAME + elif common_name == "$FQDN": + common_name = const.FQDN + elif "$" in common_name: + raise ValueError("Invalid variable '%s' supplied, only $HOSTNAME and $FQDN allowed" % common_name) + if not re.match(const.RE_HOSTNAME, common_name): + raise ValueError("Invalid common name '%s' supplied" % common_name) + ################################ ### Generate keypair and CSR ### @@ -291,6 +296,14 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): key_path = "/var/lib/certidude/%s/client_key.pem" % authority_name request_path = "/var/lib/certidude/%s/client_csr.pem" % authority_name + if os.path.exists(request_path): + with open(request_path, "rb") as fh: + header, _, der_bytes = pem.unarmor(fh.read()) + csr = CertificationRequest.load(der_bytes) + if csr["certification_request_info"]["subject"].native["common_name"] != common_name: + click.echo("Stored request's common name differs from currently requested one, deleting old request") + os.remove(request_path) + if not os.path.exists(request_path): key_partial = key_path + ".part" request_partial = request_path + ".part" @@ -312,7 +325,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): selinux_fixup(request_partial) os.rename(key_partial, key_path) os.rename(request_partial, request_path) - # else: check that CSR has correct CN + ############################################## ### Submit CSR and save signed certificate ### @@ -323,14 +336,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): except NoOptionError: certificate_path = "/var/lib/certidude/%s/client_cert.pem" % authority_name - headers={ - "Content-Type": "application/pkcs10", - "Accept": "application/x-x509-user-cert,application/x-pem-file" - } - - try: - # Attach renewal signature if renewal requested and cert exists renewal_overlap = clients.getint(authority_name, "renewal overlap") except NoOptionError: # Renewal not specified in config renewal_overlap = None @@ -343,15 +349,8 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): if renewal_overlap and NOW > expires - timedelta(days=renewal_overlap): click.echo("Certificate will expire %s, will attempt to renew" % expires) renew = True - headers["X-Renewal-Signature"] = b64encode( - asymmetric.rsa_pss_sign( - asymmetric.load_private_key(kh.read()), - cert_buf + rh.read(), - "sha384")) except EnvironmentError: # Certificate missing, can't renew pass - else: - click.echo("Attached renewal signature %s" % headers["X-Renewal-Signature"]) if not os.path.exists(certificate_path) or renew: # Set up URL-s @@ -359,31 +358,48 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): request_params.add("autosign=true") if not no_wait: request_params.add("wait=forever") + + kwargs = { + "data": open(request_path), + "verify": authority_path, + "headers": { + "Content-Type": "application/pkcs10", + "Accept": "application/x-x509-user-cert,application/x-pem-file" + } + } + + if renew: # Do mutually authenticated TLS handshake + request_url = "https://%s:8443/api/request/" % authority_name + kwargs["cert"] = certificate_path, key_path + else: + # If machine is joined to domain attempt to present machine credentials for authentication + if kerberos: + try: + from requests_kerberos import HTTPKerberosAuth, OPTIONAL + except ImportError: + click.echo("Kerberos bindings not available, please install requests-kerberos") + else: + os.environ["KRB5CCNAME"]="/tmp/ca.ticket" + + # Mac OS X has keytab with lowercase hostname + cmd = "kinit -S HTTP/%s -k %s$" % (authority_name, const.HOSTNAME.lower()) + click.echo("Executing: %s" % cmd) + if os.system(cmd): + # Fedora /w SSSD has keytab with uppercase hostname + cmd = "kinit -S HTTP/%s -k %s$" % (authority_name, const.HOSTNAME.upper()) + if os.system(cmd): + # Failed, probably /etc/krb5.keytab contains spaghetti + raise ValueError("Failed to initialize Kerberos service ticket using machine keytab") + assert os.path.exists("/tmp/ca.ticket"), "Ticket not created!" + click.echo("Initialized Kerberos service ticket using machine keytab") + kwargs["auth"] = HTTPKerberosAuth(mutual_authentication=OPTIONAL, force_preemptive=True) + else: + click.echo("Not using machine keytab") + request_url = "https://%s/api/request/" % authority_name + if request_params: request_url = request_url + "?" + "&".join(request_params) - - # If machine is joined to domain attempt to present machine credentials for authentication - if kerberos: - os.environ["KRB5CCNAME"]="/tmp/ca.ticket" - - # Mac OS X has keytab with lowercase hostname - cmd = "kinit -S HTTP/%s -k %s$" % (authority_name, const.HOSTNAME.lower()) - click.echo("Executing: %s" % cmd) - if os.system(cmd): - # Fedora /w SSSD has keytab with uppercase hostname - cmd = "kinit -S HTTP/%s -k %s$" % (authority_name, const.HOSTNAME.upper()) - if os.system(cmd): - # Failed, probably /etc/krb5.keytab contains spaghetti - raise ValueError("Failed to initialize TGT using machine keytab") - assert os.path.exists("/tmp/ca.ticket"), "Ticket not created!" - click.echo("Initialized Kerberos TGT using machine keytab") - from requests_kerberos import HTTPKerberosAuth, OPTIONAL - auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL, force_preemptive=True) - else: - click.echo("Not using machine keytab") - auth = None - - submission = requests.post(request_url, auth=auth, data=open(request_path), headers=headers) + submission = requests.post(request_url, **kwargs) # Destroy service ticket if os.path.exists("/tmp/ca.ticket"): diff --git a/certidude/config.py b/certidude/config.py index a07e17e..9a1d7c2 100644 --- a/certidude/config.py +++ b/certidude/config.py @@ -35,6 +35,8 @@ OCSP_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "ocsp subnets").split(" ") if j]) CRL_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "crl subnets").split(" ") if j]) +RENEWAL_SUBNETS = set([ipaddress.ip_network(j) for j in + cp.get("authorization", "renewal subnets").split(" ") if j]) AUTHORITY_DIR = "/var/lib/certidude" AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path") @@ -64,7 +66,6 @@ REQUEST_SUBMISSION_ALLOWED = cp.getboolean("authority", "request submission allo AUTHORITY_CERTIFICATE_URL = cp.get("signature", "authority certificate url") AUTHORITY_CRL_URL = cp.get("signature", "revoked url") AUTHORITY_OCSP_URL = cp.get("signature", "responder url") -CERTIFICATE_RENEWAL_ALLOWED = cp.getboolean("signature", "renewal allowed") REVOCATION_LIST_LIFETIME = cp.getint("signature", "revocation list lifetime") diff --git a/certidude/const.py b/certidude/const.py index 1480c8d..c7da005 100644 --- a/certidude/const.py +++ b/certidude/const.py @@ -6,6 +6,7 @@ import sys KEY_SIZE = 1024 if os.getenv("TRAVIS") else 4096 CURVE_NAME = "secp384r1" +RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])(@(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]))?$" RUN_DIR = "/run/certidude" CONFIG_DIR = "/etc/certidude" diff --git a/certidude/templates/server/nginx.conf b/certidude/templates/server/nginx.conf index 059a045..d0f7c7f 100644 --- a/certidude/templates/server/nginx.conf +++ b/certidude/templates/server/nginx.conf @@ -119,14 +119,21 @@ server { nchan_channel_id $1; nchan_subscriber eventsource; } + + # Long poll for CSR submission + location ~ "^/lp/sub/(.*)" { + nchan_channel_id $1; + nchan_subscriber longpoll; + } {% endif %} } server { # Section for certificate authenticated HTTPS clients, - # for submitting information to CA eg. leases - # and for delivering scripts to clients + # for submitting information to CA eg. leases, + # renewing certificates and + # for delivering scripts to clients server_name {{ common_name }}; listen 8443 ssl http2; diff --git a/certidude/templates/server/server.conf b/certidude/templates/server/server.conf index 9ace2cb..8fdf8ca 100644 --- a/certidude/templates/server/server.conf +++ b/certidude/templates/server/server.conf @@ -70,6 +70,12 @@ ocsp subnets = ;crl subnets = crl subnets = 0.0.0.0/0 +# If certificate renewal is attempted from whitelisted subnets, clients can +# request a certificate for the same public key with extended lifetime. +# To disable set to none +renewal subnets = +;renewal subnets = 0.0.0.0/0 + [logging] # Disable logging ;backend = @@ -107,11 +113,6 @@ revoked url = {{ revoked_url }} responder url = ;responder url = {{ responder_url }} -# If certificate renewal is allowed clients can request a certificate -# for the same public key with extended lifetime -renewal allowed = false -;renewal allowed = true - [push] # This should occasionally be regenerated