diff --git a/README.rst b/README.rst index f107367..0ba1f6b 100644 --- a/README.rst +++ b/README.rst @@ -93,13 +93,9 @@ TODO ---- * `OCSP `_ support, needs a bit hacking since OpenSSL wrappers are not exposing the functionality. -* `SECP `_ support, a client implementation available `here `_. Not sure if we can implement server-side events within current standard. -* Deep mailbox integration, eg fetch CSR-s from mailbox via IMAP. +* `SCEP `_ support, a client implementation available `here `_. Not sure if we can implement server-side events within current standard. * WebCrypto support, meanwhile check out `hwcrypto.js `_. -* Certificate push/pull, making it possible to sign offline. -* PKCS#11 hardware token support for signatures at command-line. -* Ability to send ``.ovpn`` bundle URL tokens via e-mail, for simplified VPN adoption. -* Cronjob for deleting expired certificates +* Ability to send OpenVPN profile URL tokens via e-mail, for simplified VPN adoption. * Signer process logging. @@ -384,15 +380,14 @@ as this information will already exist in AD and duplicating it in the certifica doesn't make sense. Additionally the information will get out of sync if attributes are changed in AD but certificates won't be updated. -If machine is enrolled, eg by running certidude request: +If machine is enrolled, eg by running ``certidude request`` as root on Ubuntu/Fedora/Mac OS X: -* If Kerberos credentials are presented machine is automatically enrolled -* Common name is set to short hostname/machine name in AD -* E-mail is not filled in (maybe we can fill in something from AD?) -* Given name and surname are not filled in +* If Kerberos credentials are presented machine can be automatically enrolled depending on the ``machine enrollment`` setting +* Common name is set to short ``hostname`` +* It is tricky to determine user who is triggering the action so given name, surname and e-mail attributes are not filled in If user enrolls, eg by clicking generate bundle button in the web interface: -* Common name is either set to username or username@device-identifier depending on the 'user certificate enrollment' setting -* Given name and surname are filled in based on LDAP attributes of the user -* E-mail not filled in (should it be filled in? Can we even send mail to user if it's in external domain?) +* Common name is either set to ``username`` or ``username@device-identifier`` depending on the ``user enrollment`` setting +* Given name and surname are not filled in because Unicode characters cause issues in OpenVPN Connect app +* E-mail is not filled in because it might change in AD diff --git a/certidude/api/__init__.py b/certidude/api/__init__.py index 7f97fd1..562d364 100644 --- a/certidude/api/__init__.py +++ b/certidude/api/__init__.py @@ -5,13 +5,14 @@ import mimetypes import logging import os import click +import hashlib from datetime import datetime from time import sleep from certidude import authority, mailer 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 cryptography.x509.oid import NameOID from certidude import const, config logger = logging.getLogger("api") @@ -44,6 +45,33 @@ class SessionResource(object): @login_required @event_source def on_get(self, req, resp): + def serialize_requests(g): + for common_name, path, buf, obj, server in g(): + yield dict( + common_name = common_name, + server = server, + md5sum = hashlib.md5(buf).hexdigest(), + sha1sum = hashlib.sha1(buf).hexdigest(), + sha256sum = hashlib.sha256(buf).hexdigest(), + sha512sum = hashlib.sha512(buf).hexdigest() + ) + + def serialize_certificates(g): + for common_name, path, buf, obj, server in g(): + yield dict( + serial_number = "%x" % obj.serial_number, + common_name = common_name, + server = server, + # TODO: key type, key length, key exponent, key modulo + signed = obj.not_valid_before, + expires = obj.not_valid_after, + sha256sum = hashlib.sha256(buf).hexdigest() + ) + + if req.context.get("user").is_admin(): + logger.info("Logged in authority administrator %s" % req.context.get("user")) + else: + logger.info("Logged in authority user %s" % req.context.get("user")) return dict( user = dict( name=req.context.get("user").name, @@ -51,29 +79,31 @@ class SessionResource(object): sn=req.context.get("user").surname, mail=req.context.get("user").mail ), - request_submission_allowed = sum( # Dirty hack! - [req.context.get("remote_addr") in j - for j in config.REQUEST_SUBNETS]), + request_submission_allowed = config.REQUEST_SUBMISSION_ALLOWED, authority = dict( + common_name = authority.ca_cert.subject.get_attributes_for_oid( + NameOID.COMMON_NAME)[0].value, outbox = dict( server = config.OUTBOX, name = config.OUTBOX_NAME, mail = config.OUTBOX_MAIL ), - user_certificate_enrollment=config.USER_CERTIFICATE_ENROLLMENT, - user_mutliple_certificates=config.USER_MULTIPLE_CERTIFICATES, - certificate = authority.certificate, + machine_enrollment_allowed=config.MACHINE_ENROLLMENT_ALLOWED, + user_enrollment_allowed=config.USER_ENROLLMENT_ALLOWED, + user_multiple_certificates=config.USER_MULTIPLE_CERTIFICATES, events = config.EVENT_SOURCE_SUBSCRIBE % config.EVENT_SOURCE_TOKEN, - requests=authority.list_requests(), - signed=authority.list_signed(), - revoked=authority.list_revoked(), + requests=serialize_requests(authority.list_requests), + signed=serialize_certificates(authority.list_signed), + revoked=serialize_certificates(authority.list_revoked), + users=User.objects.all(), admin_users = User.objects.filter_admins(), user_subnets = config.USER_SUBNETS, autosign_subnets = config.AUTOSIGN_SUBNETS, request_subnets = config.REQUEST_SUBNETS, admin_subnets=config.ADMIN_SUBNETS, signature = dict( - certificate_lifetime=config.CERTIFICATE_LIFETIME, + server_certificate_lifetime=config.SERVER_CERTIFICATE_LIFETIME, + client_certificate_lifetime=config.CLIENT_CERTIFICATE_LIFETIME, revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME ) ) if req.context.get("user").is_admin() else None, @@ -88,7 +118,6 @@ class StaticResource(object): self.root = os.path.realpath(root) def __call__(self, req, resp): - path = os.path.realpath(os.path.join(self.root, req.path[1:])) if not path.startswith(self.root): raise falcon.HTTPForbidden @@ -124,7 +153,7 @@ def certidude_app(): from certidude import config from .bundle import BundleResource from .revoked import RevocationListResource - from .signed import SignedCertificateListResource, SignedCertificateDetailResource + from .signed import SignedCertificateDetailResource from .request import RequestListResource, RequestDetailResource from .lease import LeaseResource, StatusFileLeaseResource from .whois import WhoisResource @@ -138,7 +167,6 @@ def certidude_app(): app.add_route("/api/certificate/", CertificateAuthorityResource()) app.add_route("/api/revoked/", RevocationListResource()) app.add_route("/api/signed/{cn}/", SignedCertificateDetailResource()) - app.add_route("/api/signed/", SignedCertificateListResource()) app.add_route("/api/request/{cn}/", RequestDetailResource()) app.add_route("/api/request/", RequestListResource()) app.add_route("/api/", SessionResource()) @@ -151,7 +179,7 @@ def certidude_app(): app.add_route("/api/whois/", WhoisResource()) # Optional user enrollment API call - if config.USER_CERTIFICATE_ENROLLMENT: + if config.USER_ENROLLMENT_ALLOWED: app.add_route("/api/bundle/", BundleResource()) if config.TAGGING_BACKEND == "sql": diff --git a/certidude/api/bundle.py b/certidude/api/bundle.py index 9cddd67..8f76baa 100644 --- a/certidude/api/bundle.py +++ b/certidude/api/bundle.py @@ -1,5 +1,3 @@ - - import logging import hashlib from certidude import config, authority diff --git a/certidude/api/request.py b/certidude/api/request.py index 00568e6..06021b7 100644 --- a/certidude/api/request.py +++ b/certidude/api/request.py @@ -4,91 +4,125 @@ import falcon import logging import ipaddress import os +import hashlib +from base64 import b64decode from certidude import config, authority, helpers, push, errors from certidude.auth import login_required, login_optional, authorize_admin 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 +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.exceptions import InvalidSignature +from cryptography.x509.oid import NameOID +from datetime import datetime logger = logging.getLogger("api") class RequestListResource(object): - @serialize - @login_required - @authorize_admin - def on_get(self, req, resp): - return authority.list_requests() - - @login_optional @whitelist_subnets(config.REQUEST_SUBNETS) @whitelist_content_types("application/pkcs10") def on_post(self, req, resp): """ - Submit certificate signing request (CSR) in PEM format + Validate and parse certificate signing request """ - body = req.stream.read(req.content_length) - - # Normalize body, TODO: newlines - if not body.endswith("\n"): - body += "\n" - - csr = Request(body) - - if not csr.common_name: + csr = x509.load_pem_x509_csr(body, default_backend()) + try: + common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) + except: # ValueError? logger.warning(u"Rejected signing request without common name from %s", req.context.get("remote_addr")) raise falcon.HTTPBadRequest( "Bad request", "No common name specified!") + """ + Handle domain computer automatic enrollment + """ machine = req.context.get("machine") - if machine: - if csr.common_name != machine: + if config.MACHINE_ENROLLMENT_ALLOWED and machine: + if common_name.value != machine: raise falcon.HTTPBadRequest( "Bad request", - "Common name %s differs from Kerberos credential %s!" % (csr.common_name, machine)) + "Common name %s differs from Kerberos credential %s!" % (common_name.value, machine)) # Automatic enroll with Kerberos machine cerdentials resp.set_header("Content-Type", "application/x-x509-user-cert") - resp.body = authority.sign(csr, overwrite=True).dump() + cert, resp.body = authority._sign(csr, body, overwrite=True) + logger.info(u"Automatically enrolled Kerberos authenticated machine %s from %s", + machine, req.context.get("remote_addr")) return - - # Check if this request has been already signed and return corresponding certificte if it has been signed + """ + Attempt to renew certificate using currently valid key pair + """ try: - cert = authority.get_signed(csr.common_name) + path, buf, cert = authority.get_signed(common_name.value) except EnvironmentError: pass else: - if cert.pubkey == csr.pubkey: - resp.status = falcon.HTTP_SEE_OTHER - resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name) - return + if cert.public_key().public_numbers() == csr.public_key().public_numbers(): + try: + renewal_signature = b64decode(req.get_header("X-Renewal-Signature")) + except TypeError, ValueError: # No header supplied, redirect to signed API call + resp.status = falcon.HTTP_SEE_OTHER + resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", common_name.value) + return + else: + try: + verifier = cert.public_key().verifier( + renewal_signature, + padding.PSS( + mgf=padding.MGF1(hashes.SHA512()), + salt_length=padding.PSS.MAX_LENGTH + ), + hashes.SHA512() + ) + verifier.update(buf) + verifier.update(body) + verifier.verify() + except InvalidSignature: + logger.error("Renewal failed, invalid signature supplied for %s", common_name.value) + else: + # At this point renewal signature was valid but we need to perform some extra checks + if datetime.utcnow() > cert.not_valid_after: + logger.error("Renewal failed, current certificate for %s has expired", common_name.value) + # Put on hold + elif not config.CERTIFICATE_RENEWAL_ALLOWED: + logger.error("Renewal requested for %s, but not allowed by authority settings", common_name.value) + # Put on hold + else: + resp.set_header("Content-Type", "application/x-x509-user-cert") + _, resp.body = authority._sign(csr, body, overwrite=True) + logger.info("Renewed certificate for %s", common_name.value) + return - # TODO: check for revoked certificates and return HTTP 410 Gone - # 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.is_client: + """ + 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 "." not in common_name.value: for subnet in config.AUTOSIGN_SUBNETS: if req.context.get("remote_addr") in subnet: try: resp.set_header("Content-Type", "application/x-x509-user-cert") - resp.body = authority.sign(csr).dump() + _, resp.body = authority._sign(csr, body) + logger.info("Autosigned %s as %s is whitelisted", common_name.value, req.context.get("remote_addr")) return - except EnvironmentError: # Certificate already exists, try to save the request - pass + except EnvironmentError: + logger.info("Autosign for %s failed, signed certificate already exists", + common_name.value, req.context.get("remote_addr")) break # Attempt to save the request otherwise try: csr = authority.store_request(body) except errors.RequestExists: - # We should stil redirect client to long poll URL below + # We should still redirect client to long poll URL below pass except errors.DuplicateCommonNameError: # TODO: Certificate renewal @@ -98,12 +132,13 @@ class RequestListResource(object): "CSR with such CN already exists", "Will not overwrite existing certificate signing request, explicitly delete CSR and try again") else: - push.publish("request-submitted", csr.common_name) + push.publish("request-submitted", common_name.value) # Wait the certificate to be signed if waiting is requested + logger.info(u"Signing request %s from %s stored", common_name.value, req.context.get("remote_addr")) if req.get_param("wait"): # Redirect to nginx pub/sub - url = config.LONG_POLL_SUBSCRIBE % csr.fingerprint() + url = config.LONG_POLL_SUBSCRIBE % hashlib.sha256(body).hexdigest() click.echo("Redirecting to: %s" % url) resp.status = falcon.HTTP_SEE_OTHER resp.set_header("Location", url.encode("ascii")) @@ -111,20 +146,17 @@ class RequestListResource(object): else: # Request was accepted, but not processed resp.status = falcon.HTTP_202 - logger.info(u"Signing request from %s stored", req.context.get("remote_addr")) class RequestDetailResource(object): - @serialize def on_get(self, req, resp, cn): """ Fetch certificate signing request as PEM """ - csr = authority.get_request(cn) + resp.set_header("Content-Type", "application/pkcs10") + _, resp.body, _ = authority.get_request(cn) logger.debug(u"Signing request %s was downloaded by %s", - csr.common_name, req.context.get("remote_addr")) - return csr - + cn, req.context.get("remote_addr")) @csrf_protection @login_required @@ -133,16 +165,15 @@ class RequestDetailResource(object): """ Sign a certificate signing request """ - csr = authority.get_request(cn) - cert = authority.sign(csr, overwrite=True, delete=True) - os.unlink(csr.path) + cert, buf = authority.sign(cn, overwrite=True) + # Mailing and long poll publishing implemented in the function above + resp.body = "Certificate successfully signed" resp.status = falcon.HTTP_201 resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn) - logger.info(u"Signing request %s signed by %s from %s", csr.common_name, + logger.info(u"Signing request %s signed by %s from %s", cn, req.context.get("user"), req.context.get("remote_addr")) - @csrf_protection @login_required @authorize_admin diff --git a/certidude/api/revoked.py b/certidude/api/revoked.py index 9954e8e..d05d310 100644 --- a/certidude/api/revoked.py +++ b/certidude/api/revoked.py @@ -1,10 +1,10 @@ +import click import falcon import json import logging from certidude import const, config from certidude.authority import export_crl, list_revoked -from certidude.decorators import MyEncoder from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import Encoding @@ -31,16 +31,13 @@ class RevocationListResource(object): resp.status = falcon.HTTP_SEE_OTHER resp.set_header("Location", url.encode("ascii")) logger.debug(u"Redirecting to CRL request to %s", url) + resp.body = "Redirecting to %s" % url else: resp.set_header("Content-Type", "application/x-pem-file") resp.append_header( "Content-Disposition", ("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") - resp.set_header("Content-Disposition", "inline") - resp.body = json.dumps(list_revoked(), cls=MyEncoder) else: raise falcon.HTTPUnsupportedMediaType( "Client did not accept application/x-pkcs7-crl or application/x-pem-file") diff --git a/certidude/api/signed.py b/certidude/api/signed.py index 14319c3..02bfa41 100644 --- a/certidude/api/signed.py +++ b/certidude/api/signed.py @@ -1,38 +1,46 @@ import falcon import logging +import json +import hashlib from certidude import authority from certidude.auth import login_required, authorize_admin -from certidude.decorators import serialize, csrf_protection +from certidude.decorators import csrf_protection logger = logging.getLogger("api") -class SignedCertificateListResource(object): - @serialize - @login_required - @authorize_admin - def on_get(self, req, resp): - return {"signed":authority.list_signed()} - - class SignedCertificateDetailResource(object): - @serialize def on_get(self, req, resp, cn): - # Compensate for NTP lag -# from time import sleep -# sleep(5) + + preferred_type = req.client_prefers(("application/json", "application/x-pem-file")) try: - cert = authority.get_signed(cn) + path, buf, cert = authority.get_signed(cn) except EnvironmentError: logger.warning(u"Failed to serve non-existant certificate %s to %s", cn, req.context.get("remote_addr")) - resp.body = "No certificate CN=%s found" % cn - raise falcon.HTTPNotFound() + raise falcon.HTTPNotFound("No certificate CN=%s found" % cn) else: - logger.debug(u"Served certificate %s to %s", - cn, req.context.get("remote_addr")) - return cert - + if preferred_type == "application/x-pem-file": + resp.set_header("Content-Type", "application/x-pem-file") + resp.set_header("Content-Disposition", ("attachment; filename=%s.pem" % cn)) + resp.body = buf + logger.debug(u"Served certificate %s to %s as application/x-pem-file", + cn, req.context.get("remote_addr")) + elif preferred_type == "application/json": + resp.set_header("Content-Type", "application/json") + resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % cn)) + resp.body = json.dumps(dict( + common_name = cn, + serial_number = "%x" % cert.serial_number, + signed = cert.not_valid_before.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", + expires = cert.not_valid_after.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", + sha256sum = hashlib.sha256(buf).hexdigest())) + logger.debug(u"Served certificate %s to %s as application/json", + cn, req.context.get("remote_addr")) + else: + logger.debug("Client did not accept application/json or application/x-pem-file") + raise falcon.HTTPUnsupportedMediaType( + "Client did not accept application/json or application/x-pem-file") @csrf_protection @login_required @@ -40,5 +48,5 @@ class SignedCertificateDetailResource(object): def on_delete(self, req, resp, cn): logger.info(u"Revoked certificate %s by %s from %s", cn, req.context.get("user"), req.context.get("remote_addr")) - authority.revoke_certificate(cn) + authority.revoke(cn) diff --git a/certidude/auth.py b/certidude/auth.py index 90ffe2a..cccecd5 100644 --- a/certidude/auth.py +++ b/certidude/auth.py @@ -31,6 +31,8 @@ if "kerberos" in config.AUTHENTICATION_BACKENDS: else: click.echo("Kerberos enabled, service principal is HTTP/%s" % const.FQDN) + click.echo("Accepting requests only for realm: %s" % const.DOMAIN) + def authenticate(optional=False): def wrapper(func): @@ -38,7 +40,7 @@ def authenticate(optional=False): # If LDAP enabled and device is not Kerberos capable fall # back to LDAP bind authentication if "ldap" in config.AUTHENTICATION_BACKENDS: - if "Android" in req.user_agent: + if "Android" in req.user_agent or "iPhone" in req.user_agent: return ldap_authenticate(resource, req, resp, *args, **kwargs) # Try pre-emptive authentication @@ -81,16 +83,20 @@ def authenticate(optional=False): raise falcon.HTTPForbidden("Forbidden", "Kerberos error: %s" % (ex.args[0],)) - user = kerberos.authGSSServerUserName(context) + user_principal = kerberos.authGSSServerUserName(context) + username, domain = user_principal.split("@") + if domain.lower() != const.DOMAIN: + raise falcon.HTTPForbidden("Forbidden", + "Invalid realm supplied") - if "$@" in user and optional: + if username.endswith("$") and optional: # Extract machine hostname # TODO: Assert LDAP group membership - req.context["machine"], _ = user.lower().split("$@", 1) + req.context["machine"] = username[:-1].lower() req.context["user"] = None else: # Attempt to look up real user - req.context["user"] = User.objects.get(user) + req.context["user"] = User.objects.get(username) try: kerberos.authGSSServerClean(context) @@ -143,12 +149,8 @@ def authenticate(optional=False): conn = ldap.initialize(config.LDAP_AUTHENTICATION_URI) conn.set_option(ldap.OPT_REFERRALS, 0) - if "@" not in user: - user = "%s@%s" % (user, const.DOMAIN) - logger.debug("Expanded username to %s", user) - try: - conn.simple_bind_s(user, passwd) + conn.simple_bind_s("%s@%s" % (user, const.DOMAIN), passwd) except ldap.STRONG_AUTH_REQUIRED: logger.critical("LDAP server demands encryption, use ldaps:// instead of ldaps://") raise @@ -160,8 +162,8 @@ def authenticate(optional=False): 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" % const.DOMAIN, - ("Basic",)) + "Please authenticate with %s domain account username" % const.DOMAIN, + ("Basic",)) req.context["ldap_conn"] = conn req.context["user"] = User.objects.get(user) diff --git a/certidude/authority.py b/certidude/authority.py index d6538c5..c5d9b16 100644 --- a/certidude/authority.py +++ b/certidude/authority.py @@ -4,15 +4,15 @@ import os import random import re import requests +import hashlib 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.x509.oid import NameOID, ExtensionOID, ExtendedKeyUsageOID 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 import errors from jinja2 import Template @@ -23,71 +23,51 @@ RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z # http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py # Cache CA certificate -certificate = Certificate(open(config.AUTHORITY_CERTIFICATE_PATH)) - -def publish_certificate(func): - # TODO: Implement e-mail and nginx notifications using hooks - def wrapped(csr, *args, **kwargs): - cert = func(csr, *args, **kwargs) - assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert)) - - recipient = None - - mailer.send( - "certificate-signed.md", - to=recipient, - attachments=(cert,), - certificate=cert) - - if config.LONG_POLL_PUBLISH: - url = config.LONG_POLL_PUBLISH % csr.fingerprint() - click.echo("Publishing certificate at %s ..." % url) - requests.post(url, data=cert.dump(), - 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", cert.common_name) - return cert - return wrapped +with open(config.AUTHORITY_CERTIFICATE_PATH) as fh: + ca_buf = fh.read() + ca_cert = x509.load_pem_x509_certificate(ca_buf, default_backend()) def get_request(common_name): if not re.match(RE_HOSTNAME, common_name): raise ValueError("Invalid common name %s" % repr(common_name)) - return Request(open(os.path.join(config.REQUESTS_DIR, common_name + ".pem"))) - + path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") + with open(path) as fh: + buf = fh.read() + return path, buf, x509.load_pem_x509_csr(buf, default_backend()) def get_signed(common_name): if not re.match(RE_HOSTNAME, common_name): raise ValueError("Invalid common name %s" % repr(common_name)) - return Certificate(open(os.path.join(config.SIGNED_DIR, common_name + ".pem"))) - - -def get_revoked(common_name): - if not re.match(RE_HOSTNAME, common_name): - raise ValueError("Invalid common name %s" % repr(common_name)) - return Certificate(open(os.path.join(config.SIGNED_DIR, common_name + ".pem"))) + path = os.path.join(config.SIGNED_DIR, common_name + ".pem") + with open(path) as fh: + buf = fh.read() + return path, buf, x509.load_pem_x509_certificate(buf, default_backend()) +def get_revoked(serial): + path = os.path.join(config.REVOKED_DIR, serial + ".pem") + with open(path) as fh: + buf = fh.read() + return path, buf, x509.load_pem_x509_certificate(buf, default_backend()) def store_request(buf, overwrite=False): """ Store CSR for later processing """ - if not buf: return # No certificate supplied + if not buf: + raise ValueError("No certificate supplied") # 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) + common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) + # TODO: validate common name again - request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") - - if not re.match(RE_HOSTNAME, common_name): + if not re.match(RE_HOSTNAME, common_name.value): raise ValueError("Invalid common name") + request_path = os.path.join(config.REQUESTS_DIR, common_name.value + ".pem") + + # If there is cert, check if it's the same if os.path.exists(request_path): if open(request_path).read() == buf: @@ -99,9 +79,11 @@ def store_request(buf, overwrite=False): fh.write(buf) os.rename(request_path + ".part", request_path) - req = Request(open(request_path)) - mailer.send("request-stored.md", attachments=(req,), request=req) - return req + attach_csr = buf, "application/x-pem-file", common_name.value + ".csr" + mailer.send("request-stored.md", + attachments=(attach_csr,), + common_name=common_name.value) + return csr def signer_exec(cmd, *bits): @@ -118,14 +100,15 @@ def signer_exec(cmd, *bits): return buf -def revoke_certificate(common_name): +def revoke(common_name): """ Revoke valid certificate """ - cert = get_signed(common_name) - revoked_filename = os.path.join(config.REVOKED_DIR, "%s.pem" % cert.serial_number) - os.rename(cert.path, revoked_filename) - push.publish("certificate-revoked", cert.common_name) + path, buf, cert = get_signed(common_name) + revoked_path = os.path.join(config.REVOKED_DIR, "%x.pem" % cert.serial_number) + signed_path = os.path.join(config.SIGNED_DIR, "%s.pem" % common_name) + os.rename(signed_path, revoked_path) + push.publish("certificate-revoked", common_name) # Publish CRL for long polls if config.LONG_POLL_PUBLISH: @@ -134,26 +117,52 @@ def revoke_certificate(common_name): requests.post(url, data=export_crl(), headers={"User-Agent": "Certidude API", "Content-Type": "application/x-pem-file"}) - mailer.send("certificate-revoked.md", attachments=(cert,), certificate=cert) + attach_cert = buf, "application/x-pem-file", common_name + ".crt" + mailer.send("certificate-revoked.md", + attachments=(attach_cert,), + serial_number="%x" % cert.serial, + common_name=common_name) + +def server_flags(cn): + if config.USER_ENROLLMENT_ALLOWED and not config.USER_MULTIPLE_CERTIFICATES: + # Common name set to username, used for only HTTPS client validation anyway + return False + if "@" in cn: + # username@hostname is user certificate anyway, can't be server + return False + if "." in cn: + # CN is hostname, if contains dot has to be FQDN, hence a server + return True + return False def list_requests(directory=config.REQUESTS_DIR): for filename in os.listdir(directory): if filename.endswith(".pem"): - yield Request(open(os.path.join(directory, filename))) + common_name = filename[:-4] + path, buf, req = get_request(common_name) + yield common_name, path, buf, req, server_flags(common_name), - -def list_signed(directory=config.SIGNED_DIR): +def _list_certificates(directory): for filename in os.listdir(directory): if filename.endswith(".pem"): - yield Certificate(open(os.path.join(directory, filename))) + common_name = filename[:-4] + path = os.path.join(directory, filename) + with open(path) as fh: + buf = fh.read() + cert = x509.load_pem_x509_certificate(buf, default_backend()) + server = False + extension = cert.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE) + for usage in extension.value: + if usage == ExtendedKeyUsageOID.SERVER_AUTH: # TODO: IKE intermediate? + server = True + yield common_name, path, buf, cert, server +def list_signed(): + return _list_certificates(config.SIGNED_DIR) -def list_revoked(directory=config.REVOKED_DIR): - for filename in os.listdir(directory): - if filename.endswith(".pem"): - yield Certificate(open(os.path.join(directory, filename))) - +def list_revoked(): + return _list_certificates(config.REVOKED_DIR) def export_crl(): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -178,14 +187,15 @@ def delete_request(common_name): raise ValueError("Invalid common name") path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") - request = Request(open(path)) + _, buf, csr = get_request(common_name) os.unlink(path) # Publish event at CA channel - push.publish("request-deleted", request.common_name) + push.publish("request-deleted", common_name) # Write empty certificate to long-polling URL - requests.delete(config.LONG_POLL_PUBLISH % request.fingerprint(), + requests.delete( + config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest(), headers={"User-Agent": "Certidude API"}) def generate_ovpn_bundle(common_name, owner=None): @@ -198,26 +208,26 @@ def generate_ovpn_bundle(common_name, owner=None): backend=default_backend() ) + key_buf = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ) + csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([ x509.NameAttribute(k, v) for k, v in ( (NameOID.COMMON_NAME, common_name), ) if v - ])) + ])).sign(key, hashes.SHA512(), default_backend()) + + buf = csr.public_bytes(serialization.Encoding.PEM) # Sign CSR - cert = sign(Request( - csr.sign(key, hashes.SHA512(), default_backend()).public_bytes(serialization.Encoding.PEM)), overwrite=True) + cert, cert_buf = _sign(csr, buf, overwrite=True) - bundle = Template(open(config.OPENVPN_BUNDLE_TEMPLATE).read()).render( - ca = certificate.dump(), - key = key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() - ), - cert = cert.dump(), - crl=export_crl(), - ) + bundle = Template(open(config.OPENVPN_PROFILE_TEMPLATE).read()).render( + ca = ca_buf, key = key_buf, cert = cert_buf, crl=export_crl(), + servers = [cn for cn, path, buf, cert, server in list_signed() if server]) return bundle, cert def generate_pkcs12_bundle(common_name, key_size=4096, owner=None): @@ -236,11 +246,12 @@ def generate_pkcs12_bundle(common_name, key_size=4096, owner=None): csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, common_name) - ])) + ])).sign(key, hashes.SHA512(), default_backend()) + + buf = csr.public_bytes(serialization.Encoding.PEM) # Sign CSR - cert = sign(Request( - csr.sign(key, hashes.SHA512(), default_backend()).public_bytes(serialization.Encoding.PEM)), overwrite=True) + cert, cert_buf = _sign(csr, buf, overwrite=True) # Generate P12, currently supported only by PyOpenSSL try: @@ -256,131 +267,102 @@ def generate_pkcs12_bundle(common_name, key_size=4096, owner=None): 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]) + encryption_algorithm=serialization.NoEncryption()))) + p12.set_certificate( + crypto.load_certificate(crypto.FILETYPE_PEM, cert_buf)) + p12.set_ca_certificates([ + crypto.load_certificate(crypto.FILETYPE_PEM, ca_buf)]) return p12.export("1234"), cert -@publish_certificate -def sign(req, overwrite=False, delete=True): +def sign(common_name, overwrite=False): """ Sign certificate signing request via signer process """ - cert_path = os.path.join(config.SIGNED_DIR, req.common_name + ".pem") + + req_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") + with open(req_path) as fh: + csr_buf = fh.read() + csr = x509.load_pem_x509_csr(csr_buf, backend=default_backend()) + common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) + + # Sign with function below + cert, buf = _sign(csr, csr_buf, overwrite) + + os.unlink(req_path) + return cert, buf + +def _sign(csr, buf, overwrite=False): + assert buf.startswith("-----BEGIN CERTIFICATE REQUEST-----\n") + assert isinstance(csr, x509.CertificateSigningRequest) + common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) + cert_path = os.path.join(config.SIGNED_DIR, common_name.value + ".pem") + renew = False # Move existing certificate if necessary if os.path.exists(cert_path): - old_cert = Certificate(open(cert_path)) + with open(cert_path) as fh: + prev_buf = fh.read() + prev = x509.load_pem_x509_certificate(prev_buf, default_backend()) + # TODO: assert validity here again? + renew = prev.public_key().public_numbers() == csr.public_key().public_numbers() + if overwrite: - revoke_certificate(req.common_name) - elif req.pubkey == old_cert.pubkey: - return old_cert + if renew: + # TODO: is this the best approach? + signed_path = os.path.join(config.SIGNED_DIR, "%s.pem" % common_name.value) + revoked_path = os.path.join(config.REVOKED_DIR, "%x.pem" % prev.serial_number) + os.rename(signed_path, revoked_path) + else: + revoke(common_name.value) else: raise EnvironmentError("Will not overwrite existing certificate") # Sign via signer process - cert_buf = signer_exec("sign-request", req.dump()) + cert_buf = signer_exec("sign-request", buf) + cert = x509.load_pem_x509_certificate(cert_buf, default_backend()) with open(cert_path + ".part", "wb") as fh: fh.write(cert_buf) os.rename(cert_path + ".part", cert_path) - return Certificate(open(cert_path)) + # Send mail + recipient = None - -@publish_certificate -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. - """ - - 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 errors.DuplicateCommonNameError("Valid certificate with common name %s already exists" % request.common_name) - - 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 + if renew: + mailer.send( + "certificate-renewed.md", + to=recipient, + attachments=( + (prev_buf, "application/x-pem-file", "deprecated.crt"), + (cert_buf, "application/x-pem-file", common_name.value + ".crt") + ), + serial_number="%x" % cert.serial, + common_name=common_name.value, + certificate=cert, + ) + else: + mailer.send( + "certificate-signed.md", + to=recipient, + attachments=( + (buf, "application/x-pem-file", common_name.value + ".csr"), + (cert_buf, "application/x-pem-file", common_name.value + ".crt") + ), + serial_number="%x" % cert.serial, + common_name=common_name.value, + certificate=cert, ) - # 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) + + if config.LONG_POLL_PUBLISH: + url = config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest() + click.echo("Publishing certificate at %s ..." % url) + requests.post(url, data=cert_buf, + headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"}) + + if config.EVENT_SOURCE_PUBLISH: # TODO: handle renewal + push.publish("request-signed", common_name.value) + + return cert, cert_buf - 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(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) - - return Certificate(open(certificate_path)) - diff --git a/certidude/cli.py b/certidude/cli.py index f1e02da..f9f4cca 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -25,7 +25,6 @@ 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 @@ -38,22 +37,29 @@ env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=Tr # Parse command-line argument defaults from environment -USERNAME = os.environ.get("USER") NOW = datetime.utcnow().replace(tzinfo=None) -FIRST_NAME = None -SURNAME = None -EMAIL = None -if USERNAME: - EMAIL = USERNAME + "@" + const.FQDN +CERTIDUDE_TIMER = """ +[Unit] +Description=Run certidude service weekly -if os.getuid() >= 1000: - _, _, _, _, gecos, _, _ = pwd.getpwnam(USERNAME) - if " " in gecos: - FIRST_NAME, SURNAME = gecos.split(" ", 1) - else: - FIRST_NAME = gecos +[Timer] +OnCalendar=weekly +Persistent=true +Unit=certidude.service +[Install] +WantedBy=timers.target +""" + +CERTIDUDE_SERVICE = """ +[Unit] +Description=Renew certificates and update revocation lists + +[Service] +Type=simple +ExecStart=%s request +""" @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") @@ -80,7 +86,24 @@ def certidude_request(fork): click.echo("Creating: %s" % run_dir) os.makedirs(run_dir) + if not os.path.exists("/etc/systemd/system/certidude.timer"): + click.echo("Creating systemd timer...") + with open("/etc/systemd/system/certidude.timer", "w") as fh: + fh.write(CERTIDUDE_TIMER) + if not os.path.exists("/etc/systemd/system/certidude.service"): + click.echo("Creating systemd service...") + with open("/etc/systemd/system/certidude.service", "w") as fh: + fh.write(CERTIDUDE_SERVICE % sys.argv[0]) + + for authority in clients.sections(): + try: + endpoint_dhparam = clients.get(authority, "dhparam path") + if not os.path.exists(endpoint_dhparam): + cmd = "openssl", "dhparam", "-out", endpoint_dhparam, "2048" + subprocess.check_call(cmd) + except NoOptionError: + pass try: endpoint_insecure = clients.getboolean(authority, "insecure") except NoOptionError: @@ -111,22 +134,6 @@ def certidude_request(fork): 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 @@ -168,8 +175,6 @@ def certidude_request(fork): endpoint_authority_path, endpoint_revocations_path, endpoint_common_name, - extended_key_usage_flags, - None, insecure=endpoint_insecure, autosign=True, wait=True) @@ -229,6 +234,10 @@ def certidude_request(fork): # OpenVPN set up with NetworkManager if service_config.get(endpoint, "service") == "network-manager/openvpn": + nm_config_path = os.path.join("/etc/NetworkManager/system-connections", endpoint) + if os.path.exists(nm_config_path): + click.echo("Not creating %s, remove to regenerate" % nm_config_path) + continue nm_config = ConfigParser() nm_config.add_section("connection") nm_config.set("connection", "id", endpoint) @@ -242,6 +251,7 @@ def certidude_request(fork): nm_config.set("vpn", "tap-dev", "no") 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", "port", "51900") nm_config.set("vpn", "key", endpoint_key_path) nm_config.set("vpn", "cert", endpoint_certificate_path) nm_config.set("vpn", "ca", endpoint_authority_path) @@ -255,9 +265,9 @@ def certidude_request(fork): os.umask(0o177) # Write NetworkManager configuration - with open(os.path.join("/etc/NetworkManager/system-connections", endpoint), "w") as fh: + with open(nm_config_path, "w") as fh: nm_config.write(fh) - click.echo("Created %s" % fh.name) + click.echo("Created %s" % nm_config_path) os.system("nmcli con reload") continue @@ -302,50 +312,18 @@ def certidude_request(fork): os.unlink(pid_path) -@click.command("client", help="Setup X.509 certificates for application") -@click.argument("server") -@click.option("--common-name", "-cn", default=const.HOSTNAME, help="Common name, '%s' by default" % const.HOSTNAME) -@click.option("--given-name", "-gn", default=FIRST_NAME, help="Given name of the person associted with the certificate, '%s' by default" % FIRST_NAME) -@click.option("--surname", "-sn", default=SURNAME, help="Surname of the person associted with the certificate, '%s' by default" % SURNAME) -@click.option("--key-usage", "-ku", help="Key usage attributes, none requested by default") -@click.option("--extended-key-usage", "-eku", help="Extended key usage attributes, none requested by default") -@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=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): - return certidude_request_certificate(**kwargs) - - @click.command("server", help="Set up OpenVPN server") @click.argument("authority") @click.option("--subnet", "-s", default="192.168.33.0/24", type=ip_network, help="OpenVPN subnet, 192.168.33.0/24 by default") @click.option("--local", "-l", default="0.0.0.0", help="OpenVPN listening address, defaults to all interfaces") -@click.option("--port", "-p", default=1194, type=click.IntRange(1,60000), help="OpenVPN listening port, 1194 by default") +@click.option("--port", "-p", default=51900, type=click.IntRange(1,60000), help="OpenVPN listening port, 51900 by default") @click.option('--proto', "-t", default="udp", type=click.Choice(['udp', 'tcp']), help="OpenVPN transport protocol, UDP 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/openvpn/site-to-client.conf", type=click.File(mode="w", atomic=True, lazy=True), help="OpenVPN configuration file") -def certidude_setup_openvpn_server(authority, config, subnet, route, 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 - subnet_second = None - for addr in subnet.hosts(): - if not subnet_first: - subnet_first = addr - continue - if not subnet_second: - subnet_second = addr - subnet_last = addr +def certidude_setup_openvpn_server(authority, config, subnet, route, local, proto, port): # Create corresponding section in Certidude client configuration file client_config = ConfigParser() @@ -356,13 +334,12 @@ def certidude_setup_openvpn_server(authority, config, subnet, route, org_unit, l 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") + client_config.set(authority, "dhparam path", "/etc/openvpn/keys/dhparam.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) @@ -380,49 +357,38 @@ def certidude_setup_openvpn_server(authority, config, subnet, route, org_unit, l 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) - - config.write("mode server\n") - config.write("tls-server\n") + authority_hostname = authority.split(".")[0] + config.write("server %s %s\n" % (subnet.network_address, subnet.netmask)) + config.write("dev tun-%s\n" % authority_hostname) 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("dh %s\n" % client_config.get(authority, "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("#ifconfig-pool-persist /tmp/openvpn-leases.txt\n") 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(" certidude request") - click.echo() - 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.argument("authority") @click.option("--common-name", "-cn", default=const.FQDN, help="Common name, %s by default" % const.FQDN) @click.option("--tls-config", default="/etc/nginx/conf.d/tls.conf", @@ -439,51 +405,56 @@ def certidude_setup_openvpn_server(authority, config, subnet, route, org_unit, l @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'])) +@click.option("--verify-client", "-vc", default="optional", type=click.Choice(['optional', 'on', 'off'])) @expand_paths() -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): - click.echo("As HTTPS 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() - retval = certidude_request_certificate(authority, key_path, request_path, - certificate_path, authority_path, revocations_path, common_name, org_unit, - 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" - subprocess.check_call(cmd) - - if retval: - return retval +def certidude_setup_nginx(authority, site_config, tls_config, common_name, directory, key_path, request_path, certificate_path, authority_path, revocations_path, dhparam_path, verify_client): + if not os.path.exists("/etc/nginx"): + raise ValueError("nginx not installed") + if "." not in common_name: + raise ValueError("Fully qualified hostname not specified as common name, make sure hostname -f works") + 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", common_name) + client_config.set(authority, "request path", request_path) + client_config.set(authority, "key path", key_path) + client_config.set(authority, "certificate path", certificate_path) + client_config.set(authority, "authority path", authority_path) + client_config.set(authority, "dhparam path", dhparam_path) + client_config.set(authority, "revocations path", revocations_path) + 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)) context = globals() # Grab const.BLAH context.update(locals()) - if os.path.exists(site_client_config.name): - click.echo("Configuration file %s already exists, not overwriting" % site_client_config.name) + if os.path.exists(site_config.name): + click.echo("Configuration file %s already exists, not overwriting" % site_config.name) else: - site_client_config.write(env.get_template("nginx-https-site.conf").render(context)) - click.echo("Generated %s" % site_client_config.name) + site_config.write(env.get_template("nginx-https-site.conf").render(context)) + click.echo("Generated %s" % site_config.name) - if os.path.exists(tls_client_config.name): - click.echo("Configuration file %s already exists, not overwriting" % tls_client_config.name) + if os.path.exists(tls_config.name): + click.echo("Configuration file %s already exists, not overwriting" % tls_config.name) else: - tls_client_config.write(env.get_template("nginx-tls.conf").render(context)) - click.echo("Generated %s" % tls_client_config.name) + tls_config.write(env.get_template("nginx-tls.conf").render(context)) + click.echo("Generated %s" % tls_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_client_config.name, "/etc/nginx/sites-enabled"), - os.path.basename(site_client_config.name))) - click.secho(" service nginx restart", bold=True) + os.path.relpath(site_config.name, "/etc/nginx/sites-enabled"), + os.path.basename(site_config.name))) + click.echo(" service nginx restart") click.echo() @@ -495,7 +466,7 @@ def certidude_setup_nginx(authority, site_config, tls_config, common_name, org_u default="/etc/openvpn/client-to-site.conf", type=click.File(mode="w", atomic=True, lazy=True), help="OpenVPN configuration file") -def certidude_setup_openvpn_client(authority, remote, config, org_unit, proto): +def certidude_setup_openvpn_client(authority, remote, config, proto): # Create corresponding section in Certidude client configuration file client_config = ConfigParser() @@ -553,15 +524,10 @@ def certidude_setup_openvpn_client(authority, remote, config, org_unit, proto): click.echo("Inspect generated files and issue following to request certificate:") click.echo() click.echo(" certidude request") - click.echo() - 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.argument("authority") @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") @@ -580,8 +546,6 @@ def certidude_setup_strongswan_server(authority, config, secrets, subnet, route, 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) @@ -617,9 +581,9 @@ def certidude_setup_strongswan_server(authority, config, secrets, subnet, route, @click.command("client", help="Set up strongSwan client") -@click.argument("server") +@click.argument("authority") @click.argument("remote") -def certidude_setup_strongswan_client(authority, config, org_unit, remote, dpdaction): +def certidude_setup_strongswan_client(authority, config, remote, dpdaction): # Create corresponding section in /etc/certidude/client.conf client_config = ConfigParser() if os.path.exists(const.CLIENT_CONFIG_PATH): @@ -664,9 +628,9 @@ def certidude_setup_strongswan_client(authority, config, org_unit, remote, dpdac @click.command("networkmanager", help="Set up strongSwan client via NetworkManager") -@click.argument("server") # Certidude server +@click.argument("authority") # Certidude server @click.argument("remote") # StrongSwan gateway -def certidude_setup_strongswan_networkmanager(server,remote, org_unit): +def certidude_setup_strongswan_networkmanager(authority, remote): endpoint = "IPSec to %s" % remote # Create corresponding section in /etc/certidude/client.conf @@ -679,7 +643,6 @@ def certidude_setup_strongswan_networkmanager(server,remote, org_unit): 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) @@ -708,10 +671,10 @@ def certidude_setup_strongswan_networkmanager(server,remote, org_unit): @click.command("networkmanager", help="Set up OpenVPN client via NetworkManager") -@click.argument("server") # Certidude server +@click.argument("authority") @click.argument("remote") # OpenVPN gateway @click.option("--common-name", "-cn", default=const.HOSTNAME, help="Common name, %s by default" % const.HOSTNAME) -def certidude_setup_openvpn_networkmanager(authority, org_unit, remote): +def certidude_setup_openvpn_networkmanager(authority, remote): # Create corresponding section in /etc/certidude/client.conf client_config = ConfigParser() if os.path.exists(const.CLIENT_CONFIG_PATH): @@ -750,29 +713,24 @@ def certidude_setup_openvpn_networkmanager(authority, org_unit, remote): @click.command("authority", help="Set up Certificate Authority in a directory") @click.option("--username", default="certidude", help="Service user account, created if necessary, 'certidude' by default") -@click.option("--static-path", default=os.path.join(os.path.dirname(__file__), "static"), help="Path to Certidude's static JS/CSS/etc") @click.option("--kerberos-keytab", default="/etc/certidude/server.keytab", help="Kerberos keytab for using 'kerberos' authentication backend, /etc/certidude/server.keytab by default") @click.option("--nginx-config", "-n", 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("--parent", "-p", help="Parent CA, none 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") -@click.option("--authority-lifetime", default=20*365, help="Authority certificate lifetime in days, 7300 days (20 years) by default") -@click.option("--certificate-lifetime", default=5*365, help="Certificate lifetime in days, 1825 days (5 years) by default") -@click.option("--revocation-list-lifetime", default=20*60, help="Revocation list lifetime in days, 1200 seconds (20 minutes) by default") +@click.option("--authority-lifetime", default=20*365, help="Authority certificate lifetime in days, 20 years by default") @click.option("--organization", "-o", default=None, help="Company or organization name") @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://" + const.FQDN, help="Push server, by default http://%s" % const.FQDN) @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" % 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, outbox, server_flags): +def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, state, locality, organization, organizational_unit, common_name, directory, authority_lifetime, push_server, outbox, server_flags): + openvpn_profile_template_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates", "openvpn-client.conf") if not directory: if os.getuid(): @@ -781,18 +739,13 @@ def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_conf directory = os.path.join("/var/lib/certidude", const.FQDN) click.echo("Using fully qualified hostname: %s" % common_name) + certificate_url = "http://%s/api/certificate/" % common_name + revoked_url = "http://%s/api/revoked/" % common_name # Expand variables - if not revoked_url: - revoked_url = "http://%s/api/revoked/" % common_name - if not certificate_url: - 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 += "/" - if os.getuid() == 0: try: pwd.getpwnam("certidude") @@ -833,6 +786,7 @@ def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_conf working_directory = os.path.realpath(os.path.dirname(__file__)) certidude_path = sys.argv[0] + # Push server config generation 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" @@ -924,7 +878,6 @@ def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_conf ).add_extension( x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False - ) if server_flags: @@ -1002,121 +955,100 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign from certidude import authority from pycountry import countries - def dump_common(j): - - person = [j for j in (j.given_name, j.surname) if j] - if person: - click.echo("Associated person: %s" % " ".join(person) + (" <%s>" % j.email_address if j.email_address else "")) - elif j.email_address: - click.echo("Associated e-mail: " + j.email_address) - - bits = [j for j in ( - countries.get(alpha2=j.country_code.upper()).name if - j.country_code else "", - j.state_or_county, - j.city, - j.organization, - j.organizational_unit) if j] - if bits: - click.echo("Organization: %s" % ", ".join(bits)) - - if show_key_type: - click.echo("Key type: %s-bit %s" % (j.key_length, j.key_type)) - - if show_extensions: - for key, value, data in j.extensions: - click.echo(("Extension " + key + ":").ljust(50) + " " + value) - else: - if j.key_usage: - click.echo("Key usage: " + j.key_usage) - if j.fqdn: - click.echo("Associated hostname: " + j.fqdn) - + def dump_common(common_name, path, cert): + click.echo("certidude revoke %s" % common_name) + with open(path, "rb") as fh: + buf = fh.read() + click.echo("md5sum: %s" % hashlib.md5(buf).hexdigest()) + click.echo("sha1sum: %s" % hashlib.sha1(buf).hexdigest()) + click.echo("sha256sum: %s" % hashlib.sha256(buf).hexdigest()) + click.echo() + for ext in cert.extensions: + print " -", ext.value + click.echo() if not hide_requests: - for j in authority.list_requests(): - + for common_name, path, buf, csr, server in authority.list_requests(): + created = 0 if not verbose: - click.echo("s " + j.path + " " + j.identity) + click.echo("s " + path) continue - click.echo(click.style(j.common_name, fg="blue")) - click.echo("=" * len(j.common_name)) - click.echo("State: ? " + click.style("submitted", fg="yellow") + " " + naturaltime(j.created) + click.style(", %s" %j.created, fg="white")) + click.echo(click.style(common_name, fg="blue")) + click.echo("=" * len(common_name)) + click.echo("State: ? " + click.style("submitted", fg="yellow") + " " + naturaltime(created) + click.style(", %s" %created, fg="white")) + click.echo("openssl req -in %s -text -noout" % path) + dump_common(common_name, path, cert) - dump_common(j) - - # Calculate checksums for cross-checking - import hashlib - md5sum = hashlib.md5() - sha1sum = hashlib.sha1() - sha256sum = hashlib.sha256() - with open(j.path, "rb") as fh: - buf = fh.read() - md5sum.update(buf) - sha1sum.update(buf) - sha256sum.update(buf) - click.echo("MD5 checksum: %s" % md5sum.hexdigest()) - click.echo("SHA-1 checksum: %s" % sha1sum.hexdigest()) - click.echo("SHA-256 checksum: %s" % sha256sum.hexdigest()) - - if show_path: - click.echo("Details: openssl req -in %s -text -noout" % j.path) - click.echo("Sign: certidude sign %s" % j.path) - click.echo() if show_signed: - for j in authority.list_signed(): + for common_name, path, buf, cert, server in authority.list_signed(): if not verbose: - if j.signed < NOW and j.expires > NOW: - click.echo("v " + j.path + " " + j.identity) - elif NOW > j.expires: - click.echo("e " + j.path + " " + j.identity) + if cert.not_valid_before < NOW and cert.not_valid_after > NOW: + click.echo("v " + path) + elif NOW > cert.not_valid_after: + click.echo("e " + path) else: - click.echo("y " + j.path + " " + j.identity) + click.echo("y " + path) continue - click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white")) - click.echo("="*(len(j.common_name)+60)) - - if j.signed < NOW and j.expires > NOW: - click.echo("Status: \u2713 " + click.style("valid", fg="green") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white")) - elif NOW > j.expires: - click.echo("Status: \u2717 " + click.style("expired", fg="red") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white")) + click.echo(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial_number, fg="white")) + click.echo("="*(len(common_name)+60)) + expires = 0 # TODO + if cert.not_valid_before < NOW and cert.not_valid_after > NOW: + click.echo("Status: " + click.style("valid", fg="green") + " until " + naturaltime(cert.not_valid_after) + click.style(", %s" % cert.not_valid_after, fg="white")) + elif NOW > cert.not_valid_after: + click.echo("Status: " + click.style("expired", fg="red") + " " + naturaltime(expires) + click.style(", %s" %expires, fg="white")) else: - click.echo("Status: \u2717 " + click.style("not valid yet", fg="red") + click.style(", %s" %j.expires, fg="white")) - dump_common(j) - - if show_path: - click.echo("Details: openssl x509 -in %s -text -noout" % j.path) - click.echo("Revoke: certidude revoke %s" % j.path) + click.echo("Status: " + click.style("not valid yet", fg="red") + click.style(", %s" %expires, fg="white")) click.echo() + click.echo("openssl x509 -in %s -text -noout" % path) + dump_common(common_name, path, cert) if show_revoked: - for j in authority.list_revoked(): + for common_name, path, buf, cert, server in authority.list_revoked(): if not verbose: - click.echo("r " + j.path + " " + j.identity) + click.echo("r " + path) continue - click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white")) - click.echo("="*(len(j.common_name)+60)) - click.echo("Status: \u2717 " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-j.changed), click.style(", %s" % j.changed, fg="white"))) - dump_common(j) - if show_path: - click.echo("Details: openssl x509 -in %s -text -noout" % j.path) - click.echo() + + click.echo(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial, fg="white")) + click.echo("="*(len(common_name)+60)) + + _, _, _, _, _, _, _, _, mtime, _ = os.stat(path) + changed = datetime.fromtimestamp(mtime) + click.echo("Status: " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-changed), click.style(", %s" % changed, fg="white"))) + click.echo("openssl x509 -in %s -text -noout" % path) + dump_common(common_name, path, cert) click.echo() -@click.command("sign", help="Sign certificates") +@click.command("sign", help="Sign certificate") @click.argument("common_name") @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, config - request = authority.get_request(common_name) - cert = authority.sign(request) +def certidude_sign(common_name, overwrite): + from certidude import authority + cert = authority.sign(common_name, overwrite) +@click.command("revoke", help="Revoke certificate") +@click.argument("common_name") +def certidude_revoke(common_name): + from certidude import authority + authority.revoke(common_name) + + +@click.command("cron", help="Run from cron to manage Certidude server") +def certidude_cron(): + import itertools + from certidude import authority, config + now = datetime.now() + for cn, path, buf, cert, server in itertools.chain(authority.list_signed(), authority.list_revoked()): + if cert.not_valid_after < now: + expired_path = os.path.join(config.EXPIRED_DIR, "%x.pem" % cert.serial) + assert not os.path.exists(expired_path) + os.rename(path, expired_path) + click.echo("Moved %s to %s" % (path, expired_path)) + @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") @@ -1276,9 +1208,6 @@ def certidude_setup_openvpn(): pass @click.group("setup", help="Getting started section") def certidude_setup(): pass -@click.group("signer", help="Signer process management") -def certidude_signer(): pass - @click.group() def entry_point(): pass @@ -1291,15 +1220,15 @@ certidude_setup_openvpn.add_command(certidude_setup_openvpn_networkmanager) certidude_setup.add_command(certidude_setup_authority) 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) entry_point.add_command(certidude_setup) entry_point.add_command(certidude_serve) -entry_point.add_command(certidude_signer) entry_point.add_command(certidude_request) entry_point.add_command(certidude_sign) +entry_point.add_command(certidude_revoke) entry_point.add_command(certidude_list) entry_point.add_command(certidude_users) +entry_point.add_command(certidude_cron) if __name__ == "__main__": entry_point() diff --git a/certidude/config.py b/certidude/config.py index 5caaab1..38e76f5 100644 --- a/certidude/config.py +++ b/certidude/config.py @@ -38,27 +38,31 @@ AUTHORITY_CERTIFICATE_PATH = cp.get("authority", "certificate path") REQUESTS_DIR = cp.get("authority", "requests dir") SIGNED_DIR = cp.get("authority", "signed dir") REVOKED_DIR = cp.get("authority", "revoked dir") +EXPIRED_DIR = cp.get("authority", "expired dir") OUTBOX = cp.get("authority", "outbox uri") OUTBOX_NAME = cp.get("authority", "outbox sender name") OUTBOX_MAIL = cp.get("authority", "outbox sender address") -BUNDLE_FORMAT = cp.get("authority", "bundle format") -OPENVPN_BUNDLE_TEMPLATE = cp.get("authority", "openvpn bundle template") +BUNDLE_FORMAT = cp.get("bundle", "format") +OPENVPN_PROFILE_TEMPLATE = cp.get("bundle", "openvpn profile template") -USER_CERTIFICATE_ENROLLMENT = { +MACHINE_ENROLLMENT_ALLOWED = { + "forbidden": False, "allowed": True }[ + cp.get("authority", "machine enrollment")] +USER_ENROLLMENT_ALLOWED = { "forbidden": False, "single allowed": True, "multiple allowed": True }[ - cp.get("authority", "user certificate enrollment")] + cp.get("authority", "user enrollment")] USER_MULTIPLE_CERTIFICATES = { "forbidden": False, "single allowed": False, "multiple allowed": True }[ - cp.get("authority", "user certificate enrollment")] + cp.get("authority", "user enrollment")] -CERTIFICATE_BASIC_CONSTRAINTS = "CA:FALSE" -CERTIFICATE_KEY_USAGE_FLAGS = "digitalSignature,keyEncipherment" -CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS = "clientAuth" -CERTIFICATE_LIFETIME = cp.getint("signature", "certificate lifetime") -CERTIFICATE_AUTHORITY_URL = cp.get("signature", "certificate url") +REQUEST_SUBMISSION_ALLOWED = cp.getboolean("authority", "request submission allowed") +CLIENT_CERTIFICATE_LIFETIME = cp.getint("signature", "client certificate lifetime") +SERVER_CERTIFICATE_LIFETIME = cp.getint("signature", "server certificate lifetime") +AUTHORITY_CERTIFICATE_URL = cp.get("signature", "authority certificate url") CERTIFICATE_CRL_URL = cp.get("signature", "revoked url") +CERTIFICATE_RENEWAL_ALLOWED = cp.getboolean("signature", "renewal allowed") REVOCATION_LIST_LIFETIME = cp.getint("signature", "revocation list lifetime") diff --git a/certidude/decorators.py b/certidude/decorators.py index 40c0ee0..bb15fda 100644 --- a/certidude/decorators.py +++ b/certidude/decorators.py @@ -2,12 +2,9 @@ import falcon import ipaddress import json import logging -import re import types from datetime import date, time, datetime -from OpenSSL import crypto from certidude.auth import User -from certidude.wrappers import Request, Certificate from urlparse import urlparse logger = logging.getLogger("api") @@ -52,21 +49,7 @@ def event_source(func): return wrapped class MyEncoder(json.JSONEncoder): - REQUEST_ATTRIBUTES = "is_client", "identity", "changed", "common_name", \ - "organizational_unit", "fqdn", \ - "key_type", "key_length", "md5sum", "sha1sum", "sha256sum", "key_usage" - - CERTIFICATE_ATTRIBUTES = "revokable", "identity", "common_name", \ - "organizational_unit", "fqdn", \ - "key_type", "key_length", "sha256sum", "serial_number", "key_usage", \ - "signed", "expires" - def default(self, obj): - if isinstance(obj, crypto.X509Name): - try: - return ", ".join(["%s=%s" % (k.decode("ascii"),v.decode("utf-8")) for k, v in obj.get_components()]) - except UnicodeDecodeError: # Work around old buggy pyopenssl - return ", ".join(["%s=%s" % (k.decode("ascii"),v.decode("iso8859")) for k, v in obj.get_components()]) if isinstance(obj, ipaddress._IPAddressBase): return str(obj) if isinstance(obj, set): @@ -77,17 +60,9 @@ class MyEncoder(json.JSONEncoder): return obj.strftime("%Y-%m-%d") if isinstance(obj, types.GeneratorType): return tuple(obj) - if isinstance(obj, Request): - return dict([(key, getattr(obj, key)) for key in self.REQUEST_ATTRIBUTES \ - if hasattr(obj, key) and getattr(obj, key)]) - if isinstance(obj, Certificate): - return dict([(key, getattr(obj, key)) for key in self.CERTIFICATE_ATTRIBUTES \ - if hasattr(obj, key) and getattr(obj, key)]) if isinstance(obj, User): return dict(name=obj.name, given_name=obj.given_name, surname=obj.surname, mail=obj.mail) - if hasattr(obj, "serialize"): - return obj.serialize() return json.JSONEncoder.default(self, obj) @@ -96,29 +71,13 @@ def serialize(func): Falcon response serialization """ def wrapped(instance, req, resp, **kwargs): + if not req.client_accepts("application/json"): + logger.debug("Client did not accept application/json") + raise falcon.HTTPUnsupportedMediaType( + "Client did not accept application/json") resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate") resp.set_header("Pragma", "no-cache") resp.set_header("Expires", "0") - r = func(instance, req, resp, **kwargs) - if resp.body is None: - if req.accept.startswith("application/json"): - resp.set_header("Content-Type", "application/json") - resp.set_header("Content-Disposition", "inline") - resp.body = json.dumps(r, cls=MyEncoder) - elif hasattr(r, "content_type") and req.client_accepts(r.content_type): - resp.set_header("Content-Type", r.content_type) - resp.set_header("Content-Disposition", - ("attachment; filename=%s" % r.suggested_filename).encode("ascii")) - resp.body = r.dump() - elif hasattr(r, "content_type"): - logger.debug(u"Client did not accept application/json or %s, " - "client expected %s", r.content_type, req.accept) - raise falcon.HTTPUnsupportedMediaType( - "Client did not accept application/json or %s" % r.content_type) - else: - logger.debug(u"Client did not accept application/json, client expected %s", req.accept) - raise falcon.HTTPUnsupportedMediaType( - "Client did not accept application/json") - return r + resp.body = json.dumps(func(instance, req, resp, **kwargs), cls=MyEncoder) return wrapped diff --git a/certidude/helpers.py b/certidude/helpers.py index b396613..4773f31 100644 --- a/certidude/helpers.py +++ b/certidude/helpers.py @@ -4,18 +4,20 @@ import os import requests import subprocess import tempfile +from base64 import b64encode +from datetime import datetime, timedelta from certidude import errors, const -from certidude.wrappers import Certificate, Request from cryptography import x509 -from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric import rsa, padding 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 +from cryptography import x509 +from cryptography.hazmat.backends import default_backend -def certidude_request_certificate(server, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, autosign=False, wait=False, ip_address=None, bundle=False, insecure=False): +def certidude_request_certificate(server, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, autosign=False, wait=False, bundle=False, insecure=False): """ Exchange CSR for certificate using Certidude HTTP API server """ @@ -26,6 +28,8 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa if wait: request_params.add("wait=forever") + renew = False # Attempt to renew if certificate has expired + # Expand ca.example.com scheme = "http" if insecure else "https" # TODO: Expose in CLI authority_url = "%s://%s/api/certificate/" % (scheme, server) @@ -41,13 +45,14 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa click.echo("Attempting to fetch authority certificate from %s" % authority_url) try: r = requests.get(authority_url, - headers={"Accept": "application/x-x509-ca-cert,application/x-pem-file"}) - cert = crypto.load_certificate(crypto.FILETYPE_PEM, r.text) - except crypto.Error: - raise ValueError("Failed to parse PEM: %s" % r.text) + headers={"Accept": "application/x-x509-ca-cert,application/x-pem-file"}) + x509.load_pem_x509_certificate(r.content, default_backend()) + except: + raise + # raise ValueError("Failed to parse PEM: %s" % r.text) authority_partial = tempfile.mktemp(prefix=authority_path + ".part") with open(authority_partial, "w") as oh: - oh.write(r.text) + oh.write(r.content) click.echo("Writing authority certificate to: %s" % authority_path) os.rename(authority_partial, authority_path) @@ -68,18 +73,19 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa # Check if we have been inserted into CRL if os.path.exists(certificate_path): - cert = crypto.load_certificate(crypto.FILETYPE_PEM, open(certificate_path).read()) - revocation_list = crypto.load_crl(crypto.FILETYPE_PEM, open(revocations_path).read()) - for revocation in revocation_list.get_revoked(): - if int(revocation.get_serial(), 16) == cert.get_serial_number(): - if revocation.get_reason() == "Certificate Hold": # TODO: 'Remove From CRL' - # TODO: Disable service for time being - click.echo("Certificate put on hold, doing nothing for now") + cert = x509.load_pem_x509_certificate(open(certificate_path).read(), default_backend()) + + for revocation in x509.load_pem_x509_crl(open(revocations_path).read(), default_backend()): + extension, = revocation.extensions + + if revocation.serial_number == cert.serial_number: + if extension.value.reason == x509.ReasonFlags.certificate_hold: + # Don't do anything for now + # TODO: disable service break - # Disable the client if operation has been ceased or - # the certificate has been superseded by other - if revocation.get_reason() in ("Cessation Of Operation", "Superseded"): + # Disable the client if operation has been ceased + if extension.value.reason == x509.ReasonFlags.cessation_of_operation: if os.path.exists("/etc/certidude/client.conf"): clients.readfp(open("/etc/certidude/client.conf")) if clients.has_section(server): @@ -87,9 +93,7 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa clients.write(open("/etc/certidude/client.conf", "w")) click.echo("Authority operation ceased, disabling in /etc/certidude/client.conf") # TODO: Disable related services - if revocation.get_reason() in ("CA Compromise", "AA Compromise"): - if os.path.exists(authority_path): - os.remove(key_path) + return click.echo("Certificate has been revoked, wiping keys and certificates!") if os.path.exists(key_path): @@ -102,9 +106,16 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa else: click.echo("Certificate does not seem to be revoked. Good!") + try: - request = Request(open(request_path)) + request_buf = open(request_path).read() + request = x509.load_pem_x509_csr(request_buf, default_backend()) click.echo("Found signing request: %s" % request_path) + with open(key_path) as fh: + key = serialization.load_pem_private_key( + fh.read(), + password=None, + backend=default_backend()) except EnvironmentError: # Construct private key @@ -146,9 +157,16 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa # Update CRL, renew certificate, maybe something extra? if os.path.exists(certificate_path): - click.echo("Found certificate: %s" % certificate_path) - # TODO: Check certificate validity, download CRL? - return + cert_buf = open(certificate_path).read() + cert = x509.load_pem_x509_certificate(cert_buf, default_backend()) + lifetime = (cert.not_valid_after - cert.not_valid_before) + rollover = lifetime / 1 # TODO: Make rollover configurable + if datetime.now() > cert.not_valid_after - rollover: + click.echo("Certificate expired %s" % cert.not_valid_after) + renew = True + else: + click.echo("Found valid certificate: %s" % certificate_path) + return # If machine is joined to domain attempt to present machine credentials for authentication if os.path.exists("/etc/krb5.keytab"): @@ -169,10 +187,25 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa 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"}) + headers={ + "Content-Type": "application/pkcs10", + "Accept": "application/x-x509-user-cert,application/x-pem-file" + } + + if renew: + signer = key.signer( + padding.PSS( + mgf=padding.MGF1(hashes.SHA512()), + salt_length=padding.PSS.MAX_LENGTH + ), + hashes.SHA512() + ) + signer.update(cert_buf) + signer.update(request_buf) + headers["X-Renewal-Signature"] = b64encode(signer.finalize()) + click.echo("Attached renewal signature %s" % headers["X-Renewal-Signature"]) + + submission = requests.post(request_url, auth=auth, data=open(request_path), headers=headers) # Destroy service ticket if os.path.exists("/tmp/ca.ticket"): @@ -192,8 +225,8 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa submission.raise_for_status() try: - cert = crypto.load_certificate(crypto.FILETYPE_PEM, submission.text) - except crypto.Error: + cert = x509.load_pem_x509_certificate(submission.text.encode("ascii"), default_backend()) + except: # TODO: catch correct exceptions raise ValueError("Failed to parse PEM: %s" % submission.text) os.umask(0o022) diff --git a/certidude/mailer.py b/certidude/mailer.py index f5365c0..fe7d5c6 100644 --- a/certidude/mailer.py +++ b/certidude/mailer.py @@ -79,10 +79,10 @@ def send(template, to=None, attachments=(), **context): msg.attach(part1) msg.attach(part2) - for attachment in attachments: - part = MIMEBase(*attachment.content_type.split("/")) - part.add_header('Content-Disposition', 'attachment', filename=attachment.suggested_filename) - part.set_payload(attachment.dump()) + for attachment, content_type, suggested_filename in attachments: + part = MIMEBase(*content_type.split("/")) + part.add_header('Content-Disposition', 'attachment', filename=suggested_filename) + part.set_payload(attachment) msg.attach(part) # Gmail employs some sort of IPS diff --git a/certidude/signer.py b/certidude/signer.py index ed9892d..cd8f479 100644 --- a/certidude/signer.py +++ b/certidude/signer.py @@ -29,7 +29,8 @@ class SignHandler(asynchat.async_chat): """ builder = x509.CertificateRevocationListBuilder( - ).last_update(now + ).last_update( + now - timedelta(minutes=5) ).next_update( now + timedelta(seconds=config.REVOCATION_LIST_LIFETIME) ).issuer_name(self.server.certificate.issuer @@ -89,9 +90,12 @@ class SignHandler(asynchat.async_chat): ).public_key( request.public_key() ).not_valid_before( - now - timedelta(hours=1) + now ).not_valid_after( - now + timedelta(days=config.CERTIFICATE_LIFETIME) + now + timedelta(days= + config.SERVER_CERTIFICATE_LIFETIME + if server_flags + else config.CLIENT_CERTIFICATE_LIFETIME) ).add_extension( x509.BasicConstraints( ca=False, @@ -122,7 +126,7 @@ class SignHandler(asynchat.async_chat): x509.AccessDescription( AuthorityInformationAccessOID.CA_ISSUERS, x509.UniformResourceIdentifier( - config.CERTIFICATE_AUTHORITY_URL) + config.AUTHORITY_CERTIFICATE_URL) ) ]), critical=False diff --git a/certidude/static/img/iconmonstr-server-1.svg b/certidude/static/img/iconmonstr-server-1.svg new file mode 100644 index 0000000..ea8cf23 --- /dev/null +++ b/certidude/static/img/iconmonstr-server-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/certidude/static/js/certidude.js b/certidude/static/js/certidude.js index 90b9f1b..1b022b9 100644 --- a/certidude/static/js/certidude.js +++ b/certidude/static/js/certidude.js @@ -108,18 +108,23 @@ function onClientDown(e) { function onRequestSigned(e) { console.log("Request signed:", e.data); + var slug = e.data.replace("@", "--").replace(".", "-"); + console.log("Removing:", slug); - $("#request-" + e.data.replace("@", "--").replace(".", "-")).slideUp("normal", function() { $(this).remove(); }); - $("#certificate-" + e.data.replace("@", "--").replace(".", "-")).slideUp("normal", function() { $(this).remove(); }); + $("#request-" + slug).slideUp("normal", function() { $(this).remove(); }); + $("#certificate-" + slug).slideUp("normal", function() { $(this).remove(); }); $.ajax({ method: "GET", url: "/api/signed/" + e.data + "/", dataType: "json", success: function(certificate, status, xhr) { - console.info(certificate); + console.info("Retrieved certificate:", certificate); $("#signed_certificates").prepend( nunjucks.render('views/signed.html', { certificate: certificate })); + }, + error: function(response) { + console.info("Failed to retrieve certificate:", response); } }); } @@ -234,7 +239,7 @@ $(document).ready(function() { $(window).on("search", function() { var q = $("#search").val(); $(".filterable").each(function(i, e) { - if ($(e).attr("data-dn").toLowerCase().indexOf(q) >= 0) { + if ($(e).attr("data-cn").toLowerCase().indexOf(q) >= 0) { $(e).show(); } else { $(e).hide(); diff --git a/certidude/static/views/authority.html b/certidude/static/views/authority.html index 6f8cd4c..63e7252 100644 --- a/certidude/static/views/authority.html +++ b/certidude/static/views/authority.html @@ -2,12 +2,10 @@

{{ session.user.gn }} {{ session.user.sn }} ({{session.user.name }}) settings

-

Mails will be sent to: {{ session.user.mail }}

+

+Click here to generate Android or iOS bundle for current user account.

-{% if session.authority.user_certificate_enrollment %} -

You can click here to generate bundle -for current user account.

-{% endif %} +

Mails will be sent to: {{ session.user.mail }}

{% if session.authority %} @@ -28,9 +26,9 @@ as such require complete reset of X509 infrastructure if some of them needs to b {% endif %} -

User certificate enrollment: -{% if session.authority.user_certificate_enrollment %} - {% if session.authority.user_mutliple_certificates %} +

User enrollment: +{% if session.authority.user_enrollment_allowed %} + {% if session.authority.user_multiple_certificates %} multiple {% else %} single @@ -42,10 +40,20 @@ forbidden

-

Web signed certificate attributes:

+

Machine enrollment: +{% if session.authority.machine_enrollment_allowed %} +allowed +{% else %} +forbidden +{% endif %} +

+ + +

Certificate attributes:

    -
  • Certificate lifetime: {{ session.authority.signature.certificate_lifetime }} days
  • +
  • Server certificate lifetime: {{ session.authority.signature.server_certificate_lifetime }} days
  • +
  • Client certificate lifetime: {{ session.authority.signature.client_certificate_lifetime }} days
  • Revocation list lifetime: {{ session.authority.signature.revocation_list_lifetime }} seconds
@@ -134,13 +142,13 @@ cat example.csr

Paste the contents here and click submit:

- + {% else %} -

Submit a certificate signing request with Certidude:

-
certidude setup client {{session.common_name}}
+

Submit a certificate signing request from Mac OS X, Ubuntu or Fedora:

+
easy_install pip
+pip install certidude
+certidude bootstrap {{session.authority.common_name}}
{% endif %}
    @@ -180,7 +188,8 @@ cat example.csr

    Revoked certificates

    To fetch certificate revocation list:

    curl {{window.location.href}}api/revoked/ > crl.der
    -curl http://ca2.koodur.lan/api/revoked/?wait=yes -H "Accept: application/x-pem-file" > crl.pem
    +curl http://ca2.koodur.lan/api/revoked/ -L -H "Accept: application/x-pem-file" +curl http://ca2.koodur.lan/api/revoked/?wait=yes -L -H "Accept: application/x-pem-file" > crl.pem