From 94e5f72566ccf661b9e7c5f9b45ddf789b41b78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lauri=20V=C3=B5sandi?= Date: Mon, 16 Apr 2018 12:13:31 +0000 Subject: [PATCH] Migrate signature profiles to separate config file --- certidude/api/__init__.py | 3 +- certidude/api/request.py | 34 ++++++++-------- certidude/authority.py | 40 +++++++------------ certidude/cli.py | 23 ++++++++--- certidude/config.py | 17 +++++++- certidude/const.py | 5 ++- certidude/profile.py | 52 +++++++++++++++++++++++++ certidude/static/views/request.html | 9 ++--- certidude/templates/server/profile.conf | 52 +++++++++++++++++++++++++ certidude/templates/server/server.conf | 8 ---- 10 files changed, 179 insertions(+), 64 deletions(-) create mode 100644 certidude/profile.py create mode 100644 certidude/templates/server/profile.conf diff --git a/certidude/api/__init__.py b/certidude/api/__init__.py index 01d1031..ab8a705 100644 --- a/certidude/api/__init__.py +++ b/certidude/api/__init__.py @@ -163,7 +163,8 @@ class SessionResource(AuthorityHandler): admin_subnets=config.ADMIN_SUBNETS or None, signature = dict( revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME, - profiles = [dict(name=k, server=v[0]=="server", lifetime=v[1], organizational_unit=v[2], title=v[3]) for k,v in config.PROFILES.items()] + profiles = sorted([p.serialize() for p in config.PROFILES.values()], key=lambda p:p.get("slug")), + ) ) if req.context.get("user").is_admin() else None, features=dict( diff --git a/certidude/api/request.py b/certidude/api/request.py index a6db1ce..0f3197f 100644 --- a/certidude/api/request.py +++ b/certidude/api/request.py @@ -10,6 +10,7 @@ from base64 import b64decode from certidude import config, push, errors from certidude.auth import login_required, login_optional, authorize_admin from certidude.decorators import csrf_protection, MyEncoder +from certidude.profile import SignatureProfile from datetime import datetime from oscrypto import asymmetric from oscrypto.errors import SignatureError @@ -71,7 +72,8 @@ class RequestListResource(AuthorityHandler): # Automatic enroll with Kerberos machine cerdentials resp.set_header("Content-Type", "application/x-pem-file") - cert, resp.body = self.authority._sign(csr, body, overwrite=True) + cert, resp.body = self.authority._sign(csr, body, + profile=config.PROFILES["rw"], overwrite=True) logger.info("Automatically enrolled Kerberos authenticated machine %s from %s", machine, req.context.get("remote_addr")) return @@ -89,28 +91,26 @@ class RequestListResource(AuthorityHandler): cert_pk = cert["tbs_certificate"]["subject_public_key_info"].native csr_pk = csr["certification_request_info"]["subject_pk_info"].native - try: + # Same public key + if cert_pk == csr_pk: 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 + # Used mutually authenticated TLS handshake, assume renewal + if buf: + header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii")) + handshake_cert = x509.Certificate.load(der_bytes) 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) + _, resp.body = self.authority._sign(csr, body, overwrite=True, + profile=SignatureProfile.from_cert(cert)) logger.info("Renewing certificate for %s as %s is whitelisted", common_name, req.context.get("remote_addr")) return - # 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 + # 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 """ @@ -123,7 +123,7 @@ class RequestListResource(AuthorityHandler): if req.context.get("remote_addr") in subnet: try: resp.set_header("Content-Type", "application/x-pem-file") - _, resp.body = self.authority._sign(csr, body) + _, resp.body = self.authority._sign(csr, body, profile=config.PROFILES["rw"]) logger.info("Autosigned %s as %s is whitelisted", common_name, req.context.get("remote_addr")) return except EnvironmentError: @@ -222,7 +222,7 @@ class RequestDetailResource(AuthorityHandler): """ try: cert, buf = self.authority.sign(cn, - profile=req.get_param("profile", default="default"), + profile=config.PROFILES[req.get_param("profile", default="rw")], overwrite=True, signer=req.context.get("user").name) # Mailing and long poll publishing implemented in the function above diff --git a/certidude/authority.py b/certidude/authority.py index 19f93f3..363406c 100644 --- a/certidude/authority.py +++ b/certidude/authority.py @@ -71,7 +71,7 @@ def self_enroll(): from certidude import authority from certidude.common import drop_privileges drop_privileges() - authority.sign(common_name, skip_push=True, overwrite=True, profile="srv") + authority.sign(common_name, skip_push=True, overwrite=True, profile=config.PROFILES["srv"]) sys.exit(0) else: os.waitpid(pid, 0) @@ -82,7 +82,7 @@ def self_enroll(): def get_request(common_name): - if not re.match(const.RE_HOSTNAME, common_name): + if not re.match(const.RE_COMMON_NAME, common_name): raise ValueError("Invalid common name %s" % repr(common_name)) path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") try: @@ -95,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(const.RE_HOSTNAME, common_name): + if not re.match(const.RE_COMMON_NAME, 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: @@ -158,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(const.RE_HOSTNAME, common_name): + if not re.match(const.RE_COMMON_NAME, common_name): raise ValueError("Invalid common name") request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") @@ -296,7 +296,7 @@ def export_crl(pem=True): def delete_request(common_name): # Validate CN - if not re.match(const.RE_HOSTNAME, common_name): + if not re.match(const.RE_COMMON_NAME, common_name): raise ValueError("Invalid common name") path, buf, csr, submitted = get_request(common_name) @@ -310,7 +310,7 @@ def delete_request(common_name): config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest(), headers={"User-Agent": "Certidude API"}) -def sign(common_name, skip_notify=False, skip_push=False, overwrite=False, profile="default", signer=None): +def sign(common_name, profile, skip_notify=False, skip_push=False, overwrite=False, signer=None): """ Sign certificate signing request by it's common name """ @@ -323,16 +323,13 @@ def sign(common_name, skip_notify=False, skip_push=False, overwrite=False, profi # Sign with function below - cert, buf = _sign(csr, csr_buf, skip_notify, skip_push, overwrite, profile, signer) + cert, buf = _sign(csr, csr_buf, profile, skip_notify, skip_push, overwrite, signer) os.unlink(req_path) return cert, buf -def _sign(csr, buf, skip_notify=False, skip_push=False, overwrite=False, profile="default", signer=None): +def _sign(csr, buf, profile, skip_notify=False, skip_push=False, overwrite=False, signer=None): # TODO: CRLDistributionPoints, OCSP URL, Certificate URL - if profile not in config.PROFILES: - raise ValueError("Invalid profile supplied '%s'" % profile) - assert buf.startswith(b"-----BEGIN ") assert isinstance(csr, CertificationRequest) csr_pubkey = asymmetric.load_public_key(csr["certification_request_info"]["subject_pk_info"]) @@ -370,10 +367,9 @@ def _sign(csr, buf, skip_notify=False, skip_push=False, overwrite=False, profile else: raise FileExistsError("Will not overwrite existing certificate") - # Sign via signer process dn = {u'common_name': common_name } - profile_server_flags, lifetime, dn["organizational_unit_name"], _ = config.PROFILES[profile] - lifetime = int(lifetime) + if profile.ou: + dn["organizational_unit_name"] = profile.ou builder = CertificateBuilder(dn, csr_pubkey) builder.serial_number = random.randint( @@ -382,18 +378,12 @@ def _sign(csr, buf, skip_notify=False, skip_push=False, overwrite=False, profile now = datetime.utcnow() builder.begin_date = now - timedelta(minutes=5) - builder.end_date = now + timedelta(days=lifetime) + builder.end_date = now + timedelta(days=profile.lifetime) builder.issuer = certificate - builder.ca = False - builder.key_usage = set(["digital_signature", "key_encipherment"]) - - # If we have FQDN and profile suggests server flags, enable them - if server_flags(common_name) and profile_server_flags: - builder.subject_alt_domains = [common_name] # OpenVPN uses CN while StrongSwan uses SAN to match hostname of the server - builder.extended_key_usage = set(["server_auth", "1.3.6.1.5.5.8.2.2", "client_auth"]) - else: - builder.subject_alt_domains = [common_name] # iOS demands SAN also for clients - builder.extended_key_usage = set(["client_auth"]) + builder.ca = profile.ca + builder.key_usage = profile.key_usage + builder.extended_key_usage = profile.extended_key_usage + builder.subject_alt_domains = [common_name] end_entity_cert = builder.build(private_key) end_entity_cert_buf = asymmetric.dump_certificate(end_entity_cert) diff --git a/certidude/cli.py b/certidude/cli.py index f2f6bc6..87c023f 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -281,8 +281,8 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): 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) + if not re.match(const.RE_COMMON_NAME, common_name): + raise ValueError("Supplied common name %s doesn't match the expression %s" % (common_name, const.RE_COMMON_NAME)) ################################ @@ -338,7 +338,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): try: renewal_overlap = clients.getint(authority_name, "renewal overlap") - except NoOptionError: # Renewal not specified in config + except NoOptionError: # Renewal not configured renewal_overlap = None try: @@ -1169,6 +1169,14 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, fh.write(env.get_template("server/builder.conf").render(vars())) click.echo("File %s created" % const.BUILDER_CONFIG_PATH) + # Create image builder config + if os.path.exists(const.PROFILE_CONFIG_PATH): + click.echo("Signature profile config %s already exists, remove to regenerate" % const.PROFILE_CONFIG_PATH) + else: + with open(const.PROFILE_CONFIG_PATH, "w") as fh: + fh.write(env.get_template("server/profile.conf").render(vars())) + click.echo("File %s created" % const.PROFILE_CONFIG_PATH) + # Create directory with 755 permissions os.umask(0o022) if not os.path.exists(directory): @@ -1347,12 +1355,12 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign @click.command("sign", help="Sign certificate") @click.argument("common_name") -@click.option("--profile", "-p", default="default", help="Profile") +@click.option("--profile", "-p", default="rw", help="Profile") @click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN") def certidude_sign(common_name, overwrite, profile): from certidude import authority drop_privileges() - cert = authority.sign(common_name, overwrite=overwrite, profile=profile) + cert = authority.sign(common_name, overwrite=overwrite, profile=config.PROFILES[profile]) @click.command("revoke", help="Revoke certificate") @@ -1397,6 +1405,11 @@ def certidude_serve(port, listen, fork): from certidude import config + click.echo("Loading signature profiles:") + for profile in config.PROFILES.values(): + click.echo("- %s" % profile) + click.echo() + # Rebuild reverse mapping for cn, path, buf, cert, signed, expires in authority.list_signed(): by_serial = os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial_number) diff --git a/certidude/config.py b/certidude/config.py index 9a1d7c2..25a113e 100644 --- a/certidude/config.py +++ b/certidude/config.py @@ -2,7 +2,9 @@ import configparser import ipaddress import os from certidude import const +from certidude.profile import SignatureProfile from collections import OrderedDict +from datetime import timedelta # Options that are parsed from config file are fetched here @@ -96,11 +98,22 @@ TOKEN_SECRET = cp.get("token", "secret").encode("ascii") # The API call for looking up scripts uses following directory as root SCRIPT_DIR = cp.get("script", "path") -PROFILES = OrderedDict([[i, [j.strip() for j in cp.get("profile", i).split(",")]] for i in cp.options("profile")]) +from configparser import ConfigParser +profile_config = ConfigParser() +profile_config.readfp(open(const.PROFILE_CONFIG_PATH)) + +PROFILES = dict([(key, SignatureProfile(key, + profile_config.get(key, "title"), + profile_config.get(key, "ou"), + profile_config.getboolean(key, "ca"), + profile_config.getint(key, "lifetime"), + profile_config.get(key, "key usage"), + profile_config.get(key, "extended key usage"), + profile_config.get(key, "common name"), + )) for key in profile_config.sections()]) cp2 = configparser.RawConfigParser() cp2.readfp(open(const.BUILDER_CONFIG_PATH, "r")) IMAGE_BUILDER_PROFILES = [(j, cp2.get(j, "title"), cp2.get(j, "rename")) for j in cp2.sections()] - TOKEN_OVERWRITE_PERMITTED=True diff --git a/certidude/const.py b/certidude/const.py index c7da005..1ee95df 100644 --- a/certidude/const.py +++ b/certidude/const.py @@ -6,12 +6,15 @@ 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]))?$" +RE_FQDN = "^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)+([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])?$" +RE_HOSTNAME = "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$" +RE_COMMON_NAME = "^[A-Za-z0-9\-\.\@]+$" RUN_DIR = "/run/certidude" CONFIG_DIR = "/etc/certidude" SERVER_CONFIG_PATH = os.path.join(CONFIG_DIR, "server.conf") BUILDER_CONFIG_PATH = os.path.join(CONFIG_DIR, "builder.conf") +PROFILE_CONFIG_PATH = os.path.join(CONFIG_DIR, "profile.conf") CLIENT_CONFIG_PATH = os.path.join(CONFIG_DIR, "client.conf") SERVICES_CONFIG_PATH = os.path.join(CONFIG_DIR, "services.conf") SERVER_PID_PATH = os.path.join(RUN_DIR, "server.pid") diff --git a/certidude/profile.py b/certidude/profile.py new file mode 100644 index 0000000..8538123 --- /dev/null +++ b/certidude/profile.py @@ -0,0 +1,52 @@ + +import click +from datetime import timedelta +from certidude import const + +class SignatureProfile(object): + def __init__(self, slug, title, ou, ca, lifetime, key_usage, extended_key_usage, common_name): + self.slug = slug + self.title = title + self.ou = ou or None + self.ca = ca + self.lifetime = lifetime + self.key_usage = set(key_usage.split(" ")) if key_usage else set() + self.extended_key_usage = set(extended_key_usage.split(" ")) if extended_key_usage else set() + if common_name.startswith("^"): + self.common_name = common_name + elif common_name == "RE_HOSTNAME": + self.common_name = const.RE_HOSTNAME + elif common_name == "RE_FQDN": + self.common_name = const.RE_FQDN + elif common_name == "RE_COMMON_NAME": + self.common_name = const.RE_COMMON_NAME + else: + raise ValueError("Invalid common name constraint %s" % common_name) + + @classmethod + def from_cert(self, cert): + """ + Derive signature profile from an already signed certificate, eg for renewal + """ + lifetime = cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None) - \ + cert["tbs_certificate"]["validity"]["not_before"].native.replace(tzinfo=None) + return SignatureProfile( + None, "Renewal", cert.subject.native.get("organizational_unit_name"), + cert.ca, lifetime.days, + " ".join(cert.key_usage_value.native), + " ".join(cert.extended_key_usage_value.native), "^") + + + def serialize(self): + return dict([(key, getattr(self,key)) for key in ( + "slug", "title", "ou", "ca", "lifetime", "key_usage", "extended_key_usage", "common_name")]) + + def __repr__(self): + bits = [] + if self.lifetime >= 365: + bits.append("%d years" % (self.lifetime / 365)) + if self.lifetime % 365: + bits.append("%d days" % (self.lifetime % 365)) + return "%s (title=%s, ca=%s, ou=%s, lifetime=%s, key_usage=%s, extended_key_usage=%s, common_name=%s)" % ( + self.slug, self.title, self.ca, self.ou, " ".join(bits), self.key_usage, self.extended_key_usage, self.common_name) + diff --git a/certidude/static/views/request.html b/certidude/static/views/request.html index 83ec143..67732fd 100644 --- a/certidude/static/views/request.html +++ b/certidude/static/views/request.html @@ -30,11 +30,10 @@ diff --git a/certidude/templates/server/profile.conf b/certidude/templates/server/profile.conf new file mode 100644 index 0000000..059835a --- /dev/null +++ b/certidude/templates/server/profile.conf @@ -0,0 +1,52 @@ +[DEFAULT] +ou = +lifetime = 120 +ca = false +common name = RE_COMMON_NAME +key usage = digital_signature key_encipherment +extended key usage = + +[ca] +title = Certificate Authority +common name = ^ca +ca = true +key usage = key_cert_sign crl_sign +extended key usage = +lifetime = 1095 + +[rw] +title = Roadwarrior +ou = Roadwarrior +common name = RE_HOSTNAME +extended key usage = client_auth + +[srv] +title = Server +;ou = Server +common name = RE_FQDN +lifetime = 365 +extended key usage = server_auth + +[gw] +title = Gateway +ou = Gateway +common name = RE_FQDN +renewable = true +lifetime = 30 +extended key usage = server_auth 1.3.6.1.5.5.8.2.2 client_auth + +[ap] +title = Access Point +ou = Access Point +common name = RE_HOSTNAME +lifetime = 1825 +extended key usage = client_auth + +[mfp] +title = Printers +ou = Printers +common name = ^mfp\- +lifetime = 30 +extended key usage = client_auth + + diff --git a/certidude/templates/server/server.conf b/certidude/templates/server/server.conf index 8fdf8ca..edf354f 100644 --- a/certidude/templates/server/server.conf +++ b/certidude/templates/server/server.conf @@ -194,14 +194,6 @@ lifetime = 30 # Secret for generating and validating tokens, regenerate occasionally secret = {{ token_secret }} -[profile] -# name, flags, lifetime, organizational unit, title -default = client, 120, Roadwarrior, Roadwarrior -gw = server, 30, Gateway, Gateway -srv = server, 365, Server, -ap = client, 1825, Access Point, Access Point -mfp = client, 30, MFP, Printers - [script] # Path to the folder with scripts that can be served to the clients, set none to disable scripting path = {{ script_dir }}