From b4d006227aa001eb62f98060a2d21d04666204b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lauri=20V=C3=B5sandi?= Date: Sun, 18 Sep 2016 00:00:14 +0300 Subject: [PATCH] Refactor codebase * Replace PyOpenSSL with cryptography.io * Rename constants to const * Drop support for uwsgi * Use systemd to launch certidude server * Signer automatically spawned as part of server * Update requirements.txt * Clean up certidude client configuration handling * Add automatic enroll with Kerberos machine cerdentials --- MANIFEST.in | 2 + README.rst | 27 +- certidude/api/__init__.py | 38 +- certidude/api/bundle.py | 1 + certidude/api/cfg.py | 4 +- certidude/api/request.py | 21 +- certidude/api/revoked.py | 6 +- certidude/auth.py | 53 +- certidude/authority.py | 179 ++- certidude/cli.py | 1369 +++++++++-------- certidude/config.py | 20 +- certidude/const.py | 24 + certidude/constants.py | 13 - certidude/decorators.py | 12 +- certidude/helpers.py | 127 +- certidude/mailer.py | 2 +- certidude/push.py | 11 +- certidude/relational.py | 2 +- certidude/signer.py | 119 +- .../{certidude.conf => certidude-server.conf} | 5 +- certidude/templates/ldap-ticket-renewal.sh | 5 + certidude/templates/nginx-https-site.conf | 6 +- certidude/templates/nginx.conf | 13 +- .../templates/openvpn-client-to-site.ovpn | 14 - .../templates/openvpn-site-to-client.ovpn | 22 - .../templates/strongswan-client-to-site.conf | 27 - .../templates/strongswan-site-to-client.conf | 2 +- certidude/templates/systemd.service | 15 + certidude/templates/uwsgi.ini | 19 - certidude/user.py | 12 +- certidude/wrappers.py | 4 +- certidude/wsgi.py | 12 - misc/certidude | 2 +- requirements.txt | 44 +- setup.py | 6 +- 35 files changed, 1181 insertions(+), 1057 deletions(-) create mode 100644 certidude/const.py delete mode 100644 certidude/constants.py rename certidude/templates/{certidude.conf => certidude-server.conf} (97%) create mode 100644 certidude/templates/ldap-ticket-renewal.sh delete mode 100644 certidude/templates/openvpn-client-to-site.ovpn delete mode 100644 certidude/templates/openvpn-site-to-client.ovpn delete mode 100644 certidude/templates/strongswan-client-to-site.conf create mode 100644 certidude/templates/systemd.service delete mode 100644 certidude/templates/uwsgi.ini delete mode 100644 certidude/wsgi.py diff --git a/MANIFEST.in b/MANIFEST.in index 08f0036..764e736 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,6 @@ include README.rst +include certidude/templates/*.sh +include certidude/templates/*.service include certidude/templates/*.ovpn include certidude/templates/*.conf include certidude/templates/*.ini diff --git a/README.rst b/README.rst index 7274a26..d56dc1d 100644 --- a/README.rst +++ b/README.rst @@ -79,7 +79,8 @@ To install Certidude: python-pysqlite2 python-mysql.connector python-ldap \ build-essential libffi-dev libssl-dev libkrb5-dev \ ldap-utils krb5-user \ - libsasl2-modules-gssapi-mit + libsasl2-modules-gssapi-mit \ + libsasl2-dev libldap2-dev pip install certidude @@ -103,17 +104,18 @@ If necessary tweak machine's fully qualified hostname in ``/etc/hosts``: 127.0.0.1 localhost 127.0.1.1 ca.example.com ca -Then proceed to install `nchan `_ and ``uwsgi``: +Then proceed to install `nchan `_: .. code:: bash - wget https://nchan.slact.net/download/nginx-common.deb https://nchan.slact.net/download/nginx-extras.deb + wget https://nchan.slact.net/download/nginx-common.deb \ + https://nchan.slact.net/download/nginx-extras.deb dpkg -i nginx-common.deb nginx-extras.deb - apt-get install nginx uwsgi uwsgi-plugin-python + apt-get -f install Certidude can set up certificate authority relatively easily. Following will set up certificate authority in ``/var/lib/certidude/hostname.domain.tld``, -configure uWSGI in ``/etc/uwsgi/apps-available/certidude.ini``, +configure gunicorn service for your platform, nginx in ``/etc/nginx/sites-available/certidude.conf``, cronjobs in ``/etc/cron.hourly/certidude`` and much more: @@ -170,7 +172,8 @@ Install dependencies: apt-get install samba-common-bin krb5-user ldap-utils -Reset Samba client configuration in ``/etc/samba/smb.conf``: +Reset Samba client configuration in ``/etc/samba/smb.conf``, adjust +workgroup and realm accordingly: .. code:: ini @@ -190,6 +193,13 @@ Reset Kerberos configuration in ``/etc/krb5.conf``: dns_lookup_realm = true dns_lookup_kdc = true +Reset LDAP configuration in /etc/ldap/ldap.conf: + +.. code:: bash + + BASE dc=example,dc=com + URI ldap://dc1.example.com + Initialize Kerberos credentials: .. code:: bash @@ -230,6 +240,11 @@ Adjust admin filter according to your setup. Also make sure there is cron.hourly job for creating GSSAPI credential cache - that's necessary for querying LDAP using Certidude machine's credentials. +Common pitfalls: + +* Following error message may mean that the IP address of the web server does not match the IP address used to join + the CA machine to domain, eg when you're running CA behind SSL terminating web server: + Bad credentials: Unspecified GSS failure. Minor code may provide more information (851968) Automating certificate setup ---------------------------- diff --git a/certidude/api/__init__.py b/certidude/api/__init__.py index 1d82d64..531aa27 100644 --- a/certidude/api/__init__.py +++ b/certidude/api/__init__.py @@ -12,7 +12,7 @@ from certidude.auth import login_required, authorize_admin from certidude.user import User from certidude.decorators import serialize, event_source, csrf_protection from certidude.wrappers import Request, Certificate -from certidude import constants, config +from certidude import const, config logger = logging.getLogger("api") @@ -35,7 +35,7 @@ class CertificateAuthorityResource(object): resp.stream = open(config.AUTHORITY_CERTIFICATE_PATH, "rb") resp.append_header("Content-Type", "application/x-x509-ca-cert") resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % - constants.HOSTNAME.encode("ascii")) + const.HOSTNAME.encode("ascii")) class SessionResource(object): @@ -112,7 +112,7 @@ class NormalizeMiddleware(object): assert not req.get_param("unicode") or req.get_param("unicode") == u"✓", "Unicode sanity check failed" req.context["remote_addr"] = ipaddress.ip_address(req.env["REMOTE_ADDR"].decode("utf-8")) - def process_response(self, req, resp, resource): + def process_response(self, req, resp, resource=None): # wtf falcon?! if isinstance(resp.location, unicode): resp.location = resp.location.encode("ascii") @@ -125,7 +125,6 @@ def certidude_app(): from .request import RequestListResource, RequestDetailResource from .lease import LeaseResource from .whois import WhoisResource - from .log import LogResource from .tag import TagResource, TagDetailResource from .cfg import ConfigResource, ScriptResource @@ -149,19 +148,6 @@ def certidude_app(): if config.USER_CERTIFICATE_ENROLLMENT: app.add_route("/api/bundle/", BundleResource()) - log_handlers = [] - if config.LOGGING_BACKEND == "sql": - from certidude.mysqllog import LogHandler - uri = config.cp.get("logging", "database") - log_handlers.append(LogHandler(uri)) - app.add_route("/api/log/", LogResource(uri)) - elif config.LOGGING_BACKEND == "syslog": - from logging.handlers import SyslogHandler - log_handlers.append(SysLogHandler()) - # Browsing syslog via HTTP is obviously not possible out of the box - elif config.LOGGING_BACKEND: - raise ValueError("Invalid logging.backend = %s" % config.LOGGING_BACKEND) - if config.TAGGING_BACKEND == "sql": uri = config.cp.get("tagging", "database") app.add_route("/api/tag/", TagResource(uri)) @@ -171,23 +157,5 @@ def certidude_app(): elif config.TAGGING_BACKEND: raise ValueError("Invalid tagging.backend = %s" % config.TAGGING_BACKEND) - if config.PUSH_PUBLISH: - from certidude.push import PushLogHandler - log_handlers.append(PushLogHandler()) - - for facility in "api", "cli": - logger = logging.getLogger(facility) - logger.setLevel(logging.DEBUG) - for handler in log_handlers: - logger.addHandler(handler) - - logging.getLogger("cli").debug("Started Certidude at %s", constants.FQDN) - - import atexit - - def exit_handler(): - logging.getLogger("cli").debug("Shutting down Certidude") - - atexit.register(exit_handler) return app diff --git a/certidude/api/bundle.py b/certidude/api/bundle.py index 6a1a637..6559d3b 100644 --- a/certidude/api/bundle.py +++ b/certidude/api/bundle.py @@ -1,4 +1,5 @@ + import logging import hashlib from certidude import config, authority diff --git a/certidude/api/cfg.py b/certidude/api/cfg.py index 5b25ad1..7db0e01 100644 --- a/certidude/api/cfg.py +++ b/certidude/api/cfg.py @@ -36,8 +36,6 @@ join tag on device_tag.tag_id = tag.id join device on device_tag.device_id = device.id -where - device.cn = %s """ @@ -63,7 +61,7 @@ class ConfigResource(RelationalMixin): @login_required @authorize_admin def on_get(self, req, resp): - return self.iterfetch(SQL_SELECT_RULES) + return self.iterfetch(SQL_SELECT_TAGS) class ScriptResource(RelationalMixin): diff --git a/certidude/api/request.py b/certidude/api/request.py index e0b5058..a84b85e 100644 --- a/certidude/api/request.py +++ b/certidude/api/request.py @@ -10,6 +10,9 @@ from certidude.decorators import serialize, csrf_protection from certidude.wrappers import Request, Certificate from certidude.firewall import whitelist_subnets, whitelist_content_types +from cryptography import x509 +from cryptography.hazmat.backends import default_backend + logger = logging.getLogger("api") class RequestListResource(object): @@ -29,6 +32,7 @@ class RequestListResource(object): """ body = req.stream.read(req.content_length) + csr = Request(body) if not csr.common_name: @@ -38,6 +42,19 @@ class RequestListResource(object): "Bad request", "No common name specified!") + machine = req.context.get("machine") + if machine: + if csr.common_name != machine: + raise falcon.HTTPBadRequest( + "Bad request", + "Common name %s differs from Kerberos credential %s!" % (csr.common_name, machine)) + if csr.signable: + # Automatic enroll with Kerberos machine cerdentials + resp.set_header("Content-Type", "application/x-x509-user-cert") + resp.body = authority.sign(csr, overwrite=True).dump() + return + + # Check if this request has been already signed and return corresponding certificte if it has been signed try: cert = authority.get_signed(csr.common_name) @@ -51,8 +68,8 @@ class RequestListResource(object): # TODO: check for revoked certificates and return HTTP 410 Gone - # Process automatic signing if the IP address is whitelisted and autosigning was requested - if req.get_param_as_bool("autosign"): + # Process automatic signing if the IP address is whitelisted, autosigning was requested and certificate can be automatically signed + if req.get_param_as_bool("autosign") and csr.signable: for subnet in config.AUTOSIGN_SUBNETS: if req.context.get("remote_addr") in subnet: try: diff --git a/certidude/api/revoked.py b/certidude/api/revoked.py index e051262..c19a1e4 100644 --- a/certidude/api/revoked.py +++ b/certidude/api/revoked.py @@ -2,7 +2,7 @@ import falcon import json import logging -from certidude import constants +from certidude import const from certidude.authority import export_crl, list_revoked from certidude.decorators import MyEncoder from cryptography import x509 @@ -21,7 +21,7 @@ class RevocationListResource(object): resp.set_header("Content-Type", "application/x-pkcs7-crl") resp.append_header( "Content-Disposition", - ("attachment; filename=%s.crl" % constants.HOSTNAME).encode("ascii")) + ("attachment; filename=%s.crl" % const.HOSTNAME).encode("ascii")) # Convert PEM to DER resp.body = x509.load_pem_x509_crl(export_crl(), default_backend()).public_bytes(Encoding.DER) @@ -29,7 +29,7 @@ class RevocationListResource(object): resp.set_header("Content-Type", "application/x-pem-file") resp.append_header( "Content-Disposition", - ("attachment; filename=%s-crl.pem" % constants.HOSTNAME).encode("ascii")) + ("attachment; filename=%s-crl.pem" % const.HOSTNAME).encode("ascii")) resp.body = export_crl() elif req.accept.startswith("application/json"): resp.set_header("Content-Type", "application/json") diff --git a/certidude/auth.py b/certidude/auth.py index 4f6406f..872cce3 100644 --- a/certidude/auth.py +++ b/certidude/auth.py @@ -1,14 +1,14 @@ import click import falcon -import kerberos +import kerberos # If this fails pip install kerberos import logging import os import re import socket from certidude.user import User from certidude.firewall import whitelist_subnets -from certidude import config, constants +from certidude import config, const logger = logging.getLogger("api") @@ -23,32 +23,34 @@ if "kerberos" in config.AUTHENTICATION_BACKENDS: exit(248) try: - principal = kerberos.getServerPrincipalDetails("HTTP", constants.FQDN) + principal = kerberos.getServerPrincipalDetails("HTTP", const.FQDN) except kerberos.KrbError as exc: click.echo("Failed to initialize Kerberos, service principal is HTTP/%s, reason: %s" % ( - constants.FQDN, exc), err=True) + const.FQDN, exc), err=True) exit(249) else: - click.echo("Kerberos enabled, service principal is HTTP/%s" % constants.FQDN) + click.echo("Kerberos enabled, service principal is HTTP/%s" % const.FQDN) def authenticate(optional=False): def wrapper(func): def kerberos_authenticate(resource, req, resp, *args, **kwargs): - if optional and not req.get_param_as_bool("authenticate"): - return func(resource, req, resp, *args, **kwargs) - + # Try pre-emptive authentication if not req.auth: - resp.append_header("WWW-Authenticate", "Negotiate") + if optional: + req.context["user"] = None + return func(resource, req, resp, *args, **kwargs) + logger.debug(u"No Kerberos ticket offered while attempting to access %s from %s", req.env["PATH_INFO"], req.context.get("remote_addr")) raise falcon.HTTPUnauthorized("Unauthorized", - "No Kerberos ticket offered, are you sure you've logged in with domain user account?") + "No Kerberos ticket offered, are you sure you've logged in with domain user account?", + ["Negotiate"]) token = ''.join(req.auth.split()[1:]) try: - result, context = kerberos.authGSSServerInit("HTTP@" + constants.FQDN) + result, context = kerberos.authGSSServerInit("HTTP@" + const.FQDN) except kerberos.GSSError as ex: # TODO: logger.error raise falcon.HTTPForbidden("Forbidden", @@ -59,22 +61,30 @@ def authenticate(optional=False): except kerberos.GSSError as ex: kerberos.authGSSServerClean(context) logger.error(u"Kerberos authentication failed from %s. " - "Bad credentials: %s (%d)", + "GSSAPI error: %s (%d), perhaps the clock skew it too large?", req.context.get("remote_addr"), ex.args[0][0], ex.args[0][1]) raise falcon.HTTPForbidden("Forbidden", - "Bad credentials: %s (%d)" % (ex.args[0][0], ex.args[0][1])) + "GSSAPI error: %s (%d), perhaps the clock skew it too large?" % (ex.args[0][0], ex.args[0][1])) except kerberos.KrbError as ex: kerberos.authGSSServerClean(context) logger.error(u"Kerberos authentication failed from %s. " - "Bad credentials: %s (%d)", + "Kerberos error: %s (%d)", req.context.get("remote_addr"), ex.args[0][0], ex.args[0][1]) raise falcon.HTTPForbidden("Forbidden", - "Bad credentials: %s" % (ex.args[0],)) + "Kerberos error: %s" % (ex.args[0],)) user = kerberos.authGSSServerUserName(context) - req.context["user"] = User.objects.get(user) + + if "$@" in user and optional: + # Extract machine hostname + # TODO: Assert LDAP group membership + req.context["machine"], _ = user.lower().split("$@", 1) + req.context["user"] = None + else: + # Attempt to look up real user + req.context["user"] = User.objects.get(user) try: kerberos.authGSSServerClean(context) @@ -114,7 +124,7 @@ def authenticate(optional=False): if not req.auth: resp.append_header("WWW-Authenticate", "Basic") raise falcon.HTTPUnauthorized("Forbidden", - "Please authenticate with %s domain account or supply UPN" % constants.DOMAIN) + "Please authenticate with %s domain account or supply UPN" % const.DOMAIN) if not req.auth.startswith("Basic "): raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth) @@ -128,13 +138,13 @@ def authenticate(optional=False): conn = ldap.initialize(server) conn.set_option(ldap.OPT_REFERRALS, 0) try: - conn.simple_bind_s(user if "@" in user else "%s@%s" % (user, constants.DOMAIN), passwd) + conn.simple_bind_s(user if "@" in user else "%s@%s" % (user, const.DOMAIN), passwd) except ldap.LDAPError, e: resp.append_header("WWW-Authenticate", "Basic") logger.critical(u"LDAP bind authentication failed for user %s from %s", repr(user), req.context.get("remote_addr")) raise falcon.HTTPUnauthorized("Forbidden", - "Please authenticate with %s domain account or supply UPN" % constants.DOMAIN) + "Please authenticate with %s domain account or supply UPN" % const.DOMAIN) req.context["ldap_conn"] = conn break @@ -154,8 +164,7 @@ def authenticate(optional=False): return func(resource, req, resp, *args, **kwargs) if not req.auth: - resp.append_header("WWW-Authenticate", "Basic") - raise falcon.HTTPUnauthorized("Forbidden", "Please authenticate") + raise falcon.HTTPUnauthorized("Forbidden", "Please authenticate", ("Basic",)) if not req.auth.startswith("Basic "): raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth) @@ -168,7 +177,7 @@ def authenticate(optional=False): if not simplepam.authenticate(user, passwd, "sshd"): logger.critical(u"Basic authentication failed for user %s from %s", repr(user), req.context.get("remote_addr")) - raise falcon.HTTPUnauthorized("Forbidden", "Invalid password") + raise falcon.HTTPForbidden("Forbidden", "Invalid password") req.context["user"] = User.objects.get(user) return func(resource, req, resp, *args, **kwargs) diff --git a/certidude/authority.py b/certidude/authority.py index fd67e52..237ef8a 100644 --- a/certidude/authority.py +++ b/certidude/authority.py @@ -1,13 +1,18 @@ import click import os +import random import re -import socket import requests -from OpenSSL import crypto -from certidude import config, push, mailer +import socket +from datetime import datetime, timedelta +from cryptography.hazmat.backends import default_backend +from cryptography import x509 +from cryptography.x509.oid import NameOID, ExtensionOID, AuthorityInformationAccessOID +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import hashes, serialization +from certidude import config, push, mailer, const from certidude.wrappers import Certificate, Request -from certidude.signer import raw_sign from certidude import errors 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]))?$" @@ -46,7 +51,7 @@ def publish_certificate(func): headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"}) # For deleting request in the web view, use pubkey modulo - push.publish("request-signed", csr.common_name) + push.publish("request-signed", cert.common_name) return cert return wrapped @@ -73,8 +78,16 @@ def store_request(buf, overwrite=False): """ Store CSR for later processing """ - request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf) - common_name = request.get_subject().CN + + if not buf: return # No certificate supplied + csr = x509.load_pem_x509_csr(buf, backend=default_backend()) + for name in csr.subject: + if name.oid == NameOID.COMMON_NAME: + common_name = name.value + break + else: + raise ValueError("No common name in %s" % csr.subject) + request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") if not re.match(RE_HOSTNAME, common_name): @@ -98,7 +111,7 @@ def store_request(buf, overwrite=False): def signer_exec(cmd, *bits): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(config.SIGNER_SOCKET_PATH) + sock.connect(const.SIGNER_SOCKET_PATH) sock.send(cmd.encode("ascii")) sock.send(b"\n") for bit in bits: @@ -141,7 +154,7 @@ def list_revoked(directory=config.REVOKED_DIR): def export_crl(): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(config.SIGNER_SOCKET_PATH) + sock.connect(const.SIGNER_SOCKET_PATH) sock.send(b"export-crl\n") for filename in os.listdir(config.REVOKED_DIR): if not filename.endswith(".pem"): @@ -177,32 +190,49 @@ def generate_pkcs12_bundle(common_name, key_size=4096, owner=None): """ Generate private key, sign certificate and return PKCS#12 bundle """ + # Construct private key click.echo("Generating %d-bit RSA key..." % key_size) - key = crypto.PKey() - key.generate_key(crypto.TYPE_RSA, key_size) - # Construct CSR - csr = crypto.X509Req() - csr.set_version(2) # Corresponds to X.509v3 - csr.set_pubkey(key) - csr.get_subject().CN = common_name + key = rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + backend=default_backend() + ) + + csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([ + x509.NameAttribute(k, v) for k, v in ( + (NameOID.COMMON_NAME, common_name), + (NameOID.GIVEN_NAME, owner and owner.given_name), + (NameOID.SURNAME, owner and owner.surname), + ) if v + ])) + if owner: - if owner.given_name: - csr.get_subject().GN = owner.given_name - if owner.surname: - csr.get_subject().SN = owner.surname - csr.add_extensions([ - crypto.X509Extension("subjectAltName", True, "email:%s" % owner.mail.encode("ascii"))]) - - buf = crypto.dump_certificate_request(crypto.FILETYPE_PEM, csr) + click.echo("Setting e-mail to: %s" % owner.mail) + csr = csr.add_extension( + x509.SubjectAlternativeName([ + x509.RFC822Name(owner.mail) + ]), + critical=False) # Sign CSR - cert = sign(Request(buf), overwrite=True) + cert = sign(Request( + csr.sign(key, hashes.SHA512(), default_backend()).public_bytes(serialization.Encoding.PEM)), overwrite=True) - # Generate P12 + # Generate P12, currently supported only by PyOpenSSL + from OpenSSL import crypto p12 = crypto.PKCS12() - p12.set_privatekey( key ) + p12.set_privatekey( + crypto.load_privatekey( + crypto.FILETYPE_PEM, + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ) + ) + ) p12.set_certificate( cert._obj ) p12.set_ca_certificates([certificate._obj]) return p12.export(), cert @@ -213,7 +243,6 @@ def sign(req, overwrite=False, delete=True): """ Sign certificate signing request via signer process """ - cert_path = os.path.join(config.SIGNED_DIR, req.common_name + ".pem") # Move existing certificate if necessary @@ -236,35 +265,95 @@ def sign(req, overwrite=False, delete=True): @publish_certificate -def sign2(request, overwrite=False, delete=True, lifetime=None): +def sign2(request, private_key, authority_certificate, overwrite=False, delete=True, lifetime=None): """ Sign directly using private key, this is usually done by root. Basic constraints and certificate lifetime are copied from config, lifetime may be overridden on the command line, other extensions are copied as is. """ - cert = raw_sign( - crypto.load_privatekey(crypto.FILETYPE_PEM, open(config.AUTHORITY_PRIVATE_KEY_PATH).read()), - crypto.load_certificate(crypto.FILETYPE_PEM, open(config.AUTHORITY_CERTIFICATE_PATH).read()), - request._obj, - config.CERTIFICATE_BASIC_CONSTRAINTS, - lifetime=lifetime or config.CERTIFICATE_LIFETIME) - path = os.path.join(config.SIGNED_DIR, request.common_name + ".pem") - if os.path.exists(path): + certificate_path = os.path.join(config.SIGNED_DIR, request.common_name + ".pem") + if os.path.exists(certificate_path): if overwrite: revoke_certificate(request.common_name) else: - raise EnvironmentError("File %s already exists!" % path) + raise errors.DuplicateCommonNameError("Valid certificate with common name %s already exists" % request.common_name) - buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) - with open(path + ".part", "wb") as fh: + now = datetime.utcnow() + request_path = os.path.join(config.REQUESTS_DIR, request.common_name + ".pem") + request = x509.load_pem_x509_csr(open(request_path).read(), default_backend()) + + cert = x509.CertificateBuilder( + ).subject_name(x509.Name([n for n in request.subject]) + ).serial_number(random.randint( + 0x1000000000000000000000000000000000000000, + 0xffffffffffffffffffffffffffffffffffffffff) + ).issuer_name(authority_certificate.issuer + ).public_key(request.public_key() + ).not_valid_before(now - timedelta(hours=1) + ).not_valid_after(now + timedelta(days=config.CERTIFICATE_LIFETIME) + ).add_extension(x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False), critical=True + ).add_extension( + x509.SubjectKeyIdentifier.from_public_key(request.public_key()), + critical=False + ).add_extension( + x509.AuthorityInformationAccess([ + x509.AccessDescription( + AuthorityInformationAccessOID.CA_ISSUERS, + x509.UniformResourceIdentifier( + config.CERTIFICATE_AUTHORITY_URL) + ) + ]), + critical=False + ).add_extension( + x509.CRLDistributionPoints([ + x509.DistributionPoint( + full_name=[ + x509.UniformResourceIdentifier( + config.CERTIFICATE_CRL_URL)], + relative_name=None, + crl_issuer=None, + reasons=None) + ]), + critical=False + ).add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key( + authority_certificate.public_key()), + critical=False + ) + + # Append subject alternative name, extended key usage flags etc + for extension in request.extensions: + if extension.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME: + click.echo("Appending subject alt name extension: %s" % extension) + cert = cert.add_extension(x509.SubjectAlternativeName(extension.value), + critical=extension.critical) + if extension.oid == ExtensionOID.EXTENDED_KEY_USAGE: + click.echo("Appending extended key usage flags extension: %s" % extension) + cert = cert.add_extension(x509.ExtendedKeyUsage(extension.value), + critical=extension.critical) + + + cert = cert.sign(private_key, hashes.SHA512(), default_backend()) + + buf = cert.public_bytes(serialization.Encoding.PEM) + with open(certificate_path + ".part", "wb") as fh: fh.write(buf) - os.rename(path + ".part", path) - click.echo("Wrote certificate to: %s" % path) + os.rename(certificate_path + ".part", certificate_path) + click.echo("Wrote certificate to: %s" % certificate_path) if delete: - os.unlink(request.path) - click.echo("Deleted request: %s" % request.path) + os.unlink(request_path) + click.echo("Deleted request: %s" % request_path) - return Certificate(open(path)) + return Certificate(open(certificate_path)) diff --git a/certidude/cli.py b/certidude/cli.py index e7c8f52..0c823fb 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # coding: utf-8 import asyncore @@ -7,33 +6,38 @@ import hashlib import logging import os import pwd +import random import re import requests import signal import socket +import string import subprocess import sys -from configparser import ConfigParser -from certidude import constants +from configparser import ConfigParser, NoOptionError, NoSectionError from certidude.helpers import certidude_request_certificate from certidude.common import expand_paths, ip_address, ip_network +from cryptography import x509 +from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa from datetime import datetime, timedelta from humanize import naturaltime from jinja2 import Environment, PackageLoader from time import sleep from setproctitle import setproctitle - +import const env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=True) # http://www.mad-hacking.net/documentation/linux/security/ssl-tls/creating-ca.xml # https://kjur.github.io/jsrsasign/ -# keyUsage, extendedKeyUsage - https://www.openssl.org/docs/apps/x509v3_config.html +# keyUsage, extendedKeyUsage - https://www.openssl.org/docs/apps/x509v3_client_config.html # strongSwan key paths - https://wiki.strongswan.org/projects/1/wiki/SimpleCA # Parse command-line argument defaults from environment -HOSTNAME = socket.gethostname() -FQDN = socket.getaddrinfo(HOSTNAME, 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME)[0][3] + USERNAME = os.environ.get("USER") NOW = datetime.utcnow().replace(tzinfo=None) FIRST_NAME = None @@ -41,7 +45,7 @@ SURNAME = None EMAIL = None if USERNAME: - EMAIL = USERNAME + "@" + FQDN + EMAIL = USERNAME + "@" + const.FQDN if os.getuid() >= 1000: _, _, _, _, gecos, _, _ = pwd.getpwnam(USERNAME) @@ -51,14 +55,22 @@ if os.getuid() >= 1000: FIRST_NAME = gecos -@click.command("spawn", help="Run processes for requesting certificates and configuring services") +@click.command("request", help="Run processes for requesting certificates and configuring services") @click.option("-f", "--fork", default=False, is_flag=True, help="Fork to background") -def certidude_request_spawn(fork): - clients = ConfigParser() - clients.readfp(open("/etc/certidude/client.conf")) +def certidude_request(fork): + if not os.path.exists(const.CLIENT_CONFIG_PATH): + click.echo("No %s!" % const.CLIENT_CONFIG_PATH) + return 1 - services = ConfigParser() - services.readfp(open("/etc/certidude/services.conf")) + if not os.path.exists(const.SERVICES_CONFIG_PATH): + click.echo("No %s!" % const.SERVICES_CONFIG_PATH) + return 1 + + clients = ConfigParser() + clients.readfp(open(const.CLIENT_CONFIG_PATH)) + + service_config = ConfigParser() + service_config.readfp(open(const.SERVICES_CONFIG_PATH)) # Process directories run_dir = "/run/certidude" @@ -68,11 +80,56 @@ def certidude_request_spawn(fork): click.echo("Creating: %s" % run_dir) os.makedirs(run_dir) - for server in clients.sections(): - if clients.get(server, "trigger") != "interface up": + for authority in clients.sections(): + try: + endpoint_common_name = clients.get(authority, "common name") + except NoOptionError: + endpoint_common_name = const.HOSTNAME + try: + endpoint_key_path = clients.get(authority, "key path") + except NoOptionError: + endpoint_key_path = "/var/lib/certidude/%s/keys/%s.pem" % (authority, const.HOSTNAME) + try: + endpoint_request_path = clients.get(authority, "request path") + except NoOptionError: + endpoint_request_path = "/var/lib/certidude/%s/requests/%s.pem" % (authority, const.HOSTNAME) + try: + endpoint_certificate_path = clients.get(authority, "certificate path") + except NoOptionError: + endpoint_certificate_path = "/var/lib/certidude/%s/signed/%s.pem" % (authority, const.HOSTNAME) + try: + endpoint_authority_path = clients.get(authority, "authority path") + except NoOptionError: + endpoint_authority_path = "/var/lib/certidude/%s/ca_crt.pem" % authority + try: + endpoint_revocations_path = clients.get(authority, "revocations path") + except NoOptionError: + endpoint_revocations_path = "/var/lib/certidude/%s/ca_crl.pem" % authority + # TODO: Create directories automatically + + extended_key_usage_flags=[] + try: + endpoint_key_flags = set([j.strip() for j in clients.get(authority, "extended key usage flags").lower().split(",") if j.strip()]) + except NoOptionError: + pass + else: + if "server auth" in endpoint_key_flags: + endpoint_key_flags -= set(["server auth"]) + extended_key_usage_flags.append(ExtendedKeyUsageOID.SERVER_AUTH) + if "ike intermediate" in endpoint_key_flags: + endpoint_key_flags -= set(["ike intermediate"]) + extended_key_usage_flags.append(x509.ObjectIdentifier("1.3.6.1.5.5.8.2.2")) + if endpoint_key_flags: + raise ValueError("Extended key usage flags %s not understood!" % endpoint_key_flags) + # TODO: IKE Intermediate + + if clients.get(authority, "trigger") == "domain joined": + if not os.path.exists("/etc/krb5.keytab"): + continue + elif clients.get(authority, "trigger") != "interface up": continue - pid_path = os.path.join(run_dir, server + ".pid") + pid_path = os.path.join(run_dir, authority + ".pid") try: with open(pid_path) as fh: @@ -92,20 +149,22 @@ def certidude_request_spawn(fork): click.echo("Spawned certificate request process with PID %d" % (child_pid)) continue + with open(pid_path, "w") as fh: fh.write("%d\n" % os.getpid()) - setproctitle("certidude request spawn %s" % server) retries = 30 + while retries > 0: try: certidude_request_certificate( - server, - clients.get(server, "key_path"), - clients.get(server, "request_path"), - clients.get(server, "certificate_path"), - clients.get(server, "authority_path"), - clients.get(server, "revocations_path"), - socket.gethostname(), + authority, + endpoint_key_path, + endpoint_request_path, + endpoint_certificate_path, + endpoint_authority_path, + endpoint_revocations_path, + endpoint_common_name, + extended_key_usage_flags, None, autosign=True, wait=True) @@ -114,10 +173,11 @@ def certidude_request_spawn(fork): retries -= 1 continue - for endpoint in services.sections(): - if services.get(endpoint, "authority") != server: + for endpoint in service_config.sections(): + if service_config.get(endpoint, "authority") != authority: continue + click.echo("Configuring '%s'" % endpoint) csummer = hashlib.sha1() csummer.update(endpoint.encode("ascii")) csum = csummer.hexdigest() @@ -126,94 +186,25 @@ def certidude_request_spawn(fork): # Intranet HTTPS handled by PKCS#12 bundle generation, # so it will not be implemented here - if services.get(endpoint, "service") == "network-manager/openvpn": - config = ConfigParser() - config.add_section("connection") - config.add_section("vpn") - config.add_section("ipv4") - config.add_section("ipv6") - - config.set("connection", "id", endpoint) - config.set("connection", "uuid", uuid) - config.set("connection", "type", "vpn") - - config.set("vpn", "service-type", "org.freedesktop.NetworkManager.openvpn") - config.set("vpn", "connection-type", "tls") - config.set("vpn", "comp-lzo", "yes") - config.set("vpn", "cert-pass-flags", "0") - config.set("vpn", "tap-dev", "yes") - config.set("vpn", "remote-cert-tls", "server") # Assert TLS Server flag of X.509 certificate - config.set("vpn", "remote", services.get(endpoint, "remote")) - config.set("vpn", "key", clients.get(server, "key_path")) - config.set("vpn", "cert", clients.get(server, "certificate_path")) - config.set("vpn", "ca", clients.get(server, "authority_path")) - - config.set("ipv6", "method", "auto") - - config.set("ipv4", "method", "auto") - config.set("ipv4", "never-default", "true") - - # Prevent creation of files with liberal permissions - os.umask(0o177) - - # Write keyfile - with open(os.path.join("/etc/NetworkManager/system-connections", endpoint), "w") as configfile: - config.write(configfile) + # OpenVPN set up with initscripts + if service_config.get(endpoint, "service") == "init/openvpn": + if os.path.exists("/etc/openvpn/%s.disabled" % endpoint) and not os.path.exists("/etc/openvpn/%s.conf" % endpoint): + os.rename("/etc/openvpn/%s.disabled" % endpoint, "/etc/openvpn/%s.conf" % endpoint) + if os.path.exists("/bin/systemctl"): + click.echo("Re-running systemd generators for OpenVPN...") + os.system("systemctl daemon-reload") + click.echo("Starting OpenVPN...") + os.system("service openvpn start") continue - - # Set up IPsec via NetworkManager - if services.get(endpoint, "service") == "network-manager/strongswan": - config = ConfigParser() - config.add_section("connection") - config.add_section("vpn") - config.add_section("ipv4") - - config.set("connection", "id", endpoint) - config.set("connection", "uuid", uuid) - config.set("connection", "type", "vpn") - - config.set("vpn", "service-type", "org.freedesktop.NetworkManager.strongswan") - config.set("vpn", "encap", "no") - config.set("vpn", "virtual", "yes") - config.set("vpn", "method", "key") - config.set("vpn", "ipcomp", "no") - config.set("vpn", "address", services.get(endpoint, "remote")) - config.set("vpn", "userkey", clients.get(server, "key_path")) - config.set("vpn", "usercert", clients.get(server, "certificate_path")) - config.set("vpn", "certificate", clients.get(server, "authority_path")) - - config.set("ipv4", "method", "auto") - - # Add routes, may need some more tweaking - if services.has_option(endpoint, "route"): - for index, subnet in enumerate(services.get(endpoint, "route").split(","), start=1): - config.set("ipv4", "route%d" % index, subnet) - - # Prevent creation of files with liberal permissions - os.umask(0o177) - - # Write keyfile - with open(os.path.join("/etc/NetworkManager/system-connections", endpoint), "w") as configfile: - config.write(configfile) - continue - - # Set up IPsec via /etc/ipsec.conf - if services.get(endpoint, "service") == "strongswan": + # IPSec set up with initscripts + if service_config.get(endpoint, "service") == "init/strongswan": from ipsecparse import loads config = loads(open('/etc/ipsec.conf').read()) - config["conn", endpoint] = dict( - leftsourceip="%config", - left="%defaultroute", - leftcert=clients.get(server, "certificate_path"), - rightid="%any", - right=services.get(endpoint, "remote"), - rightsubnet=services.get(endpoint, "route"), - keyexchange="ikev2", - keyingtries="300", - dpdaction="restart", - closeaction="restart", - auto="start") + if config["conn"][server]["left"] == "%defaultroute": + config["conn"][server]["auto"] = "start" # This is client + else: + config["conn"][server]["auto"] = "add" # This is server with open("/etc/ipsec.conf.part", "w") as fh: fh.write(config.dumps()) os.rename("/etc/ipsec.conf.part", "/etc/ipsec.conf") @@ -231,86 +222,82 @@ def certidude_request_spawn(fork): os.system("ipsec start") continue + # OpenVPN set up with NetworkManager + if service_config.get(endpoint, "service") == "network-manager/openvpn": + nm_config = ConfigParser() + nm_config.add_section("connection") + nm_config.set("connection", "id", endpoint) + nm_config.set("connection", "uuid", uuid) + nm_config.set("connection", "type", "vpn") + nm_config.add_section("vpn") + nm_config.set("vpn", "service-type", "org.freedesktop.NetworkManager.openvpn") + nm_config.set("vpn", "connection-type", "tls") + nm_config.set("vpn", "comp-lzo", "yes") + nm_config.set("vpn", "cert-pass-flags", "0") + nm_config.set("vpn", "tap-dev", "yes") + nm_config.set("vpn", "remote-cert-tls", "server") # Assert TLS Server flag of X.509 certificate + nm_config.set("vpn", "remote", service_config.get(endpoint, "remote")) + nm_config.set("vpn", "key", endpoint_key_path) + nm_config.set("vpn", "cert", endpoint_certificate_path) + nm_config.set("vpn", "ca", endpoint_authority_path) + nm_config.add_section("ipv4") + nm_config.set("ipv4", "method", "auto") + nm_config.set("ipv4", "never-default", "true") + nm_config.add_section("ipv6") + nm_config.set("ipv6", "method", "auto") + + # Prevent creation of files with liberal permissions + os.umask(0o177) + + # Write NetworkManager configuration + with open(os.path.join("/etc/NetworkManager/system-connections", endpoint), "w") as fh: + nm_config.write(fh) + click.echo("Created %s" % fh.name) + continue + + + # IPSec set up with NetworkManager + if service_config.get(endpoint, "service") == "network-manager/strongswan": + client_config = ConfigParser() + nm_config.add_section("connection") + nm_config.set("connection", "id", endpoint) + nm_config.set("connection", "uuid", uuid) + nm_config.set("connection", "type", "vpn") + nm_config.add_section("vpn") + nm_config.set("vpn", "service-type", "org.freedesktop.NetworkManager.strongswan") + nm_config.set("vpn", "encap", "no") + nm_config.set("vpn", "virtual", "yes") + nm_config.set("vpn", "method", "key") + nm_config.set("vpn", "ipcomp", "no") + nm_config.set("vpn", "address", service_config.get(endpoint, "remote")) + nm_config.set("vpn", "userkey", endpoint_key_path) + nm_config.set("vpn", "usercert", endpoint_certificate_path) + nm_config.set("vpn", "certificate", endpoint_authority_path) + nm_config.add_section("ipv4") + nm_config.set("ipv4", "method", "auto") + + # Add routes, may need some more tweaking + if service_config.has_option(endpoint, "route"): + for index, subnet in enumerate(service_config.get(endpoint, "route").split(","), start=1): + nm_config.set("ipv4", "route%d" % index, subnet) + + # Prevent creation of files with liberal permissions + os.umask(0o177) + + # Write NetworkManager configuration + with open(os.path.join("/etc/NetworkManager/system-connections", endpoint), "w") as fh: + nm_config.write(fh) + click.echo("Created %s" % fh.name) + continue + # TODO: Puppet, OpenLDAP, os.unlink(pid_path) -@click.command("spawn", help="Restart privilege isolated signer process") -@click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys") -def certidude_signer_spawn(no_interaction): - """ - Spawn privilege isolated signer process - """ - from certidude.signer import SignServer - from certidude import config - - _, _, uid, gid, gecos, root, shell = pwd.getpwnam("certidude") - os.setgid(gid) - - # Check whether we have privileges - os.umask(0o027) - uid = os.getuid() - if uid != 0: - raise click.ClickException("Not running as root") - - # Process directories - run_dir = "/run/certidude" - - # Prepare signer PID-s directory - if not os.path.exists(run_dir): - click.echo("Creating: %s" % run_dir) - os.makedirs(run_dir) - - # Preload charmap encoding for byte_string() function of pyOpenSSL - # in order to enable chrooting - "".encode("charmap") - - # Prepare chroot directories - chroot_dir = os.path.join(run_dir, "jail") - if not os.path.exists(os.path.join(chroot_dir, "dev")): - os.makedirs(os.path.join(chroot_dir, "dev")) - if not os.path.exists(os.path.join(chroot_dir, "dev", "urandom")): - # TODO: use os.mknod instead - os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom")) - - try: - with open(config.SIGNER_PID_PATH) as fh: - pid = int(fh.readline()) - os.kill(pid, 0) - click.echo("Found process with PID %d" % pid) - except EnvironmentError: - pid = 0 - - if pid > 0: - try: - click.echo("Killing %d" % pid) - os.kill(pid, signal.SIGTERM) - sleep(1) - os.kill(pid, signal.SIGKILL) - sleep(1) - except EnvironmentError: - pass - - child_pid = os.fork() - - if child_pid: - click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH)) - return - - setproctitle("certidude signer spawn") - with open(config.SIGNER_PID_PATH, "w") as fh: - fh.write("%d\n" % os.getpid()) - logging.basicConfig( - filename="/var/log/signer.log", - level=logging.INFO) - server = SignServer() - asyncore.loop() - - @click.command("client", help="Setup X.509 certificates for application") @click.argument("server") -@click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, '%s' by default" % HOSTNAME) +@click.option("--common-name", "-cn", default=const.HOSTNAME, help="Common name, '%s' by default" % const.HOSTNAME) @click.option("--org-unit", "-ou", help="Organizational unit") @click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, '%s' by default" % EMAIL) @click.option("--given-name", "-gn", default=FIRST_NAME, help="Given name of the person associted with the certificate, '%s' by default" % FIRST_NAME) @@ -320,9 +307,9 @@ def certidude_signer_spawn(no_interaction): @click.option("--quiet", "-q", default=False, is_flag=True, help="Disable verbose output") @click.option("--autosign", "-s", default=False, is_flag=True, help="Request for automatic signing if available") @click.option("--wait", "-w", default=False, is_flag=True, help="Wait for certificate, by default return immideately") -@click.option("--key-path", "-k", default=HOSTNAME + ".key", help="Key path, %s.key by default" % HOSTNAME) -@click.option("--request-path", "-r", default=HOSTNAME + ".csr", help="Request path, %s.csr by default" % HOSTNAME) -@click.option("--certificate-path", "-c", default=HOSTNAME + ".crt", help="Certificate path, %s.crt by default" % HOSTNAME) +@click.option("--key-path", "-k", default=const.HOSTNAME + ".key", help="Key path, %s.key by default" % const.HOSTNAME) +@click.option("--request-path", "-r", default=const.HOSTNAME + ".csr", help="Request path, %s.csr by default" % const.HOSTNAME) +@click.option("--certificate-path", "-c", default=const.HOSTNAME + ".crt", help="Certificate path, %s.crt by default" % const.HOSTNAME) @click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt by default") @click.option("--revocations-path", "-crl", default="ca.crl", help="Certificate revocation list, ca.crl by default") def certidude_setup_client(quiet, **kwargs): @@ -330,8 +317,7 @@ def certidude_setup_client(quiet, **kwargs): @click.command("server", help="Set up OpenVPN server") -@click.argument("server") -@click.option("--common-name", "-cn", default=FQDN, help="Common name, %s by default" % FQDN) +@click.argument("authority") @click.option("--org-unit", "-ou", help="Organizational unit") @click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, '%s' by default" % EMAIL) @click.option("--subnet", "-s", default="192.168.33.0/24", type=ip_network, help="OpenVPN subnet, 192.168.33.0/24 by default") @@ -343,15 +329,9 @@ def certidude_setup_client(quiet, **kwargs): default="/etc/openvpn/site-to-client.conf", type=click.File(mode="w", atomic=True, lazy=True), help="OpenVPN configuration file") -@click.option("--directory", "-d", default="/etc/openvpn/keys", help="Directory for keys, /etc/openvpn/keys by default") -@click.option("--key-path", "-key", default=HOSTNAME + ".key", help="Key path, %s.key relative to -d by default" % HOSTNAME) -@click.option("--request-path", "-csr", default=HOSTNAME + ".csr", help="Request path, %s.csr relative to -d by default" % HOSTNAME) -@click.option("--certificate-path", "-crt", default=HOSTNAME + ".crt", help="Certificate path, %s.crt relative to -d by default" % HOSTNAME) -@click.option("--dhparam-path", "-dh", default="dhparam2048.pem", help="Diffie/Hellman parameters path, dhparam2048.pem relative to -d by default") -@click.option("--authority-path", "-ca", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to --dir by default") -@click.option("--revocations-path", "-crl", default="ca.crl", help="Certificate revocation list, ca.crl relative to -d by default") -@expand_paths() -def certidude_setup_openvpn_server(server, config, subnet, route, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, revocations_path, dhparam_path, local, proto, port): +def certidude_setup_openvpn_server(authority, config, subnet, route, email_address, org_unit, local, proto, port): + + # TODO: Make dirs # TODO: Intelligent way of getting last IP address in the subnet subnet_first = None subnet_last = None @@ -364,58 +344,102 @@ def certidude_setup_openvpn_server(server, config, subnet, route, email_address, subnet_second = addr subnet_last = addr - if not os.path.exists(certificate_path): - click.echo("As OpenVPN server certificate needs specific key usage extensions please") - click.echo("use following command to sign on Certidude server instead of web interface:") - click.echo() - click.echo(" certidude sign %s" % common_name) - retval = certidude_request_certificate(server, - key_path, request_path, certificate_path, authority_path, revocations_path, - common_name, org_unit, email_address, - key_usage="digitalSignature,keyEncipherment", - extended_key_usage="serverAuth", - wait=True) + # Create corresponding section in Certidude client configuration file + client_config = ConfigParser() + if os.path.exists(const.CLIENT_CONFIG_PATH): + client_config.readfp(open(const.CLIENT_CONFIG_PATH)) + if client_config.has_section(authority): + click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) + else: + client_config.set(authority, "trigger", "interface up") + client_config.set(authority, "common name", const.HOSTNAME) + client_config.set(authority, "subject alternative name dns", const.FQDN) + client_config.set(authority, "extended key usage flags", "server auth") + client_config.set(authority, "request path", "/etc/openvpn/keys/%s.csr" % const.HOSTNAME) + client_config.set(authority, "key path", "/etc/openvpn/keys/%s.key" % const.HOSTNAME) + client_config.set(authority, "certificate path", "/etc/openvpn/keys/%s.crt" % const.HOSTNAME) + client_config.set(authority, "authority path", "/etc/openvpn/keys/ca.crt") + client_config.set(authority, "revocations path", "/etc/openvpn/keys/ca.crl") + with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: + client_config.write(fh) + os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) + click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) + + # Create corresponding section in /etc/certidude/services.conf + endpoint = "OpenVPN server %s of %s" % (const.FQDN, authority) + service_config = ConfigParser() + if os.path.exists(const.SERVICES_CONFIG_PATH): + service_config.readfp(open(const.SERVICES_CONFIG_PATH)) + if service_config.has_section(endpoint): + click.echo("Section '%s' already exists in %s, not reconfiguring" % (endpoint, const.SERVICES_CONFIG_PATH)) + else: + service_config.add_section(endpoint) + service_config.set(endpoint, "authority", authority) + service_config.set(endpoint, "service", "init/openvpn") + with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh: + service_config.write(fh) + os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH) + click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH)) + + dhparam_path = "/etc/openvpn/keys/dhparam.pem" if not os.path.exists(dhparam_path): cmd = "openssl", "dhparam", "-out", dhparam_path, "2048" subprocess.check_call(cmd) - if retval: - return retval - - # TODO: Add dhparam - config.write(env.get_template("openvpn-site-to-client.ovpn").render(vars())) + config.write("mode server\n") + config.write("tls-server\n") + config.write("proto %s\n" % proto) + config.write("port %d\n" % port) + config.write("dev tap\n") + config.write("local %s\n" % local) + config.write("key %s\n" % client_config.get(authority, "key path")) + config.write("cert %s\n" % client_config.get(authority, "certificate path")) + config.write("ca %s\n" % client_config.get(authority, "authority path")) + config.write("dh %s\n" % dhparam_path) + config.write("comp-lzo\n") + config.write("user nobody\n") + config.write("group nogroup\n") + config.write("persist-tun\n") + config.write("persist-key\n") + config.write("ifconfig-pool-persist /tmp/openvpn-leases.txt\n") + config.write("ifconfig %s 255.255.255.0\n" % subnet_first) + config.write("server-bridge %s 255.255.255.0 %s %s\n" % (subnet_first, subnet_second, subnet_last)) + config.write("#crl-verify %s\n" % client_config.get(authority, "revocations path")) click.echo("Generated %s" % config.name) + click.echo("Inspect generated files and issue following to request certificate:") click.echo() - click.echo("Inspect newly created %s and start OpenVPN service:" % config.name) + click.echo(" certidude request") click.echo() - click.secho(" service openvpn restart", bold=True) + click.echo("As OpenVPN server certificate needs specific key usage extensions please") + click.echo("use following command to sign on Certidude server instead of web interface:") click.echo() + click.echo(" certidude sign %s" % const.HOSTNAME) @click.command("nginx", help="Set up nginx as HTTPS server") @click.argument("server") -@click.option("--common-name", "-cn", default=FQDN, help="Common name, %s by default" % FQDN) +@click.option("--common-name", "-cn", default=const.FQDN, help="Common name, %s by default" % const.FQDN) @click.option("--org-unit", "-ou", help="Organizational unit") @click.option("--tls-config", default="/etc/nginx/conf.d/tls.conf", type=click.File(mode="w", atomic=True, lazy=True), help="TLS configuration file of nginx, /etc/nginx/conf.d/tls.conf by default") @click.option("--site-config", "-o", - default="/etc/nginx/sites-available/%s.conf" % HOSTNAME, + default="/etc/nginx/sites-available/%s.conf" % const.HOSTNAME, type=click.File(mode="w", atomic=True, lazy=True), - help="Site configuration file of nginx, /etc/nginx/sites-available/%s.conf by default" % HOSTNAME) + help="Site configuration file of nginx, /etc/nginx/sites-available/%s.conf by default" % const.HOSTNAME) @click.option("--directory", "-d", default="/etc/nginx/ssl", help="Directory for keys, /etc/nginx/ssl by default") -@click.option("--key-path", "-key", default=HOSTNAME + ".key", help="Key path, %s.key relative to -d by default" % HOSTNAME) -@click.option("--request-path", "-csr", default=HOSTNAME + ".csr", help="Request path, %s.csr relative to -d by default" % HOSTNAME) -@click.option("--certificate-path", "-crt", default=HOSTNAME + ".crt", help="Certificate path, %s.crt relative to -d by default" % HOSTNAME) +@click.option("--key-path", "-key", default=const.HOSTNAME + ".key", help="Key path, %s.key relative to -d by default" % const.HOSTNAME) +@click.option("--request-path", "-csr", default=const.HOSTNAME + ".csr", help="Request path, %s.csr relative to -d by default" % const.HOSTNAME) +@click.option("--certificate-path", "-crt", default=const.HOSTNAME + ".crt", help="Certificate path, %s.crt relative to -d by default" % const.HOSTNAME) @click.option("--dhparam-path", "-dh", default="dhparam2048.pem", help="Diffie/Hellman parameters path, dhparam2048.pem relative to -d by default") @click.option("--authority-path", "-ca", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to -d by default") @click.option("--revocations-path", "-crl", default="ca.crl", help="Certificate revocation list, ca.crl relative to -d by default") @click.option("--verify-client", "-vc", type=click.Choice(['optional', 'on', 'off'])) @expand_paths() -def certidude_setup_nginx(server, site_config, tls_config, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, revocations_path, dhparam_path, verify_client): +def certidude_setup_nginx(authority, site_config, tls_config, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, revocations_path, dhparam_path, verify_client): # TODO: Intelligent way of getting last IP address in the subnet if not os.path.exists(certificate_path): @@ -424,11 +448,10 @@ def certidude_setup_nginx(server, site_config, tls_config, common_name, org_unit click.echo() click.echo(" certidude sign %s" % common_name) click.echo() - retval = certidude_request_certificate(server, key_path, request_path, + retval = certidude_request_certificate(authority, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, org_unit, - key_usage="digitalSignature,keyEncipherment", - extended_key_usage="serverAuth", - dns = constants.FQDN, wait=True, bundle=True) + extended_key_usage_flags = [ExtendedKeyUsageOID.SERVER_AUTH], + dns = const.FQDN, wait=True, bundle=True) if not os.path.exists(dhparam_path): cmd = "openssl", "dhparam", "-out", dhparam_path, "2048" @@ -437,127 +460,158 @@ def certidude_setup_nginx(server, site_config, tls_config, common_name, org_unit if retval: return retval - context = globals() # Grab constants.BLAH + context = globals() # Grab const.BLAH context.update(locals()) - if os.path.exists(site_config.name): - click.echo("Configuration file %s already exists, not overwriting" % site_config.name) + if os.path.exists(site_client_config.name): + click.echo("Configuration file %s already exists, not overwriting" % site_client_config.name) else: - site_config.write(env.get_template("nginx-https-site.conf").render(context)) - click.echo("Generated %s" % site_config.name) + site_client_config.write(env.get_template("nginx-https-site.conf").render(context)) + click.echo("Generated %s" % site_client_config.name) - if os.path.exists(tls_config.name): - click.echo("Configuration file %s already exists, not overwriting" % tls_config.name) + if os.path.exists(tls_client_config.name): + click.echo("Configuration file %s already exists, not overwriting" % tls_client_config.name) else: - tls_config.write(env.get_template("nginx-tls.conf").render(context)) - click.echo("Generated %s" % tls_config.name) + tls_client_config.write(env.get_template("nginx-tls.conf").render(context)) + click.echo("Generated %s" % tls_client_config.name) click.echo() click.echo("Inspect configuration files, enable it and start nginx service:") click.echo() click.echo(" ln -s %s /etc/nginx/sites-enabled/%s" % ( - os.path.relpath(site_config.name, "/etc/nginx/sites-enabled"), - os.path.basename(site_config.name))) + os.path.relpath(site_client_config.name, "/etc/nginx/sites-enabled"), + os.path.basename(site_client_config.name))) click.secho(" service nginx restart", bold=True) click.echo() @click.command("client", help="Set up OpenVPN client") -@click.argument("server") +@click.argument("authority") @click.argument("remote") @click.option('--proto', "-t", default="udp", type=click.Choice(['udp', 'tcp']), help="OpenVPN transport protocol, UDP by default") -@click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, %s by default" % HOSTNAME) @click.option("--org-unit", "-ou", help="Organizational unit") -@click.option("--email-address", "-m", help="E-mail associated with the request, none by default") @click.option("--config", "-o", default="/etc/openvpn/client-to-site.conf", type=click.File(mode="w", atomic=True, lazy=True), help="OpenVPN configuration file") -@click.option("--directory", "-d", default="/etc/openvpn/keys", help="Directory for keys, /etc/openvpn/keys by default") -@click.option("--key-path", "-key", default=HOSTNAME + ".key", help="Key path, %s.key relative to -d by default" % HOSTNAME) -@click.option("--request-path", "-csr", default=HOSTNAME + ".csr", help="Request path, %s.csr relative to -d by default" % HOSTNAME) -@click.option("--certificate-path", "-crt", default=HOSTNAME + ".crt", help="Certificate path, %s.crt relative to -d by default" % HOSTNAME) -@click.option("--authority-path", "-ca", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to --dir by default") -@click.option("--revocations-path", "-crl", default="ca.crl", help="Certificate revocation list, ca.crl relative to -d by default") -@expand_paths() -def certidude_setup_openvpn_client(server, config, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, revocations_path, proto, remote): +def certidude_setup_openvpn_client(authority, remote, config, org_unit, proto): - retval = certidude_request_certificate(server, - key_path, request_path, certificate_path, authority_path, revocations_path, - common_name, org_unit, email_address, - wait=True) + # Create corresponding section in Certidude client configuration file + client_config = ConfigParser() + if os.path.exists(const.CLIENT_CONFIG_PATH): + client_config.readfp(open(const.CLIENT_CONFIG_PATH)) + if client_config.has_section(authority): + click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) + else: + client_config.add_section(authority) + client_config.set(authority, "trigger", "interface up") + client_config.set(authority, "common name", const.HOSTNAME) + client_config.set(authority, "request path", "/etc/openvpn/keys/%s.csr" % const.HOSTNAME) + client_config.set(authority, "key path", "/etc/openvpn/keys/%s.key" % const.HOSTNAME) + client_config.set(authority, "certificate path", "/etc/openvpn/keys/%s.crt" % const.HOSTNAME) + client_config.set(authority, "authority path", "/etc/openvpn/keys/ca.crt") + client_config.set(authority, "revocations path", "/etc/openvpn/keys/ca.crl") + with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: + client_config.write(fh) + os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) + click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) - if retval: - return retval + # Create corresponding section in /etc/certidude/services.conf + endpoint = "OpenVPN connection to %s" % remote + service_config = ConfigParser() + if os.path.exists(const.SERVICES_CONFIG_PATH): + service_config.readfp(open(const.SERVICES_CONFIG_PATH)) + if service_config.has_section(endpoint): + click.echo("Section '%s' already exists in %s, not reconfiguring" % (endpoint, const.SERVICES_CONFIG_PATH)) + else: + service_config.add_section(endpoint) + service_config.set(endpoint, "authority", authority) + service_config.set(endpoint, "service", "init/openvpn") + with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh: + service_config.write(fh) + os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH) + click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH)) - # TODO: Add dhparam - config.write(env.get_template("openvpn-client-to-site.ovpn").render(vars())) + config.write("client\n") + config.write("remote %s\n" % remote) + config.write("remote-cert-tls server\n") + config.write("proto %s\n" % proto) + config.write("dev tap\n") + config.write("nobind\n") + config.write("key %s\n" % client_config.get(authority, "key path")) + config.write("cert %s\n" % client_config.get(authority, "certificate path")) + config.write("ca %s\n" % client_config.get(authority, "authority path")) + config.write("crl-verify %s\n" % client_config.get(authority, "revocations path")) + config.write("comp-lzo\n") + config.write("user nobody\n") + config.write("group nogroup\n") + config.write("persist-tun\n") + config.write("persist-key\n") click.echo("Generated %s" % config.name) + click.echo("Inspect generated files and issue following to request certificate:") click.echo() - click.echo("Inspect newly created %s and start OpenVPN service:" % config.name) + click.echo(" certidude request") click.echo() - click.echo(" service openvpn restart") + click.echo("As OpenVPN server certificate needs specific key usage extensions please") + click.echo("use following command to sign on Certidude server instead of web interface:") click.echo() + click.echo(" certidude sign %s" % const.HOSTNAME) @click.command("server", help="Set up strongSwan server") @click.argument("server") -@click.option("--common-name", "-cn", default=FQDN, help="Common name, %s by default" % FQDN) @click.option("--org-unit", "-ou", help="Organizational unit") -@click.option("--fqdn", "-f", default=FQDN, help="Fully qualified hostname associated with the certificate") @click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, %s by default" % EMAIL) @click.option("--subnet", "-sn", default=u"192.168.33.0/24", type=ip_network, help="IPsec virtual subnet, 192.168.33.0/24 by default") @click.option("--local", "-l", type=ip_address, help="IP address associated with the certificate, none by default") @click.option("--route", "-r", type=ip_network, multiple=True, help="Subnets to advertise via this connection, multiple allowed") -@click.option("--config", "-o", - default="/etc/ipsec.conf", - type=click.File(mode="w", atomic=True, lazy=True), - help="strongSwan configuration file, /etc/ipsec.conf by default") -@click.option("--secrets", "-s", - default="/etc/ipsec.secrets", - type=click.File(mode="w", atomic=True, lazy=True), - help="strongSwan secrets file, /etc/ipsec.secrets by default") -@click.option("--directory", "-d", default="/etc/ipsec.d", help="Directory for keys, /etc/ipsec.d by default") -@click.option("--key-path", "-key", default="private/%s.pem" % HOSTNAME, help="Key path, private/%s.pem by default" % HOSTNAME) -@click.option("--request-path", "-csr", default="reqs/%s.pem" % HOSTNAME, help="Request path, reqs/%s.pem by default" % HOSTNAME) -@click.option("--certificate-path", "-crt", default="certs/%s.pem" % HOSTNAME, help="Certificate path, certs/%s.pem by default" % HOSTNAME) -@click.option("--authority-path", "-ca", default="cacerts/ca.pem", help="Certificate authority certificate path, cacerts/ca.pem by default") -@click.option("--revocations-path", "-crl", default="crls/ca.pem", help="Certificate revocation list, crls/ca.pem by default") -@expand_paths() -def certidude_setup_strongswan_server(server, config, secrets, subnet, route, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, revocations_path, local, fqdn): +def certidude_setup_strongswan_server(authority, config, secrets, subnet, route, email_address, org_unit, local, fqdn): if "." not in common_name: raise ValueError("Hostname has to be fully qualified!") if not local: raise ValueError("Please specify local IP address") - if not os.path.exists(certificate_path): - click.echo("As strongSwan server certificate needs specific key usage extensions please") - click.echo("use following command to sign on Certidude server instead of web interface:") - click.echo() - click.echo(" certidude sign %s" % common_name) - click.echo() + # Create corresponding section in Certidude client configuration file + client_config = ConfigParser() + if os.path.exists(const.CLIENT_CONFIG_PATH): + client_config.readfp(open(const.CLIENT_CONFIG_PATH)) + if client_config.has_section(server): + click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) + else: + client_config.set(authority, "trigger", "interface up") + client_config.set(authority, "common name", const.FQDN) + client_config.set(authority, "subject alternative name dns", const.FQDN) + client_config.set(authority, "extended key usage flags", "server auth, ike intermediate") + client_config.set(authority, "request path", "/etc/ipsec.d/reqs/%s.pem" % const.HOSTNAME) + client_config.set(authority, "key path", "/etc/ipsec.d/private/%s.pem" % const.HOSTNAME) + client_config.set(authority, "certificate path", "/etc/ipsec.d/certs/%s.pem" % const.HOSTNAME) + client_config.set(authority, "authority path", "/etc/ipsec.d/cacerts/ca.pem") + client_config.set(authority, "authority path", "/etc/ipsec.d/crls/ca.pem") + with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: + client_config.write(fh) + os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) + click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) - retval = certidude_request_certificate(server, - key_path, request_path, certificate_path, authority_path, revocations_path, - common_name, org_unit, email_address, - key_usage="digitalSignature,keyEncipherment", - extended_key_usage="serverAuth,1.3.6.1.5.5.8.2.2", - dns=fqdn, - wait=True) + # Create corresponding section to /etc/ipsec.conf + from ipsecparse import loads + config = loads(open('/etc/ipsec.conf').read()) + config["conn", server] = dict( + leftsourceip="%config", + left=common_name, + leftcert=certificate_path, + leftsubnet=route.join(", "), + right="%any", + rightsourceip=subnet, + keyexchange="ikev2", + keyingtries="300", + dpdaction=dpdaction, + closeaction="restart", + auto="ignore") + with open("/etc/ipsec.conf.part", "w") as fh: + fh.write(client_config.dumps()) + os.rename("/etc/ipsec.conf.part", "/etc/ipsec.conf") - if retval: - return retval - - config.write(env.get_template("strongswan-site-to-client.conf").render(vars())) - secrets.write(": RSA %s\n" % key_path) - - click.echo("Generated %s and %s" % (config.name, secrets.name)) - click.echo() - click.echo("Inspect newly created %s and start strongSwan service:" % config.name) - click.echo() - click.echo(" apt-get install strongswan strongswan-starter strongswan-ikev2") - click.secho(" service strongswan restart", bold=True) click.echo() click.echo("If you're running Ubuntu make sure you're not affected by #1505222") click.echo("https://bugs.launchpad.net/ubuntu/+source/strongswan/+bug/1505222") @@ -566,130 +620,137 @@ def certidude_setup_strongswan_server(server, config, secrets, subnet, route, em @click.command("client", help="Set up strongSwan client") @click.argument("server") @click.argument("remote") -@click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, %s by default" % HOSTNAME) @click.option("--org-unit", "-ou", help="Organizational unit") -@click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, '%s' by default" % EMAIL) -@click.option("--config", "-o", - default="/etc/ipsec.conf", - type=click.File(mode="w", atomic=True, lazy=True), - help="strongSwan configuration file, /etc/ipsec.conf by default") -@click.option("--secrets", "-s", - default="/etc/ipsec.secrets", - type=click.File(mode="w", atomic=True, lazy=True), - help="strongSwan secrets file, /etc/ipsec.secrets by default") -@click.option("--dpdaction", "-d", - default="restart", - type=click.Choice(["none", "clear", "hold", "restart"]), - help="Action upon dead peer detection; either none, clear, hold or restart") -@click.option("--auto", "-a", - default="start", - type=click.Choice(["ignore", "add", "route", "start"]), - help="Operation at startup; either ignore, add, route or start") -@click.option("--directory", "-d", default="/etc/ipsec.d", help="Directory for keys, /etc/ipsec.d by default") -@click.option("--key-path", "-key", default="private/%s.pem" % HOSTNAME, help="Key path, private/%s.pem by default" % HOSTNAME) -@click.option("--request-path", "-csr", default="reqs/%s.pem" % HOSTNAME, help="Request path, reqs/%s.pem by default" % HOSTNAME) -@click.option("--certificate-path", "-crt", default="certs/%s.pem" % HOSTNAME, help="Certificate path, certs/%s.pem by default" % HOSTNAME) -@click.option("--authority-path", "-ca", default="cacerts/ca.pem", help="Certificate authority certificate path, cacerts/ca.pem by default") -@click.option("--revocations-path", "-crl", default="crls/ca.pemf", help="Certificate revocation list, ca.crl relative to -d by default") -@expand_paths() -def certidude_setup_strongswan_client(server, config, secrets, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, remote, auto, dpdaction): - retval = certidude_request_certificate(server, - key_path, request_path, certificate_path, authority_path, - common_name, org_unit, email_address, - wait=True) +def certidude_setup_strongswan_client(authority, config, org_unit, remote, dpdaction): + # Create corresponding section in /etc/certidude/client.conf + client_config = ConfigParser() + if os.path.exists(const.CLIENT_CONFIG_PATH): + client_config.readfp(open(const.CLIENT_CONFIG_PATH)) + if client_config.has_section(server): + click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) + else: + client_config.add_section(authority) + client_config.set(authority, "trigger", "interface up") + client_config.set(authority, "common name", const.HOSTNAME) + client_config.set(authority, "request path", "/etc/ipsec.d/reqs/%s.pem" % const.HOSTNAME) + client_config.set(authority, "key path", "/etc/ipsec.d/private/%s.pem" % const.HOSTNAME) + client_config.set(authority, "certificate path", "/etc/ipsec.d/certs/%s.pem" % const.HOSTNAME) + client_config.set(authority, "authority path", "/etc/ipsec.d/cacerts/ca.pem") + client_config.set(authority, "authority path", "/etc/ipsec.d/crls/ca.pem") + with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: + client_config.write(fh) + os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) + click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) - if retval: - return retval + # Create corresponding section in /etc/ipsec.conf + from ipsecparse import loads + config = loads(open('/etc/ipsec.conf').read()) + config["conn", server] = dict( + leftsourceip="%config", + left="%defaultroute", + leftcert=certificate_path, + rightid="%any", + right=remote, + rightsubnet=route, + keyexchange="ikev2", + keyingtries="300", + dpdaction="restart", + closeaction="restart", + auto="ignore") + with open("/etc/ipsec.conf.part", "w") as fh: + fh.write(client_config.dumps()) + os.rename("/etc/ipsec.conf.part", "/etc/ipsec.conf") - # TODO: Add dhparam - config.write(env.get_template("strongswan-client-to-site.conf").render(vars())) - secrets.write(": RSA %s\n" % key_path) - - click.echo("Generated %s and %s" % (config.name, secrets.name)) - click.echo() - click.echo("Inspect newly created %s and start strongSwan service:" % config.name) - click.echo() - click.echo(" apt-get install strongswan strongswan-starter") - click.echo(" service strongswan restart") - click.echo() + click.echo("Generated section %s in %s" % (authority, client_config.name)) + click.echo("Run 'certidude request' to request certificates and to enable services") @click.command("networkmanager", help="Set up strongSwan client via NetworkManager") @click.argument("server") # Certidude server @click.argument("remote") # StrongSwan gateway -@click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, %s by default" % HOSTNAME) @click.option("--org-unit", "-ou", help="Organizational unit") -@click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, '%s' by default" % EMAIL) -@click.option("--directory", "-d", default="/etc/ipsec.d", help="Directory for keys, /etc/ipsec.d by default") -@click.option("--key-path", "-key", default="private/%s.pem" % HOSTNAME, help="Key path, private/%s.pem by default" % HOSTNAME) -@click.option("--request-path", "-csr", default="reqs/%s.pem" % HOSTNAME, help="Request path, reqs/%s.pem by default" % HOSTNAME) -@click.option("--certificate-path", "-crt", default="certs/%s.pem" % HOSTNAME, help="Certificate path, certs/%s.pem by default" % HOSTNAME) -@click.option("--authority-path", "-ca", default="cacerts/ca.pem", help="Certificate authority certificate path, cacerts/ca.pem by default") -@click.option("--revocations-path", "-crl", default="crls/ca.pem", help="Certificate revocation list, crls/ca.pem by default") -@expand_paths() -def certidude_setup_strongswan_networkmanager(server, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, revocations_path, remote): - retval = certidude_request_certificate(server, - key_path, request_path, certificate_path, authority_path, revocations_path, - common_name, org_unit, email_address, - wait=True) - - if retval: - return retval - - services = ConfigParser() - if os.path.exists("/etc/certidude/services.conf"): - services.readfp(open("/etc/certidude/services.conf")) - +def certidude_setup_strongswan_networkmanager(server,remote, org_unit): endpoint = "IPSec to %s" % remote - if services.has_section(endpoint): - click.echo("Section %s already exists in /etc/certidude/services.conf, not reconfiguring" % endpoint) + # Create corresponding section in /etc/certidude/client.conf + client_config = ConfigParser() + if os.path.exists(const.CLIENT_CONFIG_PATH): + client_config.readfp(open(const.CLIENT_CONFIG_PATH)) + if client_config.has_section(server): + click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) else: - click.echo("Section %s added to /etc/certidude/client.conf" % endpoint) - services.add_section(endpoint) - services.set(endpoint, "authority", server) - services.set(endpoint, "remote", remote) - services.set(endpoint, "service", "network-manager/strongswan") - services.write(open("/etc/certidude/services.conf", "w")) + client_config.add_section(authority) + client_config.set(authority, "trigger", "interface up") + client_config.set(authority, "common name", const.HOSTNAME) + client_config.set(authority, "org unit", org_unit) + client_config.set(authority, "request path", "/etc/ipsec.d/reqs/%s.pem" % const.HOSTNAME) + client_config.set(authority, "key path", "/etc/ipsec.d/private/%s.pem" % const.HOSTNAME) + client_config.set(authority, "certificate path", "/etc/ipsec.d/certs/%s.pem" % const.HOSTNAME) + client_config.set(authority, "authority path", "/etc/ipsec.d/cacerts/ca.pem") + client_config.set(authority, "authority path", "/etc/ipsec.d/crls/ca.pem") + with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: + client_config.write(fh) + os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) + click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) + + # Create corresponding section in /etc/certidude/services.conf + service_config = ConfigParser() + if os.path.exists(const.SERVICES_CONFIG_PATH): + service_config.readfp(open(const.SERVICES_CONFIG_PATH)) + if service_config.has_section(endpoint): + click.echo("Section '%s' already exists in %s, remove to regenerate" % (endpoint, const.SERVICES_CONFIG_PATH)) + else: + service_config.add_section(endpoint) + service_config.set(authority, "authority", server) + service_config.set(authority, "remote", remote) + service_config.set(authority, "service", "network-manager/strongswan-client") + with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh: + service_config.write(fh) + os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH) + click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH)) @click.command("networkmanager", help="Set up OpenVPN client via NetworkManager") @click.argument("server") # Certidude server @click.argument("remote") # OpenVPN gateway -@click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, %s by default" % HOSTNAME) +@click.option("--common-name", "-cn", default=const.HOSTNAME, help="Common name, %s by default" % const.HOSTNAME) @click.option("--org-unit", "-ou", help="Organizational unit") @click.option("--email-address", "-m", help="E-mail associated with the request, none by default") -@click.option("--directory", "-d", default="/etc/openvpn/keys", help="Directory for keys, /etc/openvpn/keys by default") -@click.option("--key-path", "-key", default=HOSTNAME + ".key", help="Key path, %s.key relative to -d by default" % HOSTNAME) -@click.option("--request-path", "-csr", default=HOSTNAME + ".csr", help="Request path, %s.csr relative to -d by default" % HOSTNAME) -@click.option("--certificate-path", "-crt", default=HOSTNAME + ".crt", help="Certificate path, %s.crt relative to -d by default" % HOSTNAME) -@click.option("--authority-path", "-ca", default="ca.crt", help="Certificate path, ca.crt relative to -d by default") -@click.option("--revocations-path", "-crl", default="ca.crl", help="Certificate revocation list, ca.crl by default") -@expand_paths() -def certidude_setup_openvpn_networkmanager(server, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, revocations_path, remote): - retval = certidude_request_certificate(server, - key_path, request_path, certificate_path, authority_path, revocations_path, - common_name, org_unit, email_address, - wait=True) - - if retval: - return retval - - services = ConfigParser() - if os.path.exists("/etc/certidude/services.conf"): - services.readfp(open("/etc/certidude/services.conf")) +def certidude_setup_openvpn_networkmanager(authority, email_address, org_unit, remote): + # Create corresponding section in /etc/certidude/client.conf + client_config = ConfigParser() + if os.path.exists(const.CLIENT_CONFIG_PATH): + client_config.readfp(open(const.CLIENT_CONFIG_PATH)) + if client_config.has_section(server): + click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) + else: + client_config.add_section(authority) + client_config.set(authority, "trigger", "interface up") + client_config.set(authority, "common name", const.HOSTNAME) + client_config.set(authority, "request path", "/etc/ipsec.d/reqs/%s.pem" % const.HOSTNAME) + client_config.set(authority, "key path", "/etc/ipsec.d/private/%s.pem" % const.HOSTNAME) + client_config.set(authority, "certificate path", "/etc/ipsec.d/certs/%s.pem" % const.HOSTNAME) + client_config.set(authority, "authority path", "/etc/ipsec.d/cacerts/ca.pem") + client_config.set(authority, "authority path", "/etc/ipsec.d/crls/ca.pem") + with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: + client_config.write(fh) + os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) + click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) endpoint = "OpenVPN to %s" % remote - if services.has_section(endpoint): - click.echo("Section %s already exists in /etc/certidude/services.conf, not reconfiguring" % endpoint) + service_config = ConfigParser() + if os.path.exists(const.SERVICES_CONFIG_PATH): + service_config.readfp(open(const.SERVICES_CONFIG_PATH)) + if service_config.has_section(endpoint): + click.echo("Section '%s' already exists in %s, remove to regenerate" % (endpoint, const.SERVICES_CONFIG_PATH)) else: + service_config.add_section(endpoint) + service_config.set(authority, "authority", server) + service_config.set(endpoint, "remote", remote) + service_config.set(endpoint, "service", "network-manager/openvpn") + service_config.write(open("/etc/certidude/services.conf", "w")) click.echo("Section %s added to /etc/certidude/client.conf" % endpoint) - services.add_section(endpoint) - services.set(endpoint, "authority", server) - services.set(endpoint, "remote", remote) - services.set(endpoint, "service", "network-manager/openvpn") - services.write(open("/etc/certidude/services.conf", "w")) @click.command("authority", help="Set up Certificate Authority in a directory") @@ -700,12 +761,8 @@ def certidude_setup_openvpn_networkmanager(server, email_address, common_name, o default="/etc/nginx/sites-available/certidude.conf", type=click.File(mode="w", atomic=True, lazy=True), help="nginx site config for serving Certidude, /etc/nginx/sites-available/certidude by default") -@click.option("--uwsgi-config", "-u", - default="/etc/uwsgi/apps-available/certidude.ini", - type=click.File(mode="w", atomic=True, lazy=True), - help="uwsgi configuration for serving Certidude API, /etc/uwsgi/apps-available/certidude.ini by default") @click.option("--parent", "-p", help="Parent CA, none by default") -@click.option("--common-name", "-cn", default=FQDN, help="Common name, fully qualified hostname by default") +@click.option("--common-name", "-cn", default=const.FQDN, help="Common name, fully qualified hostname by default") @click.option("--country", "-c", default=None, help="Country, none by default") @click.option("--state", "-s", default=None, help="State or country, none by default") @click.option("--locality", "-l", default=None, help="City or locality, none by default") @@ -716,12 +773,20 @@ def certidude_setup_openvpn_networkmanager(server, email_address, common_name, o @click.option("--organizational-unit", "-ou", default=None) @click.option("--revoked-url", default=None, help="CRL distribution URL") @click.option("--certificate-url", default=None, help="Authority certificate URL") -@click.option("--push-server", default="http://" + constants.FQDN, help="Push server, by default http://%s" % constants.FQDN) -@click.option("--email-address", default="certidude@" + FQDN, help="E-mail address of the CA") -@click.option("--directory", default=os.path.join("/var/lib/certidude", FQDN), help="Directory for authority files, /var/lib/certidude/%s/ by default" % FQDN) +@click.option("--push-server", default="http://" + const.FQDN, help="Push server, by default http://%s" % const.FQDN) +@click.option("--email-address", default="certidude@" + const.FQDN, help="E-mail address of the CA") +@click.option("--directory", help="Directory for authority files") @click.option("--server-flags", is_flag=True, help="Add TLS Server and IKE Intermediate extended key usage flags") -@click.option("--outbox", default="smtp://smtp.%s" % constants.DOMAIN, help="SMTP server, smtp://smtp.%s by default" % constants.DOMAIN) -def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_config, uwsgi_config, parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, revoked_url, certificate_url, push_server, email_address, outbox, server_flags): +@click.option("--outbox", default="smtp://smtp.%s" % const.DOMAIN, help="SMTP server, smtp://smtp.%s by default" % const.DOMAIN) +def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_config, parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, revoked_url, certificate_url, push_server, email_address, outbox, server_flags): + + if not directory: + if os.getuid(): + directory = os.path.join(os.path.expanduser("~/.certidude"), const.FQDN) + else: + directory = os.path.join("/var/lib/certidude", const.FQDN) + + click.echo("Using fully qualified hostname: %s" % common_name) # Expand variables if not revoked_url: @@ -730,173 +795,182 @@ def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_conf certificate_url = "http://%s/api/certificate/" % common_name ca_key = os.path.join(directory, "ca_key.pem") ca_crt = os.path.join(directory, "ca_crt.pem") + if not static_path.endswith("/"): static_path += "/" - certidude_conf = os.path.join("/etc/certidude/server.conf") - try: - pwd.getpwnam("certidude") - except KeyError: - cmd = "adduser", "--system", "--no-create-home", "--group", "certidude" - if subprocess.call(cmd): - click.echo("Failed to create system user 'certidude'") - return 255 + if os.getuid() == 0: + try: + pwd.getpwnam("certidude") + except KeyError: + cmd = "adduser", "--system", "--no-create-home", "--group", "certidude" + if subprocess.call(cmd): + click.echo("Failed to create system user 'certidude'") + return 255 - if not os.path.exists("/etc/certidude"): - click.echo("Creating /etc/certidude") - os.makedirs("/etc/certidude") + if os.path.exists(kerberos_keytab): + click.echo("Service principal keytab found in '%s'" % kerberos_keytab) + else: + click.echo("To use 'kerberos' authentication backend join the domain and create service principal with:") + click.echo() + click.echo(" KRB5_KTNAME=FILE:%s net ads keytab add HTTP -P" % kerberos_keytab) + click.echo(" chown %s %s" % (username, kerberos_keytab)) + click.echo() - if os.path.exists(kerberos_keytab): - click.echo("Service principal keytab found in '%s'" % kerberos_keytab) + if os.path.exists("/etc/krb5.keytab") and os.path.exists("/etc/samba/smb.conf"): + # Fetch Kerberos ticket for system account + cp = ConfigParser() + cp.read("/etc/samba/smb.conf") + realm = cp.get("global", "realm") + domain = realm.lower() + name = cp.get("global", "netbios name") + + base = ",".join(["dc=" + j for j in domain.split(".")]) + with open("/etc/cron.hourly/certidude", "w") as fh: + fh.write(env.get_template("ldap-ticket-renewal.sh").render(vars())) + os.chmod("/etc/cron.hourly/certidude", 0o755) + click.echo("Created /etc/cron.hourly/certidude for automatic LDAP service ticket renewal, inspect and adjust accordingly") + os.system("/etc/cron.hourly/certidude") + else: + click.echo("Warning: /etc/krb5.keytab or /etc/samba/smb.conf not found, Kerberos unconfigured") + + + working_directory = os.path.realpath(os.path.dirname(__file__)) + certidude_path = sys.argv[0] + + if not os.path.exists("/etc/nginx"): + click.echo("Directory /etc/nginx does not exist, hence not creating nginx configuration") + listen = "0.0.0.0" + port = "80" + else: + nginx_client_config.write(env.get_template("nginx.conf").render(vars())) + click.echo("Generated: %s" % nginx_client_config.name) + if not os.path.exists("/etc/nginx/sites-enabled/certidude.conf"): + os.symlink("../sites-available/certidude.conf", "/etc/nginx/sites-enabled/certidude.conf") + click.echo("Symlinked %s -> /etc/nginx/sites-enabled/" % nginx_client_config.name) + if os.path.exists("/etc/nginx/sites-enabled/default"): + os.unlink("/etc/nginx/sites-enabled/default") + if not push_server: + click.echo("Remember to install nchan instead of regular nginx!") + + if os.path.exists("/etc/systemd"): + if os.path.exists("/etc/systemd/system/certidude.service"): + click.echo("File /etc/systemd/system/certidude.service already exists, remove to regenerate") + else: + with open("/etc/systemd/system/certidude.service", "w") as fh: + fh.write(env.get_template("systemd.service").render(vars())) + click.echo("File /etc/systemd/system/certidude.service created") + else: + NotImplemented # No systemd + + _, _, uid, gid, gecos, root, shell = pwd.getpwnam("certidude") + os.setgid(gid) else: - click.echo("To use 'kerberos' authentication backend create service principal with:") - click.echo() - click.echo(" KRB5_KTNAME=FILE:%s net ads keytab add HTTP -P" % kerberos_keytab) - click.echo(" chown %s %s" % (username, kerberos_keytab)) - click.echo() + click.echo("Not root, skipping user and system config creation") - if os.path.exists("/etc/krb5.keytab") and os.path.exists("/etc/samba/smb.conf"): + if not os.path.exists(const.CONFIG_DIR): + click.echo("Creating %s" % const.CONFIG_DIR) + os.makedirs(const.CONFIG_DIR) - # Fetch Kerberos ticket for system account - cp = ConfigParser() - cp.read("/etc/samba/smb.conf") - domain = cp.get("global", "realm").lower() - base = ",".join(["dc=" + j for j in domain.split(".")]) - with open("/etc/cron.hourly/certidude", "w") as fh: - fh.write("#!/bin/bash\n") - fh.write("KRB5CCNAME=/run/certidude/krb5cc-new kinit -k %s$\n" % cp.get("global", "netbios name")) - fh.write("chown certidude /run/certidude/krb5cc-new\n") - fh.write("mv /run/certidude/krb5cc-new /run/certidude/krb5cc\n") - os.chmod("/etc/cron.hourly/certidude", 0o755) - click.echo("Created /etc/cron.hourly/certidude for automatic Kerberos TGT renewal") - else: - click.echo("Warning: /etc/krb5.keytab or /etc/samba/smb.conf not found, Kerberos unconfigured") - - nginx_config.write(env.get_template("nginx.conf").render(vars())) - click.echo("Generated: %s" % nginx_config.name) - uwsgi_config.write(env.get_template("uwsgi.ini").render(vars())) - click.echo("Generated: %s" % uwsgi_config.name) - - if not os.path.exists("/etc/nginx/sites-enabled/certidude.conf"): - os.symlink("../sites-available/certidude.conf", "/etc/nginx/sites-enabled/certidude.conf") - click.echo("Symlinked %s -> /etc/nginx/sites-enabled/" % nginx_config.name) - if not os.path.exists("/etc/uwsgi/apps-enabled/certidude.ini"): - os.symlink("../apps-available/certidude.ini", "/etc/uwsgi/apps-enabled/certidude.ini") - click.echo("Symlinked %s -> /etc/uwsgi/apps-enabled/" % uwsgi_config.name) - if os.path.exists("/etc/nginx/sites-enabled/default"): - os.unlink("/etc/nginx/sites-enabled/default") - - - if not push_server: - click.echo("Remember to install nchan instead of regular nginx!") - - from cryptography import x509 - from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import rsa - - _, _, uid, gid, gecos, root, shell = pwd.getpwnam("certidude") - os.setgid(gid) - - if os.path.exists(certidude_conf): - click.echo("Configuration file %s already exists, remove to regenerate" % certidude_conf) + if os.path.exists(const.CONFIG_PATH): + click.echo("Configuration file %s already exists, remove to regenerate" % const.CONFIG_PATH) else: os.umask(0o137) - with open(certidude_conf, "w") as fh: - fh.write(env.get_template("certidude.conf").render(vars())) - click.echo("Generated %s" % certidude_conf) + push_token = "".join([random.choice(string.ascii_letters + string.digits) for j in range(0,32)]) + with open(const.CONFIG_PATH, "w") as fh: + fh.write(env.get_template("certidude-server.conf").render(vars())) + click.echo("Generated %s" % const.CONFIG_PATH) if os.path.lexists(directory): - raise click.ClickException("CA directory %s already exists, remove to regenerate" % directory) + click.echo("CA directory %s already exists, remove to regenerate" % directory) + else: + click.echo("CA configuration files are saved to: {}".format(directory)) - click.echo("CA configuration files are saved to: {}".format(directory)) - - click.echo("Generating 4096-bit RSA key...") - - key = rsa.generate_private_key( - public_exponent=65537, - key_size=4096, - backend=default_backend() - ) - - subject = issuer = x509.Name([ - x509.NameAttribute(o, value) for o, value in ( - (NameOID.COUNTRY_NAME, country), - (NameOID.STATE_OR_PROVINCE_NAME, state), - (NameOID.LOCALITY_NAME, locality), - (NameOID.ORGANIZATION_NAME, organization), - (NameOID.COMMON_NAME, common_name), - ) if value - ]) - - builder = x509.CertificateBuilder( - ).subject_name(subject - ).issuer_name(issuer - ).public_key(key.public_key() - ).not_valid_before(datetime.utcnow() - ).not_valid_after( - datetime.utcnow() + timedelta(days=authority_lifetime) - ).serial_number(1 - ).add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True, - ).add_extension(x509.KeyUsage( - digital_signature=True, - key_encipherment=False, - content_commitment=False, - data_encipherment=False, - key_agreement=False, - key_cert_sign=True, - crl_sign=True, - encipher_only=False, - decipher_only=False), critical=True, - ).add_extension( - x509.SubjectAlternativeName([ - x509.DNSName(common_name), - x509.RFC822Name(email_address) - ]), - critical=False, - ).add_extension( - x509.SubjectKeyIdentifier.from_public_key(key.public_key()), - critical=False - ).add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), - critical=False + click.echo("Generating 4096-bit RSA key...") + key = rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + backend=default_backend() ) - if server_flags: - builder = builder.add_extension(x509.ExtendedKeyUsage([ - ExtendedKeyUsageOID.SERVER_AUTH, - x509.ObjectIdentifier("1.3.6.1.5.5.8.2.2")]), critical=False) + subject = issuer = x509.Name([ + x509.NameAttribute(o, value) for o, value in ( + (NameOID.COUNTRY_NAME, country), + (NameOID.STATE_OR_PROVINCE_NAME, state), + (NameOID.LOCALITY_NAME, locality), + (NameOID.ORGANIZATION_NAME, organization), + (NameOID.COMMON_NAME, common_name), + ) if value + ]) - cert = builder.sign(key, hashes.SHA512(), default_backend()) + builder = x509.CertificateBuilder( + ).subject_name(subject + ).issuer_name(issuer + ).public_key(key.public_key() + ).not_valid_before(datetime.utcnow() + ).not_valid_after( + datetime.utcnow() + timedelta(days=authority_lifetime) + ).serial_number(1 + ).add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True, + ).add_extension(x509.KeyUsage( + digital_signature=True, + key_encipherment=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False), critical=True, + ).add_extension( + x509.SubjectAlternativeName([ + x509.DNSName(common_name), + x509.RFC822Name(email_address) + ]), + critical=False, + ).add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), + critical=False + ).add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), + critical=False - click.echo("Signing %s..." % cert.subject) + ) - # Create authority directory with 750 permissions - os.umask(0o027) - if not os.path.exists(directory): - os.makedirs(directory) + if server_flags: + builder = builder.add_extension(x509.ExtendedKeyUsage([ + ExtendedKeyUsageOID.SERVER_AUTH, + x509.ObjectIdentifier("1.3.6.1.5.5.8.2.2")]), critical=False) - # Create subdirectories with 770 permissions - os.umask(0o007) - for subdir in ("signed", "requests", "revoked", "expired"): - if not os.path.exists(os.path.join(directory, subdir)): - os.mkdir(os.path.join(directory, subdir)) + cert = builder.sign(key, hashes.SHA512(), default_backend()) - # Set permission bits to 640 - os.umask(0o137) - with open(ca_crt, "wb") as fh: - fh.write(cert.public_bytes(serialization.Encoding.PEM)) + click.echo("Signing %s..." % cert.subject) - # Set permission bits to 600 - os.umask(0o177) - with open(ca_key, "wb") as fh: - fh.write(key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() # TODO: Implement passphrase - )) + # Create authority directory with 750 permissions + os.umask(0o027) + if not os.path.exists(directory): + os.makedirs(directory) + + # Create subdirectories with 770 permissions + os.umask(0o007) + for subdir in ("signed", "requests", "revoked", "expired"): + if not os.path.exists(os.path.join(directory, subdir)): + os.mkdir(os.path.join(directory, subdir)) + + # Set permission bits to 640 + os.umask(0o137) + with open(ca_crt, "wb") as fh: + fh.write(cert.public_bytes(serialization.Encoding.PEM)) + + # Set permission bits to 600 + os.umask(0o177) + with open(ca_key, "wb") as fh: + fh.write(key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() # TODO: Implement passphrase + )) click.echo() click.echo("Use following commands to inspect the newly created files:") @@ -905,10 +979,6 @@ def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_conf click.echo(" openssl rsa -check -in %s" % ca_key) click.echo(" openssl verify -CAfile %s %s" % (ca_crt, ca_crt)) click.echo() - click.echo("Use following to launch privilege isolated signer processes:") - click.echo() - click.echo(" certidude signer spawn") - click.echo() click.echo("Use following command to serve CA read-only:") click.echo() click.echo(" certidude serve") @@ -922,7 +992,7 @@ def certidude_users(): print "%s;%s;%s;%s;%s" % ( "admin" if user in admins else "user", user.name, user.given_name, user.surname, user.mail) - + @click.command("list", help="List certificates") @click.option("--verbose", "-v", default=False, is_flag=True, help="Verbose output") @@ -941,8 +1011,8 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign # r - revoked from certidude import authority - from pycountry import countries + def dump_common(j): person = [j for j in (j.given_name, j.surname) if j] @@ -1053,29 +1123,94 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign @click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN") @click.option("--lifetime", "-l", help="Lifetime") def certidude_sign(common_name, overwrite, lifetime): - from certidude import authority + from certidude import authority, config request = authority.get_request(common_name) + + # Use signer if this is regular client CSR if request.signable: # Sign via signer process cert = authority.sign(request) + + # Sign directly if it's eg. TLS server CSR else: + # Load CA private key and certificate + private_key = serialization.load_pem_private_key( + open(config.AUTHORITY_PRIVATE_KEY_PATH).read(), + password=None, # TODO: Ask password for private key? + backend=default_backend()) + authority_certificate = x509.load_pem_x509_certificate( + open(config.AUTHORITY_CERTIFICATE_PATH).read(), + backend=default_backend()) + + # Drop privileges + # to use LDAP service ticket to read usernames of the admins group + # in order to send e-mail + _, _, uid, gid, gecos, root, shell = pwd.getpwnam("certidude") + os.setgroups([]) + os.setgid(gid) + os.setuid(uid) + # Sign directly using private key - cert = authority.sign2(request, overwrite, True, lifetime) - - click.echo("Signed %s" % cert.identity) - for key, value, data in cert.extensions: - click.echo("Added extension %s: %s" % (key, value)) - click.echo() + cert = authority.sign2(request, private_key, authority_certificate, + overwrite, True, lifetime) -@click.command("serve", help="Run built-in HTTP server") -@click.option("-u", "--user", default="certidude", help="Run as user") -@click.option("-p", "--port", default=80, help="Listen port") +@click.command("serve", help="Run server") +@click.option("-p", "--port", default=8080 if os.getuid() else 80, help="Listen port") @click.option("-l", "--listen", default="0.0.0.0", help="Listen address") -@click.option("-s", "--enable-signature", default=False, is_flag=True, help="Allow signing operations with private key of CA") -def certidude_serve(user, port, listen, enable_signature): +def certidude_serve(port, listen): + from certidude.signer import SignServer + from certidude import const + click.echo("Using configuration from: %s" % const.CONFIG_PATH) + + from certidude import config + # Fetch UID, GID of certidude user + import pwd + _, _, uid, gid, gecos, root, shell = pwd.getpwnam("certidude") + restricted_groups = [] + restricted_groups.append(gid) + + """ + Spawn signer process + """ + + child_pid = os.fork() + + if child_pid: + pass + else: + click.echo("Signer process spawned with PID %d at %s" % (os.getpid(), const.SIGNER_SOCKET_PATH)) + setproctitle("[signer]") + + with open(const.SIGNER_PID_PATH, "w") as fh: + fh.write("%d\n" % os.getpid()) + + logging.basicConfig( + filename=const.SIGNER_LOG_PATH, + level=logging.INFO) + + os.umask(0o007) + server = SignServer() + + # Drop privileges + if not os.getuid(): + os.chown(const.SIGNER_SOCKET_PATH, uid, gid) + os.chmod(const.SIGNER_SOCKET_PATH, 0770) + + click.echo("Dropping privileges of signer") + _, _, uid, gid, gecos, root, shell = pwd.getpwnam("nobody") + os.setgroups([]) + os.setgid(gid) + os.setuid(uid) + else: + click.echo("Not dropping privileges of signer process") + + asyncore.loop() + return + + click.echo("Users subnets: %s" % ", ".join([str(j) for j in config.USER_SUBNETS])) click.echo("Administrative subnets: %s" % @@ -1086,58 +1221,88 @@ def certidude_serve(user, port, listen, enable_signature): ", ".join([str(j) for j in config.REQUEST_SUBNETS])) logging.basicConfig( - filename='/var/log/certidude.log', + filename=const.SERVER_LOG_PATH, level=logging.DEBUG) click.echo("Serving API at %s:%d" % (listen, port)) - import pwd from wsgiref.simple_server import make_server, WSGIServer - from socketserver import ThreadingMixIn + from SocketServer import ThreadingMixIn, ForkingMixIn from certidude.api import certidude_app, StaticResource - class ThreadingWSGIServer(ThreadingMixIn, WSGIServer): + class ThreadingWSGIServer(ForkingMixIn, WSGIServer): pass click.echo("Listening on %s:%d" % (listen, port)) - - # TODO: Bind before dropping privileges, - # but create app (sqlite log files!) after dropping privileges app = certidude_app() - app.add_sink(StaticResource(os.path.join(os.path.dirname(__file__), "static"))) httpd = make_server(listen, port, app, ThreadingWSGIServer) - if user: - # Load required utils which cannot be imported from chroot - # TODO: Figure out better approach - from jinja2.debug import make_traceback as _make_traceback - "".encode("charmap") - restricted_groups = [] + """ + Drop privileges + """ + if os.getuid() == 0: + + # Initialize LDAP service ticket + if os.path.exists("/etc/cron.hourly/certidude"): + os.system("/etc/cron.hourly/certidude") + + # Drop privileges if config.AUTHENTICATION_BACKENDS == {"pam"}: # PAM needs access to /etc/shadow import grp - name, passwd, gid, mem = grp.getgrnam("shadow") + name, passwd, num, mem = grp.getgrnam("shadow") click.echo("Adding current user to shadow group due to PAM authentication backend") - restricted_groups.append(gid) - - _, _, uid, gid, gecos, root, shell = pwd.getpwnam(user) - restricted_groups.append(gid) - + restricted_groups.append(num) os.setgroups(restricted_groups) os.setgid(gid) os.setuid(uid) click.echo("Switched to user %s (uid=%d, gid=%d); member of groups %s" % - (user, os.getuid(), os.getgid(), ", ".join([str(j) for j in os.getgroups()]))) + ("certidude", os.getuid(), os.getgid(), ", ".join([str(j) for j in os.getgroups()]))) os.umask(0o007) - elif os.getuid() == 0: - click.echo("Warning: running as root, this is not recommended!") + + + # Set up log handlers + log_handlers = [] + + if config.LOGGING_BACKEND == "sql": + from certidude.mysqllog import LogHandler + from certidude.api.log import LogResource + uri = config.cp.get("logging", "database") + log_handlers.append(LogHandler(uri)) + app.add_route("/api/log/", LogResource(uri)) + elif config.LOGGING_BACKEND == "syslog": + from logging.handlers import SyslogHandler + log_handlers.append(SysLogHandler()) + # Browsing syslog via HTTP is obviously not possible out of the box + elif config.LOGGING_BACKEND: + raise ValueError("Invalid logging.backend = %s" % config.LOGGING_BACKEND) + + if config.PUSH_PUBLISH: + from certidude.push import PushLogHandler + log_handlers.append(PushLogHandler()) + + for facility in "api", "cli": + logger = logging.getLogger(facility) + logger.setLevel(logging.DEBUG) + for handler in log_handlers: + logger.addHandler(handler) + + import atexit + + def exit_handler(): + logging.getLogger("cli").debug("Shutting down Certidude") + + atexit.register(exit_handler) + + logging.getLogger("cli").debug("Started Certidude at %s", const.FQDN) + print "Ready" httpd.serve_forever() @click.group("strongswan", help="strongSwan helpers") @@ -1152,9 +1317,6 @@ def certidude_setup(): pass @click.group("signer", help="Signer process management") def certidude_signer(): pass -@click.group("request", help="CSR process management") -def certidude_request(): pass - @click.group() def entry_point(): pass @@ -1169,8 +1331,6 @@ certidude_setup.add_command(certidude_setup_openvpn) certidude_setup.add_command(certidude_setup_strongswan) certidude_setup.add_command(certidude_setup_client) certidude_setup.add_command(certidude_setup_nginx) -certidude_request.add_command(certidude_request_spawn) -certidude_signer.add_command(certidude_signer_spawn) entry_point.add_command(certidude_setup) entry_point.add_command(certidude_serve) entry_point.add_command(certidude_signer) @@ -1178,3 +1338,6 @@ entry_point.add_command(certidude_request) entry_point.add_command(certidude_sign) entry_point.add_command(certidude_list) entry_point.add_command(certidude_users) + +if __name__ == "__main__": + entry_point() diff --git a/certidude/config.py b/certidude/config.py index f369d7c..adfd4a8 100644 --- a/certidude/config.py +++ b/certidude/config.py @@ -5,11 +5,13 @@ import configparser import ipaddress import os import string +import const from random import choice -from urllib.parse import urlparse -cp = configparser.ConfigParser() -cp.readfp(codecs.open("/etc/certidude/server.conf", "r", "utf8")) +# Options that are parsed from config file are fetched here + +cp = configparser.RawConfigParser() +cp.readfp(codecs.open(const.CONFIG_PATH, "r", "utf8")) AUTHENTICATION_BACKENDS = set([j for j in cp.get("authentication", "backends").split(" ") if j]) # kerberos, pam, ldap @@ -28,9 +30,6 @@ AUTOSIGN_SUBNETS = set([ipaddress.ip_network(j) for j in REQUEST_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "request subnets").split(" ") if j]).union(AUTOSIGN_SUBNETS) -SIGNER_SOCKET_PATH = "/run/certidude/signer.sock" -SIGNER_PID_PATH = "/run/certidude/signer.pid" - AUTHORITY_DIR = "/var/lib/certidude" AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path") AUTHORITY_CERTIFICATE_PATH = cp.get("authority", "certificate path") @@ -49,16 +48,13 @@ USER_MULTIPLE_CERTIFICATES = { CERTIFICATE_BASIC_CONSTRAINTS = "CA:FALSE" CERTIFICATE_KEY_USAGE_FLAGS = "digitalSignature,keyEncipherment" CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS = "clientAuth" -CERTIFICATE_LIFETIME = int(cp.get("signature", "certificate lifetime")) +CERTIFICATE_LIFETIME = cp.getint("signature", "certificate lifetime") CERTIFICATE_AUTHORITY_URL = cp.get("signature", "certificate url") CERTIFICATE_CRL_URL = cp.get("signature", "revoked url") -REVOCATION_LIST_LIFETIME = int(cp.get("signature", "revocation list lifetime")) - -PUSH_TOKEN = "".join([choice(string.ascii_letters + string.digits) for j in range(0,32)]) - -PUSH_TOKEN = "ca" +REVOCATION_LIST_LIFETIME = cp.getint("signature", "revocation list lifetime") +PUSH_TOKEN = cp.get("push", "token") PUSH_EVENT_SOURCE = cp.get("push", "event source") PUSH_LONG_POLL = cp.get("push", "long poll") PUSH_PUBLISH = cp.get("push", "publish") diff --git a/certidude/const.py b/certidude/const.py new file mode 100644 index 0000000..33442ce --- /dev/null +++ b/certidude/const.py @@ -0,0 +1,24 @@ + +import click +import os +import socket + +CONFIG_DIR = os.path.expanduser("~/.certidude") if os.getuid() else "/etc/certidude" +CONFIG_PATH = os.path.join(CONFIG_DIR, "server.conf") + +CLIENT_CONFIG_PATH = os.path.join(CONFIG_DIR, "client.conf") +SERVICES_CONFIG_PATH = os.path.join(CONFIG_DIR, "services.conf") +SERVER_LOG_PATH = os.path.join(CONFIG_DIR, "server.log") if os.getuid() else "/var/log/certidude-server.log" +SIGNER_SOCKET_PATH = os.path.join(CONFIG_DIR, "signer.sock") if os.getuid() else "/run/certidude/signer.sock" +SIGNER_PID_PATH = os.path.join(CONFIG_DIR, "signer.pid") if os.getuid() else "/run/certidude/signer.pid" +SIGNER_LOG_PATH = os.path.join(CONFIG_DIR, "signer.log") if os.getuid() else "/var/log/certidude-signer.log" + +FQDN = socket.getaddrinfo(socket.gethostname(), 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME)[0][3] + +if "." in FQDN: + HOSTNAME, DOMAIN = FQDN.split(".", 1) +else: + HOSTNAME, DOMAIN = FQDN, "local" + click.echo("Unable to determine domain of this computer, falling back to local") + +EXTENSION_WHITELIST = set(["subjectAltName"]) diff --git a/certidude/constants.py b/certidude/constants.py deleted file mode 100644 index 86cbf04..0000000 --- a/certidude/constants.py +++ /dev/null @@ -1,13 +0,0 @@ - -import click -import socket - -FQDN = socket.getaddrinfo(socket.gethostname(), 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME)[0][3] - -if "." in FQDN: - HOSTNAME, DOMAIN = FQDN.split(".", 1) -else: - HOSTNAME, DOMAIN = FQDN, "local" - click.echo("Unable to determine domain of this computer, falling back to local") - -EXTENSION_WHITELIST = set(["subjectAltName"]) diff --git a/certidude/decorators.py b/certidude/decorators.py index cf7b057..20b24cf 100644 --- a/certidude/decorators.py +++ b/certidude/decorators.py @@ -8,7 +8,7 @@ from datetime import date, time, datetime from OpenSSL import crypto from certidude.auth import User from certidude.wrappers import Request, Certificate -from urllib.parse import urlparse +from urlparse import urlparse logger = logging.getLogger("api") @@ -23,15 +23,21 @@ def csrf_protection(func): # For everything else assert referrer referrer = req.headers.get("REFERER") + + if referrer: scheme, netloc, path, params, query, fragment = urlparse(referrer) - if netloc == req.host: + if ":" in netloc: + host, port = netloc.split(":", 1) + else: + host, port = netloc, None + if host == req.host: return func(self, req, resp, *args, **kwargs) # Kaboom! logger.warning(u"Prevented clickbait from '%s' with user agent '%s'", referrer or "-", req.user_agent) - raise falcon.HTTPUnauthorized("Forbidden", + raise falcon.HTTPForbidden("Forbidden", "No suitable UA or referrer provided, cross-site scripting disabled") return wrapped diff --git a/certidude/helpers.py b/certidude/helpers.py index 31d1bed..c16b421 100644 --- a/certidude/helpers.py +++ b/certidude/helpers.py @@ -6,14 +6,19 @@ import subprocess import tempfile from certidude import errors from certidude.wrappers import Certificate, Request +from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.serialization import Encoding +from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, AuthorityInformationAccessOID from configparser import ConfigParser from OpenSSL import crypto -def certidude_request_certificate(server, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, org_unit=None, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None, ip_address=None, dns=None, bundle=False): +def certidude_request_certificate(server, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, extended_key_usage_flags=None, org_unit=None, email_address=None, given_name=None, surname=None, autosign=False, wait=False, ip_address=None, dns=None, bundle=False, insecure=False): """ Exchange CSR for certificate using Certidude HTTP API server """ - # Set up URL-s request_params = set() if autosign: @@ -22,9 +27,10 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa request_params.add("wait=forever") # Expand ca.example.com - authority_url = "http://%s/api/certificate/" % server - request_url = "http://%s/api/request/" % server - revoked_url = "http://%s/api/revoked/" % server + scheme = "http" if insecure else "https" # TODO: Expose in CLI + authority_url = "%s://%s/api/certificate/" % (scheme, server) + request_url = "%s://%s/api/request/" % (scheme, server) + revoked_url = "%s://%s/api/revoked/" % (scheme, server) if request_params: request_url = request_url + "?" + "&".join(request_params) @@ -103,54 +109,62 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa # Construct private key click.echo("Generating 4096-bit RSA key...") - key = crypto.PKey() - key.generate_key(crypto.TYPE_RSA, 4096) + key = rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + backend=default_backend() + ) # Dump private key key_partial = tempfile.mktemp(prefix=key_path + ".part") os.umask(0o077) with open(key_partial, "wb") as fh: - fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) + fh.write(key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + )) - # Construct CSR - csr = crypto.X509Req() - csr.set_version(2) # Corresponds to X.509v3 - csr.set_pubkey(key) - csr.get_subject().CN = common_name - - request = Request(csr) - - # Set subject attributes + # Set subject name attributes + names = [x509.NameAttribute(NameOID.COMMON_NAME, common_name.decode("utf-8"))] if given_name: - request.given_name = given_name + names.append(x509.NameAttribute(NameOID.GIVEN_NAME, given_name.decode("utf-8"))) if surname: - request.surname = surname + names.append(x509.NameAttribute(NameOID.SURNAME, surname.decode("utf-8"))) if org_unit: - request.organizational_unit = org_unit + names.append(x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT, org_unit.decode("utf-8"))) # Collect subject alternative names - subject_alt_name = set() + subject_alt_names = set() if email_address: - subject_alt_name.add("email:%s" % email_address) + subject_alt_names.add(x509.RFC822Name(email_address)) if ip_address: - subject_alt_name.add("IP:%s" % ip_address) + subject_alt_names.add("IP:%s" % ip_address) if dns: - subject_alt_name.add("DNS:%s" % dns) + subject_alt_names.add(x509.DNSName(dns)) - # Set extensions - extensions = [] - if key_usage: - extensions.append(("keyUsage", key_usage, True)) - if extended_key_usage: - extensions.append(("extendedKeyUsage", extended_key_usage, False)) - if subject_alt_name: - extensions.append(("subjectAltName", ", ".join(subject_alt_name), False)) - request.set_extensions(extensions) - # Dump CSR + # Construct CSR + csr = x509.CertificateSigningRequestBuilder( + ).subject_name(x509.Name(names)) + + + if extended_key_usage_flags: + click.echo("Adding extended key usage extension: %s" % extended_key_usage_flags) + csr = csr.add_extension(x509.ExtendedKeyUsage( + extended_key_usage_flags), critical=True) + + if subject_alt_names: + click.echo("Adding subject alternative name extension: %s" % subject_alt_names) + csr = csr.add_extension( + x509.SubjectAlternativeName(subject_alt_names), + critical=False) + + + # Sign & dump CSR os.umask(0o022) - with open(request_path + ".part", "w") as fh: - fh.write(request.dump()) + with open(request_path + ".part", "wb") as f: + f.write(csr.sign(key, hashes.SHA256(), default_backend()).public_bytes(serialization.Encoding.PEM)) click.echo("Writing private key to: %s" % key_path) os.rename(key_partial, key_path) @@ -160,37 +174,36 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa # We have CSR now, save the paths to client.conf so we could: # Update CRL, renew certificate, maybe something extra? - if not os.path.exists("/etc/certidude"): - os.makedirs("/etc/certidude") - - clients = ConfigParser() - if os.path.exists("/etc/certidude/client.conf"): - clients.readfp(open("/etc/certidude/client.conf")) - - if clients.has_section(server): - click.echo("Section %s already exists in /etc/certidude/client.conf, not reconfiguring" % server) - else: - clients.add_section(server) - clients.set(server, "trigger", "interface up") - clients.set(server, "key_path", key_path) - clients.set(server, "request_path", request_path) - clients.set(server, "certificate_path", certificate_path) - clients.set(server, "authority_path", authority_path) - clients.set(server, "key_path", key_path) - clients.set(server, "revocations_path", revocations_path) - clients.write(open("/etc/certidude/client.conf", "w")) - click.echo("Section %s added to /etc/certidude/client.conf" % repr(server)) - if os.path.exists(certificate_path): click.echo("Found certificate: %s" % certificate_path) # TODO: Check certificate validity, download CRL? return + # If machine is joined to domain attempt to present machine credentials for authentication + if os.path.exists("/etc/krb5.keytab") and os.path.exists("/etc/samba/smb.conf"): + # Get HTTP service ticket + from configparser import ConfigParser + cp = ConfigParser(delimiters=("=")) + cp.readfp(open("/etc/samba/smb.conf")) + name = cp.get("global", "netbios name") + realm = cp.get("global", "realm") + os.environ["KRB5CCNAME"]="/tmp/ca.ticket" + os.system("kinit -k %s$ -S HTTP/%s@%s -t /etc/krb5.keytab" % (name, server, realm)) + from requests_kerberos import HTTPKerberosAuth, OPTIONAL + auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL, force_preemptive=True) + else: + auth = None + click.echo("Submitting to %s, waiting for response..." % request_url) submission = requests.post(request_url, + auth=auth, data=open(request_path), headers={"Content-Type": "application/pkcs10", "Accept": "application/x-x509-user-cert,application/x-pem-file"}) + # Destroy service ticket + if os.path.exists("/tmp/ca.ticket"): + os.system("kdestroy") + if submission.status_code == requests.codes.ok: pass if submission.status_code == requests.codes.accepted: diff --git a/certidude/mailer.py b/certidude/mailer.py index 2d92e8c..6efcba7 100644 --- a/certidude/mailer.py +++ b/certidude/mailer.py @@ -8,7 +8,7 @@ from jinja2 import Environment, PackageLoader from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.base import MIMEBase -from urllib.parse import urlparse +from urlparse import urlparse env = Environment(loader=PackageLoader("certidude", "templates/mail")) diff --git a/certidude/push.py b/certidude/push.py index f84edd0..c05f4f0 100644 --- a/certidude/push.py +++ b/certidude/push.py @@ -23,12 +23,17 @@ def publish(event_type, event_data): url, data=event_data, headers={"X-EventSource-Event": event_type, "User-Agent": "Certidude API"}) - if notification.status_code != requests.codes.created: - click.echo("Failed to submit event to push server, server responded %d, expected %d" % ( - notification.status_code, requests.codes.created)) + if notification.status_code == requests.codes.created: + pass # Sent to client + elif notification.status_code == requests.codes.accepted: + pass # Buffered in nchan + else: + click.echo("Failed to submit event to push server, server responded %d" % ( + notification.status_code)) except requests.exceptions.ConnectionError: click.echo("Failed to submit event to push server, connection error") + class PushLogHandler(logging.Handler): """ To be used with Python log handling framework for publishing log entries diff --git a/certidude/relational.py b/certidude/relational.py index 18b2b68..0a91688 100644 --- a/certidude/relational.py +++ b/certidude/relational.py @@ -3,7 +3,7 @@ import click import re import os -from urllib.parse import urlparse +from urlparse import urlparse SCRIPTS = {} diff --git a/certidude/signer.py b/certidude/signer.py index 7bfd50d..8927dfa 100644 --- a/certidude/signer.py +++ b/certidude/signer.py @@ -1,14 +1,11 @@ import random -import pwd import socket import os import asyncore import asynchat -from certidude import constants, config -from OpenSSL import crypto - +from certidude import const, config from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, serialization @@ -20,99 +17,6 @@ import random DN_WHITELIST = NameOID.COMMON_NAME, NameOID.GIVEN_NAME, NameOID.SURNAME, \ NameOID.EMAIL_ADDRESS -SERIAL_MIN = 0x1000000000000000000000000000000000000000 -SERIAL_MAX = 0xffffffffffffffffffffffffffffffffffffffff - -def raw_sign(private_key, ca_cert, request, basic_constraints, lifetime, key_usage=None, extended_key_usage=None): - """ - Sign certificate signing request directly with private key assuming it's readable by the process - """ - - # Initialize X.509 certificate object - cert = crypto.X509() - cert.set_version(2) # This corresponds to X.509v3 - - # Set public key - cert.set_pubkey(request.get_pubkey()) - - # Set issuer - cert.set_issuer(ca_cert.get_subject()) - - # Set SKID and AKID extensions - cert.add_extensions([ - crypto.X509Extension( - b"subjectKeyIdentifier", - False, - b"hash", - subject = cert), - crypto.X509Extension( - b"authorityKeyIdentifier", - False, - b"keyid:always", - issuer = ca_cert), - crypto.X509Extension( - b"authorityInfoAccess", - False, - ("caIssuers;URI: %s" % config.CERTIFICATE_AUTHORITY_URL).encode("ascii")), - crypto.X509Extension( - b"crlDistributionPoints", - False, - ("URI: %s" % config.CERTIFICATE_CRL_URL).encode("ascii")) - ]) - - - # Copy attributes from request - cert.get_subject().CN = request.get_subject().CN - - if request.get_subject().SN: - cert.get_subject().SN = request.get_subject().SN - if request.get_subject().GN: - cert.get_subject().GN = request.get_subject().GN - - if request.get_subject().OU: - cert.get_subject().OU = req_subject.OU - - # Copy e-mail, key usage, extended key from request - for extension in request.get_extensions(): - cert.add_extensions([extension]) - - # TODO: Set keyUsage and extendedKeyUsage defaults if none has been provided in the request - - # Override basic constraints if nececssary - if basic_constraints: - cert.add_extensions([ - crypto.X509Extension( - b"basicConstraints", - True, - basic_constraints.encode("ascii"))]) - - if key_usage: - try: - cert.add_extensions([ - crypto.X509Extension( - b"keyUsage", - True, - key_usage.encode("ascii"))]) - except crypto.Error: - raise ValueError("Invalid value '%s' for keyUsage attribute" % key_usage) - - if extended_key_usage: - cert.add_extensions([ - crypto.X509Extension( - b"extendedKeyUsage", - True, - extended_key_usage.encode("ascii"))]) - - # Set certificate lifetime - cert.gmtime_adj_notBefore(-3600) - cert.gmtime_adj_notAfter(lifetime * 24 * 60 * 60) - - # Generate random serial - cert.set_serial_number(random.randint(SERIAL_MIN, SERIAL_MAX)) - cert.sign(private_key, 'sha512') - return cert - - class SignHandler(asynchat.async_chat): def __init__(self, sock, server): asynchat.async_chat.__init__(self, sock=sock) @@ -162,7 +66,9 @@ class SignHandler(asynchat.async_chat): cert = x509.CertificateBuilder( ).subject_name(subject - ).serial_number(random.randint(SERIAL_MIN, SERIAL_MAX) + ).serial_number(random.randint( + 0x1000000000000000000000000000000000000000, + 0xffffffffffffffffffffffffffffffffffffffff) ).issuer_name(self.server.certificate.issuer ).public_key(request.public_key() ).not_valid_before(now - timedelta(hours=1) @@ -224,32 +130,31 @@ class SignHandler(asynchat.async_chat): def collect_incoming_data(self, data): self.buffer.append(data) +import signal +import click class SignServer(asyncore.dispatcher): def __init__(self): asyncore.dispatcher.__init__(self) - # Bind to sockets - if os.path.exists(config.SIGNER_SOCKET_PATH): - os.unlink(config.SIGNER_SOCKET_PATH) - os.umask(0o007) + if os.path.exists(const.SIGNER_SOCKET_PATH): + os.unlink(const.SIGNER_SOCKET_PATH) + self.create_socket(socket.AF_UNIX, socket.SOCK_STREAM) - self.bind(config.SIGNER_SOCKET_PATH) + self.bind(const.SIGNER_SOCKET_PATH) self.listen(5) # Load CA private key and certificate + click.echo("Signer reading private key from %s" % config.AUTHORITY_PRIVATE_KEY_PATH) self.private_key = serialization.load_pem_private_key( open(config.AUTHORITY_PRIVATE_KEY_PATH).read(), password=None, # TODO: Ask password for private key? backend=default_backend()) + click.echo("Signer reading certificate from %s" % config.AUTHORITY_CERTIFICATE_PATH) self.certificate = x509.load_pem_x509_certificate( open(config.AUTHORITY_CERTIFICATE_PATH).read(), backend=default_backend()) - # Drop privileges - _, _, uid, gid, gecos, root, shell = pwd.getpwnam("nobody") - os.setgid(gid) - os.setuid(uid) def handle_accept(self): pair = self.accept() diff --git a/certidude/templates/certidude.conf b/certidude/templates/certidude-server.conf similarity index 97% rename from certidude/templates/certidude.conf rename to certidude/templates/certidude-server.conf index 4188e97..4cacafb 100644 --- a/certidude/templates/certidude.conf +++ b/certidude/templates/certidude-server.conf @@ -14,11 +14,10 @@ backends = pam # The accounts backend specifies how the user's given name, surname and e-mail # address are looked up. In case of 'posix' basically 'getent passwd' is performed, # in case of 'ldap' a search is performed on LDAP server specified in /etc/ldap/ldap.conf -# with Kerberos credential cache initialized at path specified by 'ldap gssapi credential cache' +# with Kerberos credential cache initialized at path specified by environment variable KRB5CCNAME backend = posix ;backend = ldap -ldap gssapi credential cache = /run/certidude/krb5cc [authorization] # The authorization backend specifies how the users are authorized. @@ -66,6 +65,7 @@ certificate url = {{ certificate_url }} revoked url = {{ revoked_url }} [push] +token = {{ push_token }} event source = {{ push_server }}/ev/%s long poll = {{ push_server }}/lp/%s publish = {{ push_server }}/pub?id=%s @@ -80,7 +80,6 @@ publish = {{ push_server }}/pub?id=%s ;user certificate enrollment = single allowed user certificate enrollment = multiple allowed - private key path = {{ ca_key }} certificate path = {{ ca_crt }} diff --git a/certidude/templates/ldap-ticket-renewal.sh b/certidude/templates/ldap-ticket-renewal.sh new file mode 100644 index 0000000..7c07217 --- /dev/null +++ b/certidude/templates/ldap-ticket-renewal.sh @@ -0,0 +1,5 @@ +#!/bin/bash +KRB5CCNAME={{ticket_path}}.part kinit -k {{name}}$ -S ldap/dc1.{{domain}}@{{realm}} -t /etc/krb5.keytab +chown certidude:certidude {{ticket_path}}.part +mv {{ticket_path}}.part {{ticket_path}} + diff --git a/certidude/templates/nginx-https-site.conf b/certidude/templates/nginx-https-site.conf index 0710ac8..c64f8ee 100644 --- a/certidude/templates/nginx-https-site.conf +++ b/certidude/templates/nginx-https-site.conf @@ -1,8 +1,8 @@ server { listen 80; - server_name {{constants.FQDN}}; - rewrite ^ https://{{constants.FQDN}}$request_uri?; + server_name {{const.FQDN}}; + rewrite ^ https://{{const.FQDN}}$request_uri?; } server { @@ -10,7 +10,7 @@ server { add_header X-Frame-Options "DENY"; add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload"; listen 443 ssl; - server_name {{constants.FQDN}}; + server_name {{const.FQDN}}; client_max_body_size 10G; ssl_certificate {{certificate_path}}; ssl_certificate_key {{key_path}}; diff --git a/certidude/templates/nginx.conf b/certidude/templates/nginx.conf index 86df244..41efc19 100644 --- a/certidude/templates/nginx.conf +++ b/certidude/templates/nginx.conf @@ -1,8 +1,4 @@ -upstream certidude_api { - server unix:///run/uwsgi/app/certidude/socket; -} - server { server_name {{ common_name }}; listen 80 default_server; @@ -11,8 +7,13 @@ server { root {{static_path}}; location /api/ { - include uwsgi_params; - uwsgi_pass certidude_api; + proxy_pass http://127.0.0.1/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_connect_timeout 600; + proxy_send_timeout 600; + proxy_read_timeout 600; + send_timeout 600; } {% if not push_server %} diff --git a/certidude/templates/openvpn-client-to-site.ovpn b/certidude/templates/openvpn-client-to-site.ovpn deleted file mode 100644 index 0beff8f..0000000 --- a/certidude/templates/openvpn-client-to-site.ovpn +++ /dev/null @@ -1,14 +0,0 @@ -client -remote {{remote}} -remote-cert-tls server -proto {{proto}} -dev tap -nobind -key {{key_path}} -cert {{certificate_path}} -ca {{authority_path}} -comp-lzo -user nobody -group nogroup -persist-tun -persist-key diff --git a/certidude/templates/openvpn-site-to-client.ovpn b/certidude/templates/openvpn-site-to-client.ovpn deleted file mode 100644 index d5a6b78..0000000 --- a/certidude/templates/openvpn-site-to-client.ovpn +++ /dev/null @@ -1,22 +0,0 @@ -mode server -tls-server -proto {{proto}} -port {{port}} -dev tap -local {{local}} -key {{key_path}} -cert {{certificate_path}} -ca {{authority_path}} -crl-verify {{revocations_path}} -dh {{dhparam_path}} -comp-lzo -user nobody -group nogroup -persist-tun -persist-key -ifconfig-pool-persist /tmp/openvpn-leases.txt -ifconfig {{subnet_first}} {{subnet.netmask}} -server-bridge {{subnet_first}} {{subnet.netmask}} {{subnet_second}} {{subnet_last}} -{% for subnet in route %} -push "route {{subnet}}" -{% endfor %} diff --git a/certidude/templates/strongswan-client-to-site.conf b/certidude/templates/strongswan-client-to-site.conf deleted file mode 100644 index 4ec2f52..0000000 --- a/certidude/templates/strongswan-client-to-site.conf +++ /dev/null @@ -1,27 +0,0 @@ -# /etc/ipsec.conf - strongSwan IPsec configuration file - -# left/local = client -# right/remote = gateway - -config setup - -conn %default - ikelifetime=60m - keylife=20m - rekeymargin=3m - keyingtries=1 - keyexchange=ikev2 - dpdaction={{dpdaction}} - closeaction=restart - -conn client-to-site - auto={{auto}} - left=%defaultroute # Use IP of default route for listening - leftsourceip=%config # Accept server suggested virtual IP as inner address for tunnel - leftcert={{certificate_path}} # Client certificate - leftid={{common_name}} # Client certificate identifier - leftfirewall=yes # Local machine may be behind NAT - right={{remote}} # Gateway IP address - rightid=%any # Allow any common name - rightsubnet=0.0.0.0/0 # Accept all subnets suggested by server - diff --git a/certidude/templates/strongswan-site-to-client.conf b/certidude/templates/strongswan-site-to-client.conf index afb5cf9..36253a9 100644 --- a/certidude/templates/strongswan-site-to-client.conf +++ b/certidude/templates/strongswan-site-to-client.conf @@ -15,7 +15,7 @@ conn %default keyexchange=ikev2 conn site-to-clients - auto=add + auto=ignore right=%any # Allow connecting from any IP address rightsourceip={{subnet}} # Serve virtual IP-s from this pool left={{common_name}} # Gateway IP address diff --git a/certidude/templates/systemd.service b/certidude/templates/systemd.service new file mode 100644 index 0000000..c99f771 --- /dev/null +++ b/certidude/templates/systemd.service @@ -0,0 +1,15 @@ +[Unit] +Description=Certidude server +After=network.target + +[Service] +Environment=PYTHON_EGG_CACHE=/tmp/.cache +Environment=KRB5_KTNAME={{kerberos_keytab}} +PIDFile=/run/certidude/server.pid +ExecStart={{ certidude_path }} serve {% if listen} -l {{listen}}{% endif %}{% if port %} -p {{port}}{% endif %} +ExecReload=/bin/kill -s HUP $MAINPID +ExecStop=/bin/kill -s TERM $MAINPID + +[Install] +WantedBy=multi-user.target + diff --git a/certidude/templates/uwsgi.ini b/certidude/templates/uwsgi.ini deleted file mode 100644 index 8d25093..0000000 --- a/certidude/templates/uwsgi.ini +++ /dev/null @@ -1,19 +0,0 @@ -[uwsgi] -exec-as-root = /usr/local/bin/certidude signer spawn -master = true -processes = 1 -vacuum = true -uid = {{username}} -gid = {{username}} -plugins = python27 -chdir = /tmp -module = certidude.wsgi -callable = app -chmod-socket = 660 -chown-socket = {{username}}:www-data -buffer-size = 32768 -env = LANG=C.UTF-8 -env = LC_ALL=C.UTF-8 -env = KRB5_KTNAME={{kerberos_keytab}} -env = KRB5CCNAME=/run/certidude/krb5cc - diff --git a/certidude/user.py b/certidude/user.py index 722b189..b55743d 100644 --- a/certidude/user.py +++ b/certidude/user.py @@ -5,7 +5,7 @@ import ldap import ldap.sasl import os import pwd -from certidude import constants, config +from certidude import const, config class User(object): def __init__(self, username, mail, given_name="", surname=""): @@ -46,7 +46,7 @@ class PosixUserManager(object): _, _, _, _, gecos, _, _ = pwd.getpwnam(username) gecos = gecos.decode("utf-8").split(",") full_name = gecos[0] - mail = username + "@" + constants.DOMAIN + mail = username + "@" + const.DOMAIN if full_name and " " in full_name: given_name, surname = full_name.split(" ", 1) return User(username, mail, given_name, surname) @@ -67,8 +67,8 @@ class DirectoryConnection(object): def __enter__(self): # TODO: Implement simple bind if not os.path.exists(config.LDAP_GSSAPI_CRED_CACHE): - raise ValueError("Ticket cache not initialized, unable to " - "authenticate with computer account against LDAP server!") + raise ValueError("Ticket cache at %s not initialized, unable to " + "authenticate with computer account against LDAP server!" % config.LDAP_GSSAPI_CRED_CACHE) os.environ["KRB5CCNAME"] = config.LDAP_GSSAPI_CRED_CACHE for server in config.LDAP_SERVERS: self.conn = ldap.initialize(server) @@ -106,7 +106,7 @@ class ActiveDirectoryUserManager(object): else: given_name, surname = cn, "" - mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + constants.DOMAIN,) + mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + const.DOMAIN,) return User(username.decode("utf-8"), mail.decode("utf-8"), given_name.decode("utf-8"), surname.decode("utf-8")) raise User.DoesNotExist("User %s does not exist" % username) @@ -121,7 +121,7 @@ class ActiveDirectoryUserManager(object): continue username, = entry.get("sAMAccountName") cn, = entry.get("cn") - mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + constants.DOMAIN,) + mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + const.DOMAIN,) if entry.get("givenName") and entry.get("sn"): given_name, = entry.get("givenName") surname, = entry.get("sn") diff --git a/certidude/wrappers.py b/certidude/wrappers.py index 9152341..c77d658 100644 --- a/certidude/wrappers.py +++ b/certidude/wrappers.py @@ -3,7 +3,7 @@ import hashlib import re import click import io -from certidude import constants +from certidude import const from OpenSSL import crypto from datetime import datetime @@ -228,7 +228,7 @@ class Request(CertificateBase): @property def signable(self): for key, value, data in self.extensions: - if key not in constants.EXTENSION_WHITELIST: + if key not in const.EXTENSION_WHITELIST: return False return True diff --git a/certidude/wsgi.py b/certidude/wsgi.py deleted file mode 100644 index 9f93e8f..0000000 --- a/certidude/wsgi.py +++ /dev/null @@ -1,12 +0,0 @@ -""" - certidude.wsgi - ~~~~~~~~~~~~~~ - - Certidude web app factory for WSGI-compatible web servers -""" -import os -from certidude.api import certidude_app - -# TODO: set up /run/certidude/api paths and permissions - -app = certidude_app() diff --git a/misc/certidude b/misc/certidude index 97d7996..cd0ec1b 100644 --- a/misc/certidude +++ b/misc/certidude @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python from certidude.cli import entry_point diff --git a/requirements.txt b/requirements.txt index f72620b..001886c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,25 @@ -cffi==1.2.1 -click==5.1 -configparser -cryptography==1.0 -falcon==0.3.0 -humanize==0.5.1 -idna==2.0 -ipaddress==1.0.16 -ipsecparse==0.1.0 Jinja2==2.8 -Markdown==2.6.5 -pyasn1==0.1.8 -pycrypto==2.6.1 -pykerberos==1.1.8 -pyOpenSSL==0.15.1 -python-ldap==2.4.10 -python-mimeparse==0.1.4 -requests==2.2.1 -setproctitle==1.1.9 -simplepam==0.1.5 -six==1.9.0 +Markdown==2.6.6 +MarkupSafe==0.23 +argparse==1.2.1 +certifi==2016.2.28 +cffi==1.7.0 +click==6.6 +configparser==3.5.0 +cryptography==1.4 +enum34==1.1.6 +falcon==1.0.0 +humanize==0.5.1 +ipaddress==1.0.16 +Markdown==2.6.6 +ndg-httpsclient==0.4.2 +pyOpenSSL==16.0.0 +pyasn1==0.1.9 +pycparser==2.14 +python-ldap==2.4.25 +python-mimeparse==1.5.2 +requests==2.10.0 +setproctitle==1.1.10 +six==1.10.0 +urllib3==1.16 +wsgiref==0.1.2 diff --git a/setup.py b/setup.py index 5569b96..7a75ae7 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( author_email = "lauri.vosandi@gmail.com", description = "Certidude is a novel X.509 Certificate Authority management tool aiming to support PKCS#11 and in far future WebCrypto.", license = "MIT", - keywords = "falcon http jinja2 x509 pkcs11 webcrypto", + keywords = "falcon http jinja2 x509 pkcs11 webcrypto kerberos ldap", url = "http://github.com/laurivosandi/certidude", packages=[ "certidude", @@ -23,13 +23,9 @@ setup( "falcon", "jinja2", "pyopenssl", - "pycountry", "humanize", - "pycrypto", "cryptography", "markupsafe", - "ldap3", - "pykerberos", ], scripts=[ "misc/certidude"