diff --git a/MANIFEST.in b/MANIFEST.in index c4dc582..a444f9d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ include README.rst include certidude/templates/*.html include certidude/templates/*.svg +include certidude/templates/*.ovpn +include certidude/templates/*.cnf diff --git a/README.rst b/README.rst index 5aeeb14..2c2d290 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,39 @@ Certidude ========= -Certidude is a novel X.509 Certificate Authority management tool aiming to -support PKCS#11 and in far future WebCrypto +Introduction +------------ + +Certidude is a novel X.509 Certificate Authority management tool +with privilege isolation mechanism aiming to +eventually support PKCS#11 and in far future WebCrypto. + + +Features +-------- + +* Standard request, sign, revoke workflow via web interface. +* Colored command-line interface, check out ``butterknife list`` +* OpenVPN integration, check out ``butterknife setup openvpn server`` and ``butterknife setup openvpn client`` +* Privilege isolation, separate signer process is spawned per private key isolating + private key use from the the web interface. +* Certificate numbering obfuscation, certificate serial numbers are intentionally + randomized to avoid leaking information about business practices. +* Server-side events support via for example nginx-push-stream-module + + +TODO +---- + +* Refactor mailing subsystem and server-side events to use hooks. +* Notifications via e-mail. +* strongSwan setup integration. +* OCSP support. +* Deep mailbox integration, eg fetch CSR-s from mailbox via IMAP. +* 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. + Install ------- @@ -11,7 +42,7 @@ To install Certidude: .. code:: bash - apt-get install python3-openssl + apt-get install python3 python3-dev build-essential pip3 install certidude @@ -22,13 +53,100 @@ Certidude can set up CA relatively easily: .. code:: bash - certidude ca create /path/to/directory + certidude setup authority /path/to/directory Tweak command-line options until you meet your requirements and -finally insert corresponding segment to your /etc/ssl/openssl.cnf +then insert generated section to your /etc/ssl/openssl.cnf Finally serve the certificate authority via web: .. code:: bash certidude serve + + +Certificate management +---------------------- + +Use following command to request a certificate on a machine: + +.. code:: + + certidude setup client http://certidude-hostname-or-ip:perhaps-port/api/ca-name/ + +Use following to list signing requests, certificates and revoked certificates: + +.. code:: + + certidude list + +Use web interface or following to sign a certificate on Certidude server: + +.. code:: + + certidude sign client-hostname-or-common-name + + +Streaming push support +---------------------- + +We support `nginx-push-stream-module `_, +configure it as follows to enable real-time responses to events: + +.. code:: + + user www-data; + worker_processes 4; + pid /run/nginx.pid; + + events { + worker_connections 768; + # multi_accept on; + } + + http { + push_stream_shared_memory_size 32M; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + gzip on; + gzip_disable "msie6"; + + server { + listen 80 default_server; + listen [::]:80 default_server ipv6only=on; + server_name localhost; + + location ~ /event/publish/(.*) { + allow 127.0.0.1; # Allow publishing only from this IP address + push_stream_publisher admin; + push_stream_channels_path $1; + } + + location ~ /event/subscribe/(.*) { + push_stream_channels_path $1; + push_stream_subscriber long-polling; + } + + location /api/ { + proxy_pass http://127.0.0.1:9090/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } + } + + +For ``butterknife serve`` export environment variables: + +.. code:: bash + + export CERTIDUDE_EVENT_PUBLISH = "http://localhost/event/publish/%s" + export CERTIDUDE_EVENT_SUBSCRIBE = "http://localhost/event/subscribe/%s" + certidude server -p 9090 diff --git a/certidude/api.py b/certidude/api.py index 65eb58c..c3bfcd7 100644 --- a/certidude/api.py +++ b/certidude/api.py @@ -3,41 +3,51 @@ import falcon import os import json import types +import urllib.request +import click +from time import sleep +from certidude.wrappers import Request, Certificate +from certidude.mailer import Mailer +from pyasn1.codec.der import decoder from datetime import datetime, date from OpenSSL import crypto -from jinja2 import Environment, PackageLoader -env = Environment(loader=PackageLoader('certidude', 'templates')) +from jinja2 import Environment, PackageLoader, Template + +env = Environment(loader=PackageLoader("certidude", "templates")) RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" def omit(**kwargs): return dict([(key,value) for (key, value) in kwargs.items() if value]) - + def pop_certificate_authority(func): def wrapped(self, req, resp, *args, **kwargs): kwargs["ca"] = self.config.instantiate_authority(kwargs["ca"]) return func(self, req, resp, *args, **kwargs) return wrapped + def validate_common_name(func): def wrapped(*args, **kwargs): if not re.match(RE_HOSTNAME, kwargs["cn"]): raise falcon.HTTPBadRequest("Invalid CN", "Common name supplied with request didn't pass the validation regex") return func(*args, **kwargs) return wrapped - + + class MyEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, datetime): - return obj.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + "Z" + return obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" if isinstance(obj, date): - return obj.strftime('%Y-%m-%d') + return obj.strftime("%Y-%m-%d") if isinstance(obj, map): return tuple(obj) if isinstance(obj, types.GeneratorType): return tuple(obj) return json.JSONEncoder.default(self, obj) + def serialize(func): """ Falcon response serialization @@ -51,13 +61,14 @@ def serialize(func): if not resp.body: if not req.client_accepts_json: raise falcon.HTTPUnsupportedMediaType( - 'This API only supports the JSON media type.', - href='http://docs.examples.com/api/json') - resp.set_header('Content-Type', 'application/json') + "This API only supports the JSON media type.", + href="http://docs.examples.com/api/json") + resp.set_header("Content-Type", "application/json") resp.body = json.dumps(r, cls=MyEncoder) return r return wrapped + def templatize(path): template = env.get_template(path) def wrapper(func): @@ -69,20 +80,30 @@ def templatize(path): resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate"); resp.set_header("Pragma", "no-cache"); resp.set_header("Expires", "0"); - resp.set_header('Content-Type', 'application/json') + resp.set_header("Content-Type", "application/json") resp.body = json.dumps(r, cls=MyEncoder) return r else: - resp.set_header('Content-Type', 'text/html') + resp.set_header("Content-Type", "text/html") resp.body = template.render(request=req, **r) return r return wrapped return wrapper + class CertificateAuthorityBase(object): def __init__(self, config): self.config = config + +class RevocationListResource(CertificateAuthorityBase): + @pop_certificate_authority + def on_get(self, req, resp, ca): + resp.set_header("Content-Type", "application/x-pkcs7-crl") + resp.append_header("Content-Disposition", "attachment; filename=%s.crl" % ca.slug) + resp.body = ca.export_crl() + + class SignedCertificateDetailResource(CertificateAuthorityBase): @pop_certificate_authority @validate_common_name @@ -92,7 +113,7 @@ class SignedCertificateDetailResource(CertificateAuthorityBase): raise falcon.HTTPNotFound() resp.stream = open(path, "rb") resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % cn) - + @pop_certificate_authority @validate_common_name def on_delete(self, req, resp, ca, cn): @@ -105,17 +126,17 @@ class SignedCertificateListResource(CertificateAuthorityBase): def on_get(self, req, resp, ca): for j in authority.get_signed(): yield omit( - key_type=j.key_type(), - key_length=j.key_length(), - subject=j.get_dn(), - issuer=j.get_issuer_dn(), - cn=j.subject.CN, - c=j.subject.C, - st=j.subject.ST, - l=j.subject.L, - o=j.subject.O, - ou=j.subject.OU, - fingerprint=j.get_pubkey_fingerprint()) + key_type=j.key_type, + key_length=j.key_length, + subject=j.distinguished_name, + cn=j.common_name, + c=j.country_code, + st=j.state_or_county, + l=j.city, + o=j.organization, + ou=j.organizational_unit, + fingerprint=j.fingerprint) + class RequestDetailResource(CertificateAuthorityBase): @pop_certificate_authority @@ -128,22 +149,25 @@ class RequestDetailResource(CertificateAuthorityBase): if not os.path.exists(path): raise falcon.HTTPNotFound() resp.stream = open(path, "rb") + resp.append_header("Content-Type", "application/x-x509-user-cert") resp.append_header("Content-Disposition", "attachment; filename=%s.csr" % cn) @pop_certificate_authority - @validate_common_name + @validate_common_name def on_patch(self, req, resp, ca, cn): """ Sign a certificate signing request """ - path = os.path.join(ca.request_dir, cn + ".pem") - if not os.path.exists(path): - raise falcon.HTTPNotFound() - ca.sign(ca.get_request(cn)) + csr = ca.get_request(cn) + cert = ca.sign(csr, overwrite=True, delete=True) + os.unlink(csr.path) resp.body = "Certificate successfully signed" resp.status = falcon.HTTP_201 resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn) + @pop_certificate_authority + def on_delete(self, req, resp, ca, cn): + ca.delete_request(cn) class RequestListResource(CertificateAuthorityBase): @serialize @@ -151,46 +175,133 @@ class RequestListResource(CertificateAuthorityBase): def on_get(self, req, resp, ca): for j in ca.get_requests(): yield omit( - key_type=j.key_type(), - key_length=j.key_length(), - subject=j.get_dn(), - cn=j.subject.CN, - c=j.subject.C, - st=j.subject.ST, - l=j.subject.L, - o=j.subject.O, - ou=j.subject.OU, - fingerprint=j.get_pubkey_fingerprint()) + key_type=j.key_type, + key_length=j.key_length, + subject=j.distinguished_name, + cn=j.common_name, + c=j.country_code, + st=j.state_or_county, + l=j.city, + o=j.organization, + ou=j.organizational_unit, + fingerprint=j.fingerprint()) @pop_certificate_authority def on_post(self, req, resp, ca): - + """ + Submit certificate signing request (CSR) in PEM format + """ + if req.get_header("Content-Type") != "application/pkcs10": raise falcon.HTTPUnsupportedMediaType( "This API call accepts only application/pkcs10 content type") - - # POTENTIAL SECURITY HOLE HERE! - # Should we sanitize input before we handle it to SSL libs? + + body = req.stream.read(req.content_length) + csr = Request(body) + + # Check if this request has been already signed and return corresponding certificte if it has been signed try: - csr = crypto.load_certificate_request( - crypto.FILETYPE_PEM, req.stream.read(req.content_length)) - except crypto.Error: - raise falcon.HTTPBadRequest("Invalid CSR", "Failed to parse request body as PEM") - - common_name = csr.get_subject().CN - - if not re.match(RE_HOSTNAME, common_name): - raise falcon.HTTPBadRequest("Invalid CN", "Common name supplied with CSR did not match validation regex") - - path = os.path.join(ca.request_dir, common_name + ".pem") - with open(path, "wb") as fh: - fh.write(crypto.dump_certificate_request( - crypto.FILETYPE_PEM, csr)) + cert_buf = ca.get_certificate(csr.common_name) + except FileNotFoundError: + pass + else: + cert = Certificate(cert_buf) + if cert.pubkey == csr.pubkey: + resp.status = falcon.HTTP_FOUND + resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name) + return + + # TODO: check for revoked certificates and return HTTP 410 Gone + + # Process automatic signing if the IP address is whitelisted and autosigning was requested + if ca.autosign_allowed(req.env["REMOTE_ADDR"]) and req.get_param("autosign"): + try: + resp.append_header("Content-Type", "application/x-x509-user-cert") + resp.body = ca.sign(req).dump() + return + except FileExistsError: # Certificate already exists, try to save the request + pass + + # Attempt to save the request otherwise + try: + request = ca.store_request(body) + except FileExistsError: + raise falcon.HTTPConflict( + "CSR with such CN already exists", + "Will not overwrite existing certificate signing request, explicitly delete CSR and try again") + + # Wait the certificate to be signed if waiting is requested + if req.get_param("wait"): + url_template = os.getenv("CERTIDUDE_EVENT_SUBSCRIBE") + if url_template: + # Redirect to nginx pub/sub + url = url_template % request.fingerprint() + click.echo("Redirecting to: %s" % url) + resp.status = falcon.HTTP_FOUND + resp.append_header("Location", url) + else: + click.echo("Using dummy streaming mode, please switch to nginx in production!", err=True) + # Dummy streaming mode + while True: + sleep(1) + if not ca.request_exists(csr.common_name): + resp.append_header("Content-Type", "application/x-x509-user-cert") + resp.status = falcon.HTTP_201 # Certificate was created + resp.body = ca.get_certificate(csr.common_name) + break + else: + # Request was accepted, but not processed + resp.status = falcon.HTTP_202 + +class CertificateStatusResource(CertificateAuthorityBase): + """ + openssl ocsp -issuer CAcert_class1.pem -serial 0x -url http://localhost -CAfile cacert_both.pem + """ + def on_post(self, req, resp, ca): + ocsp_request = req.stream.read(req.content_length) + for component in decoder.decode(ocsp_request): + click.echo(component) + resp.append_header("Content-Type", "application/ocsp-response") + resp.status = falcon.HTTP_200 + raise NotImplementedError() class CertificateAuthorityResource(CertificateAuthorityBase): + @pop_certificate_authority + def on_get(self, req, resp, ca): + path = os.path.join(ca.certificate.path) + resp.stream = open(path, "rb") + resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % ca.slug) + +class IndexResource(CertificateAuthorityBase): @templatize("index.html") + @pop_certificate_authority def on_get(self, req, resp, ca): return { - "authority": self.config.instantiate_authority(ca)} - + "authority": ca } + +class ApplicationConfigurationResource(CertificateAuthorityBase): + @validate_common_name + @pop_certificate_authority + def on_get(self, req, resp, ca, cn): + ctx = dict( + cn = cn, + certificate = ca.get_certificate(cn), + ca_certificate = open(ca.certificate.path, "r").read()) + resp.append_header("Content-Type", "application/ovpn") + resp.append_header("Content-Disposition", "attachment; filename=%s.ovpn" % cn) + resp.body = Template(open("/etc/openvpn/%s.template" % ca.slug).read()).render(ctx) + + @validate_common_name + @pop_certificate_authority + def on_put(self, req, resp, ca, cn=None): + pkey_buf, req_buf, cert_buf = ca.create_bundle(cn) + + ctx = dict( + private_key = pkey_buf, + certificate = cert_buf, + ca_certificate = ca.certificate.dump()) + + resp.append_header("Content-Type", "application/ovpn") + resp.append_header("Content-Disposition", "attachment; filename=%s.ovpn" % cn) + resp.body = Template(open("/etc/openvpn/%s.template" % ca.slug).read()).render(ctx) diff --git a/certidude/cli.py b/certidude/cli.py index a3c6ccf..04bb4d7 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -1,54 +1,480 @@ #!/usr/bin/python3 # coding: utf-8 +import sys +import pwd +import random import socket import click import os +import asyncore import time import os import re +import logging +import signal +import netifaces +import urllib.request +from humanize import naturaltime +from ipaddress import ip_network +from time import sleep from datetime import datetime from OpenSSL import crypto +from setproctitle import setproctitle +from certidude.signer import SignServer +from jinja2 import Environment, PackageLoader from certidude.wrappers import CertificateAuthorityConfig, \ - CertificateAuthority, SerialCounter, Certificate, subject2dn + CertificateAuthority, Certificate, subject2dn, Request + +env = Environment(loader=PackageLoader("certidude", "templates")) # Big fat warning: # m2crypto overflows around 2030 because on 32-bit systems # m2crypto does not support hardware engine support (?) # m2crypto CRL object is pretty much useless + # pyopenssl has no straight-forward methods for getting RSA key modulus +# pyopenssl 0.13 bundled with Ubuntu 14.04 has no get_extension_count() for X509Req objects +assert hasattr(crypto.X509Req(), "get_extensions"), "You're running too old version of pyopenssl, upgrade to 0.15+" + # http://www.mad-hacking.net/documentation/linux/security/ssl-tls/creating-ca.xml +# https://kjur.github.io/jsrsasign/ +# keyUsage, extendedKeyUsage - https://www.openssl.org/docs/apps/x509v3_config.html config = CertificateAuthorityConfig("/etc/ssl/openssl.cnf") +# Parse command-line argument defaults from environment +HOSTNAME = socket.gethostname() +USERNAME = os.environ.get("USER") +EMAIL = USERNAME + "@" + HOSTNAME NOW = datetime.utcnow().replace(tzinfo=None) +FIRST_NAME = None +SURNAME = None -@click.command("create", help="Set up Certificate Authority in a directory") +if os.getuid() >= 1000: + _, _, _, _, gecos, _, _ = pwd.getpwnam(USERNAME) + if " " in gecos: + FIRST_NAME, SURNAME = gecos.split(" ", 1) + else: + FIRST_NAME = gecos + +def first_nic_address(): + """ + Return IP address of the first network interface + """ + for interface in netifaces.interfaces(): + if interface == "lo": + continue + for iftype, addresses in netifaces.ifaddresses(interface).items(): + if iftype != 2: + continue + for address in addresses: + return address.pop("addr") + raise ValueError("Unable to determine IP address of first NIC") + +def spawn_signers(kill, no_interaction): + """ + Spawn processes for signers + """ + + os.umask(0o027) + uid = os.getuid() + assert uid == 0, "Not running as root" + + # Preload charmap encoding for byte_string() function of pyOpenSSL + # in order to enable chrooting + "".encode("charmap") + + # Process directories + run_dir = "/run/certidude" + signer_dir = os.path.join(run_dir, "signer") + chroot_dir = os.path.join(signer_dir, "jail") + + # Prepare signer PID-s directory + if not os.path.exists(signer_dir): + click.echo("Creating: %s" % signer_dir) + os.makedirs(signer_dir) + + # Prepare chroot directories + if not os.path.exists(os.path.join(chroot_dir, "dev")): + os.makedirs(os.path.join(chroot_dir, "dev")) + if not os.path.exists(os.path.join(chroot_dir, "dev", "urandom")): + # TODO: use os.mknod instead + os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom")) + + for ca in config.all_authorities(): + + pidfile = "/run/certidude/signer/%s.pid" % ca.slug + + try: + with open(pidfile) as fh: + pid = int(fh.readline()) + os.kill(pid, 0) + click.echo("Found process with PID %d for %s" % (pid, ca.slug)) + except (ValueError, ProcessLookupError, FileNotFoundError): + pid = 0 + + if pid > 0: + if kill: + try: + click.echo("Killing %d" % pid) + os.kill(pid, signal.SIGTERM) + sleep(1) + os.kill(pid, signal.SIGKILL) + sleep(1) + except ProcessLookupError: + pass + else: + continue + + child_pid = os.fork() + + if child_pid == 0: + with open(pidfile, "w") as fh: + fh.write("%d\n" % os.getpid()) + + setproctitle("%s spawn %s" % (sys.argv[0], ca.slug)) + logging.basicConfig( + filename="/var/log/certidude-%s.log" % ca.slug, + level=logging.INFO) + socket_path = os.path.join(signer_dir, ca.slug + ".sock") + click.echo("Spawned certidude signer process with PID %d at %s" % (os.getpid(), socket_path)) + server = SignServer(socket_path, ca.private_key, ca.certificate.path, + ca.lifetime, ca.basic_constraints, ca.key_usage, ca.extended_key_usage) + asyncore.loop() + +def certidude_request_certificate(url, key_path, request_path, certificate_path, authority_path, common_name, org_unit, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None): + """ + Exchange CSR for certificate using Certidude HTTP API server + """ + + # Set up URL-s + request_params = set() + if autosign: + request_params.add("autosign=yes") + if wait: + request_params.add("wait=forever") + + if not url.endswith("/"): + url = url + "/" + + authority_url = url + "certificate" + request_url = url + "request" + + if request_params: + request_url = request_url + "?" + "&".join(request_params) + + if os.path.exists(authority_path): + click.echo("Found CA certificate in: %s" % authority_path) + else: + if authority_url: + click.echo("Attempting to fetch CA certificate from %s" % authority_url) + try: + with urllib.request.urlopen(authority_url) as fh: + buf = fh.read() + try: + cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf) + except crypto.Error: + raise ValueError("Failed to parse PEM: %s" % buf) + with open(authority_path + ".part", "wb") as oh: + oh.write(buf) + click.echo("Writing CA certificate to: %s" % authority_path) + os.rename(authority_path + ".part", authority_path) + except urllib.error.HTTPError as e: + click.echo("Failed to fetch CA certificate, server responded with: %d %s" % (e.code, e.reason), err=True) + return 1 + else: + raise FileNotFoundError("CA certificate not found and no URL specified") + + try: + certificate = Certificate(open(certificate_path)) + click.echo("Found certificate: %s" % certificate_path) + except FileNotFoundError: + try: + request = Request(open(request_path)) + click.echo("Found signing request: %s" % request_path) + except FileNotFoundError: + + # Construct private key + click.echo("Generating 4096-bit RSA key...") + key = crypto.PKey() + key.generate_key(crypto.TYPE_RSA, 4096) + + # Dump private key + os.umask(0o077) + with open(key_path + ".part", "wb") as fh: + fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) + + # Construct CSR + csr = crypto.X509Req() + csr.set_pubkey(key) + request = Request(csr) + + # Set subject attributes + request.common_name = common_name + if given_name: + request.given_name = given_name + if surname: + request.surname = surname + if org_unit: + request.organizational_unit = org_unit + + # Set extensions + extensions = [] + if key_usage: + extensions.append(("keyUsage", key_usage, True)) + if extended_key_usage: + extensions.append(("extendedKeyUsage", extended_key_usage, True)) + if email_address: + extensions.append(("subjectAltName", "email:" + email_address, False)) + request.set_extensions(extensions) + + # Dump CSR + os.umask(0o022) + with open(request_path + ".part", "w") as fh: + fh.write(request.dump()) + + click.echo("Writing private key to: %s" % key_path) + os.rename(key_path + ".part", key_path) + click.echo("Writing certificate signing request to: %s" % request_path) + os.rename(request_path + ".part", request_path) + + + with open(request_path, "rb") as fh: + buf = fh.read() + submission = urllib.request.Request(request_url, buf) + submission.add_header("User-Agent", "Certidude") + submission.add_header("Content-Type", "application/pkcs10") + + click.echo("Submitting to %s, waiting for response..." % request_url) + try: + response = urllib.request.urlopen(submission) + buf = response.read() + cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf) + except crypto.Error: + raise ValueError("Failed to parse PEM: %s" % buf) + except urllib.error.HTTPError as e: + if e.code == 409: + click.echo("Different signing request with same CN is already present on server, server refuses to overwrite", err=True) + return 2 + else: + click.echo("Failed to fetch certificate, server responded with: %d %s" % (e.code, e.reason), err=True) + return 3 + else: + if response.code == 202: + click.echo("Server stored the request for processing (202 Accepted), but waiting was not requested, hence quitting for now", err=True) + return 254 + + os.umask(0o022) + with open(certificate_path + ".part", "wb") as gh: + gh.write(buf) + + click.echo("Writing certificate to: %s" % certificate_path) + os.rename(certificate_path + ".part", certificate_path) + + # TODO: Validate fetched certificate against CA + # TODO: Check that recevied certificate CN and pubkey match + # TODO: Check file permissions + + +@click.command("spawn", help="Run privilege isolated signer processes") +@click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instances") +@click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys") +def certidude_spawn(**args): + spawn_signers(**args) + + +@click.command("client", help="Setup X.509 certificates for application") +@click.argument("url") #, help="Certidude authority endpoint URL") +@click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, '%s' by default" % HOSTNAME) +@click.option("--org-unit", "-ou", help="Organizational unit") +@click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, '%s' by default" % EMAIL) +@click.option("--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=HOSTNAME + ".key", help="Key path, %s.key by default" % HOSTNAME) +@click.option("--request-path", "-r", default=HOSTNAME + ".csr", help="Request path, %s.csr by default" % HOSTNAME) +@click.option("--certificate-path", "-c", default=HOSTNAME + ".crt", help="Certificate path, %s.crt by default" % HOSTNAME) +@click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt by default") +def certidude_setup_client(quiet, **kwargs): + return certidude_request_certificate(**kwargs) + + +@click.command("server", help="Set up OpenVPN server") +@click.argument("url") +@click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, %s by default" % HOSTNAME) +@click.option("--org-unit", "-ou", help="Organizational unit") +@click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, '%s' by default" % EMAIL) +@click.option("--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=first_nic_address(), help="OpenVPN listening address, %s" % first_nic_address()) +@click.option("--port", "-p", default=1194, type=click.IntRange(1,60000), help="OpenVPN listening port, 1194 by default") +@click.option('--proto', "-t", default="udp", type=click.Choice(['udp', 'tcp']), help="OpenVPN transport protocol, UDP by default") +@click.option("--config", "-o", + default="/etc/openvpn/site-to-client.conf", + type=click.File(mode="w", atomic=True, lazy=True), + help="OpenVPN configuration file") +@click.option("--directory", "-d", default="/etc/openvpn/keys", help="Directory for keys, /etc/openvpn/keys by default") +@click.option("--key-path", "-k", default=HOSTNAME + ".key", help="Key path, %s.key relative to --directory by default" % HOSTNAME) +@click.option("--request-path", "-r", default=HOSTNAME + ".csr", help="Request path, %s.csr relative to --directory by default" % HOSTNAME) +@click.option("--certificate-path", "-c", default=HOSTNAME + ".crt", help="Certificate path, %s.crt relative to --directory by default" % HOSTNAME) +@click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to --dir by default") +def certidude_setup_openvpn_server(url, config, subnet, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, local, proto, port): + # 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 + + if directory: + if not os.path.exists(directory): + click.echo("Making directory: %s" % directory) + os.makedirs(directory) + key_path = os.path.join(directory, key_path) + certificate_path = os.path.join(directory, certificate_path) + request_path = os.path.join(directory, request_path) + authority_path = os.path.join(directory, authority_path) + + if not os.path.exists(certificate_path): + click.echo("As OpenVPN server certificate needs specific key usage extensions please") + click.echo("use following command to sign on Certidude server instead of web interface:") + click.echo() + click.echo(" certidude sign %s" % common_name) + + retval = certidude_request_certificate( + url, + key_path, + request_path, + certificate_path, + authority_path, + common_name, + org_unit, + email_address, + key_usage="nonRepudiation,digitalSignature,keyEncipherment", + extended_key_usage="serverAuth", + wait=True) + + if retval: + return retval + + # TODO: Add dhparam + config.write(env.get_template("site-to-client.ovpn").render(locals())) + + click.echo("Generated %s" % config.name) + click.echo() + click.echo("Inspect newly created %s and start OpenVPN service:" % config.name) + click.echo() + click.secho(" service openvpn restart", bold=True) + click.echo() + + +@click.command("client", help="Set up OpenVPN client") +@click.argument("url") +@click.argument("remote") +@click.option('--proto', "-t", default="udp", type=click.Choice(['udp', 'tcp']), help="OpenVPN transport protocol, UDP by default") +@click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, %s by default" % HOSTNAME) +@click.option("--org-unit", "-ou", help="Organizational unit") +@click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, '%s' by default" % EMAIL) +@click.option("--config", "-o", + default="/etc/openvpn/client-to-site.conf", + type=click.File(mode="w", atomic=True, lazy=True), + help="OpenVPN configuration file") +@click.option("--directory", "-d", default="/etc/openvpn/keys", help="Directory for keys, /etc/openvpn/keys by default") +@click.option("--key-path", "-k", default=HOSTNAME + ".key", help="Key path, %s.key relative to --directory by default" % HOSTNAME) +@click.option("--request-path", "-r", default=HOSTNAME + ".csr", help="Request path, %s.csr relative to --directory by default" % HOSTNAME) +@click.option("--certificate-path", "-c", default=HOSTNAME + ".crt", help="Certificate path, %s.crt relative to --directory by default" % HOSTNAME) +@click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to --dir by default") +def certidude_setup_openvpn_client(url, config, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, proto, remote): + + if directory: + if not os.path.exists(directory): + click.echo("Making directory: %s" % directory) + os.makedirs(directory) + key_path = os.path.join(directory, key_path) + certificate_path = os.path.join(directory, certificate_path) + request_path = os.path.join(directory, request_path) + authority_path = os.path.join(directory, authority_path) + + retval = certidude_request_certificate( + url, + key_path, + request_path, + certificate_path, + authority_path, + common_name, + org_unit, + email_address, + wait=True) + + if retval: + return retval + + # TODO: Add dhparam + config.write(env.get_template("client-to-site.ovpn").render(locals())) + + click.echo("Generated %s" % config.name) + click.echo() + click.echo("Inspect newly created %s and start OpenVPN service:" % config.name) + click.echo() + click.echo(" service openvpn restart") + click.echo() + + +@click.command("authority", help="Set up Certificate Authority in a directory") +@click.option("--group", "-g", default="certidude", help="Group for file permissions, certidude by default") @click.option("--parent", "-p", help="Parent CA, none by default") -@click.option("--common-name", "-cn", default=socket.gethostname(), help="Common name, hostname by default") +@click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, hostname by default") @click.option("--country", "-c", default="ee", help="Country, Estonia by default") @click.option("--state", "-s", default="Harjumaa", help="State or country, Harjumaa by default") @click.option("--locality", "-l", default="Tallinn", help="City or locality, Tallinn by default") -@click.option("--lifetime", default=20, help="Lifetime in years") +@click.option("--lifetime", default=20*365, help="Lifetime in days, 7300 days (20 years) by default") @click.option("--organization", "-o", default="Example LLC", help="Company or organization name") @click.option("--organizational-unit", "-ou", default="Certification Department") @click.option("--crl-age", default=1, help="CRL expiration age, 1 day by default") @click.option("--pkcs11", default=False, is_flag=True, help="Use PKCS#11 token instead of files") +@click.option("--crl-distribution-url", default=None, help="CRL distribution URL") +@click.option("--ocsp-responder-url", default=None, help="OCSP responder URL") +@click.option("--email-address", default=EMAIL, help="CA e-mail address") +@click.option("--inbox", default="imap://user:pass@host:port/INBOX", help="Inbound e-mail server") +@click.option("--outbox", default="smtp://localhost", help="Outbound e-mail server") @click.argument("directory") -def ca_create(parent, country, state, locality, organization, organizational_unit, common_name, directory, crl_age, lifetime, pkcs11): +def certidude_setup_authority(parent, country, state, locality, organization, organizational_unit, common_name, directory, crl_age, lifetime, pkcs11, group, crl_distribution_url, ocsp_responder_url, email_address, inbox, outbox): + logging.info("Creating certificate authority in %s", directory) + _, _, uid, gid, gecos, root, shell = pwd.getpwnam(group) + os.setgid(gid) + click.echo("Generating 4096-bit RSA key...") - + if pkcs11: raise NotImplementedError("Hardware token support not yet implemented!") else: key = crypto.PKey() key.generate_key(crypto.TYPE_RSA, 4096) + slug = os.path.basename(directory) - crl_distribution_points = "URI:http://%s/api/%s/revoked/" % (common_name, slug) + + if not crl_distribution_url: + crl_distribution_url = "http://%s/api/%s/revoked/" % (common_name, slug) + + # File paths + ca_key = os.path.join(directory, "ca_key.pem") + ca_crt = os.path.join(directory, "ca_crt.pem") + ca_crl = os.path.join(directory, "ca_crl.pem") + crl_distribution_points = "URI:%s" % crl_distribution_url + ca = crypto.X509() - ca.set_version(3) + #ca.set_version(3) # breaks gcr-viewer?! ca.set_serial_number(1) ca.get_subject().CN = common_name ca.get_subject().C = country @@ -57,14 +483,14 @@ def ca_create(parent, country, state, locality, organization, organizational_uni ca.get_subject().O = organization ca.get_subject().OU = organizational_unit ca.gmtime_adj_notBefore(0) - ca.gmtime_adj_notAfter(lifetime * 365 * 24 * 60 * 60) + ca.gmtime_adj_notAfter(lifetime * 24 * 60 * 60) ca.set_issuer(ca.get_subject()) ca.set_pubkey(key) ca.add_extensions([ crypto.X509Extension( b"basicConstraints", True, - b"CA:TRUE, pathlen:0"), + b"CA:TRUE"), crypto.X509Extension( b"keyUsage", True, @@ -80,74 +506,170 @@ def ca_create(parent, country, state, locality, organization, organizational_uni crl_distribution_points.encode("ascii")) ]) + if email_address: + subject_alt_name = "email:%s" % email_address + ca.add_extensions([ + crypto.X509Extension( + b"subjectAltName", + False, + subject_alt_name.encode("ascii")) + ]) + + if not ocsp_responder_url: + ocsp_responder_url = "http://%s/api/%s/ocsp/" % (common_name, slug) + authority_info_access = "OCSP;URI:%s" % ocsp_responder_url + ca.add_extensions([ + crypto.X509Extension( + b"authorityInfoAccess", + False, + authority_info_access.encode("ascii")) + ]) + click.echo("Signing %s..." % subject2dn(ca.get_subject())) + + # openssl x509 -in ca_crt.pem -outform DER | sha1sum + # openssl x509 -fingerprint -in ca_crt.pem + ca.sign(key, "sha1") - + + os.umask(0o027) if not os.path.exists(directory): os.makedirs(directory) + + os.umask(0o007) + for subdir in ("signed", "requests", "revoked"): if not os.path.exists(os.path.join(directory, subdir)): os.mkdir(os.path.join(directory, subdir)) - with open(os.path.join(directory, "ca_key.pem"), "wb") as fh: - fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) - with open(os.path.join(directory, "ca_crt.pem"), "wb") as fh: - fh.write(crypto.dump_certificate(crypto.FILETYPE_PEM, ca)) - with open(os.path.join(directory, "ca_crl.pem"), "wb") as fh: + with open(ca_crl, "wb") as fh: crl = crypto.CRL() fh.write(crl.export(ca, key, days=crl_age)) with open(os.path.join(directory, "serial"), "w") as fh: fh.write("1") - + os.umask(0o027) + with open(ca_crt, "wb") as fh: + fh.write(crypto.dump_certificate(crypto.FILETYPE_PEM, ca)) + + + os.umask(0o077) + with open(ca_key, "wb") as fh: + fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) + + click.echo("Insert following to /etc/ssl/openssl.cnf:") click.echo() - click.echo("Add following to your /etc/ssl/openssl.cnf:") - click.echo() - click.echo("[CA_%s]" % slug) - click.echo("dir = %s" % directory) - click.echo("private_key = $dir/ca_key.pem") - click.echo("certificate = $dir/ca_crt.pem") - click.echo("new_certs_dir = $dir/requests/") - click.echo("revoked_certs_dir = $dir/revoked/") - click.echo("certs = $dir/signed/") - click.echo("crl = $dir/ca_crl.pem") - click.echo("serial = $dir/serial") - click.echo("crlDistributionPoints = %s" % crl_distribution_points) + click.secho(env.get_template("openssl.cnf").render(locals()), fg="blue") click.echo() click.echo("Use following commands to inspect the newly created files:") click.echo() - click.echo(" openssl crl -inform PEM -text -noout -in %s" % os.path.join(directory, "ca_crl.pem")) - click.echo(" openssl x509 -in %s -text -noout" % os.path.join(directory, "ca_crt.pem")) - click.echo(" openssl rsa -in %s -check" % os.path.join(directory, "ca_key.pem")) + click.echo(" openssl crl -inform PEM -text -noout -in %s" % ca_crl) + click.echo(" openssl x509 -text -noout -in %s" % ca_crt) + click.echo(" openssl rsa -check -in %s" % ca_key) + click.echo(" openssl verify -CAfile %s %s" % (ca_crt, ca_crt)) + click.echo() + click.echo("Use following to launch privilege isolated signer processes:") + click.echo() + click.echo(" certidude spawn") click.echo() click.echo("Use following command to serve CA read-only:") click.echo() click.echo(" certidude serve") - -@click.command("list", help="List Certificate Authorities") -def ca_list(): - for ca in config.all_authorities(): - click.echo("Certificate authority '%s'" % ca.certificate.get_dn()) - if ca.certificate.not_before < NOW and ca.certificate.not_after > NOW: - click.echo(" ✓ Certificate valid %s" % (ca.certificate.not_after - NOW)) - elif NOW > ca.certificate.not_after: - click.echo(" ✗ Certificate expired") + +@click.command("list", help="List certificates") +@click.argument("ca", nargs=-1) +@click.option("--show-key-type", "-k", default=False, is_flag=True, help="Show key type and length") +@click.option("--show-path", "-p", default=False, is_flag=True, help="Show filesystem paths") +@click.option("--show-extensions", "-e", default=False, is_flag=True, help="Show X.509 Certificate Extensions") +def certidude_list(ca, show_key_type, show_extensions, show_path): + from pycountry import countries + def dump_common(j): + if show_path: + click.echo(" | | Path: %s" % j.path) + + 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) + elif j.key_usage: + click.echo(" | | Key usage: " + j.key_usage) + click.echo(" | |") + + for ca in config.all_authorities(): + click.echo("Certificate authority " + click.style(ca.slug, fg="blue")) +# if ca.certificate.email_address: +# click.echo(" \u2709 %s" % ca.certificate.email_address) + + if ca.certificate.signed < NOW and ca.certificate.expires > NOW: + print(ca.certificate.expires) + click.echo(" | \u2713 Certificate: " + click.style("valid", fg="green") + ", %s" % ca.certificate.expires) + elif NOW > ca.certificate.expires: + click.echo(" | \u2717 Certificate: " + click.style("expired", fg="red")) else: - click.echo(" ✗ Certificate authority not valid yet") - + click.echo(" | \u2717 Certificate: " + click.style("not valid yet", fg="red")) + if os.path.exists(ca.private_key): - click.echo(" ✓ Private key %s okay" % ca.private_key) + click.echo(" | \u2713 Private key " + ca.private_key + ": " + click.style("okay", fg="green")) + # TODO: Check permissions else: - click.echo(" ✗ Private key %s does not exist" % ca.private_key) - + click.echo(" | \u2717 Private key " + ca.private_key + ": " + click.style("does not exist", fg="red")) + if os.path.isdir(ca.signed_dir): - click.echo(" ✓ Signed certificates directory %s okay" % ca.signed_dir) + click.echo(" | \u2713 Signed certificates directory " + ca.signed_dir + ": " + click.style("okay", fg="green")) else: - click.echo(" ✗ Signed certificates directory %s okay" % ca.signed_dir) - - click.echo(" Revoked certificates directory: %s" % ca.revoked_dir) - click.echo(" Revocation list: %s" % ca.revocation_list) + click.echo(" | \u2717 Signed certificates directory " + ca.signed_dir + ": " + click.style("does not exist", fg="red")) + + if ca.revoked_dir: + click.echo(" | Revoked certificates directory: %s" % ca.revoked_dir) + + click.echo(" +-- Pending requests") + + for j in ca.get_requests(): + click.echo(" | +-- Request " + click.style(j.common_name, fg="blue")) + click.echo(" | | Submitted: %s, %s" % (naturaltime(j.created), j.created)) + dump_common(j) + + click.echo(" +-- Signed certificates") + + for j in ca.get_signed(): + click.echo(" | +-- Certificate " + click.style(j.common_name, fg="blue") + " " + click.style(":".join(re.findall("\d\d", j.serial_number)), fg="white")) + + if j.signed < NOW and j.expires > NOW: + click.echo(" | | \u2713 Certificate " + click.style("valid", fg="green") + " " + naturaltime(j.expires)) + elif NOW > j.expires: + click.echo(" | | \u2717 Certificate " + click.style("expired", fg="red") + " " + naturaltime(j.expires)) + else: + click.echo(" | | \u2717 Certificate " + click.style("not valid yet", fg="red")) + dump_common(j) + + click.echo(" +-- Revocations") + + for j in ca.get_revoked(): + click.echo(" | +-- Revocation " + click.style(j.common_name, fg="blue") + " " + click.style(":".join(re.findall("\d\d", j.serial_number)), fg="white")) + # click.echo(" | | Serial: %s" % ":".join(re.findall("\d\d", j.serial_number))) + if show_path: + click.echo(" | | Path: %s" % j.path) + click.echo(" | | Revoked: %s%s" % (naturaltime(NOW-j.changed), click.style(", %s" % j.changed, fg="white"))) + dump_common(j) click.echo() @@ -155,8 +677,9 @@ def ca_list(): @click.argument("ca") @config.pop_certificate_authority() def cert_list(ca): + mapping = {} - + click.echo("Listing certificates for: %s" % ca.certificate.subject.CN) for serial, reason, timestamp in ca.get_revoked(): @@ -164,8 +687,8 @@ def cert_list(ca): for certificate in ca.get_signed(): mapping[certificate.serial] = certificate, None - - for serial, (certificate, reason) in sorted(mapping.items(), key=lambda j:j[0]): + + for serial, (certificate, reason) in sorted(mapping.items(), key=lambda j:j[0]): if not reason: click.echo(" %03d. %s %s" % (serial, certificate.subject.CN, (certificate.not_after-NOW))) else: @@ -174,12 +697,51 @@ def cert_list(ca): for request in ca.get_requests(): click.echo(" ⌛ %s" % request.subject.CN) +@click.command("sign", help="Sign certificates") +@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): + def iterate(): + for ca in config.all_authorities(): + for request in ca.get_requests(): + if request.common_name != common_name: + continue + print(request.fingerprint(), request.common_name, request.path, request.key_usage) + yield ca, request + + results = tuple(iterate()) + click.echo() + + click.echo("Press Ctrl-C to cancel singing these requests...") + sys.stdin.readline() + + for ca, request in results: + if request.signable: + # Sign via signer process + cert = ca.sign(request) + else: + # Sign directly using private key + cert = ca.sign2(request, overwrite, True, lifetime) + os.unlink(request.path) + click.echo("Signed %s" % cert.distinguished_name) + for key, value, data in cert.extensions: + click.echo("Added extension %s: %s" % (key, value)) + click.echo() + + @click.command("serve", help="Run built-in HTTP server") -@click.option("-u", "--user", default=None, help="Run as user") +@click.option("-u", "--user", default="certidude", help="Run as user") @click.option("-p", "--port", default=80, help="Listen port") @click.option("-l", "--listen", default="0.0.0.0", help="Listen address") @click.option("-s", "--enable-signature", default=False, is_flag=True, help="Allow signing operations with private key of CA") -def serve(user, port, listen, enable_signature): +def certidude_serve(user, port, listen, enable_signature): + spawn_signers(kill=False, no_interaction=False) + + logging.basicConfig( + filename='/var/log/certidude.log', + level=logging.DEBUG) + click.echo("Serving API at %s:%d" % (listen, port)) import pwd import falcon @@ -187,18 +749,24 @@ def serve(user, port, listen, enable_signature): from socketserver import ThreadingMixIn from certidude.api import CertificateAuthorityResource, \ RequestDetailResource, RequestListResource, \ - SignedCertificateDetailResource, SignedCertificateListResource + SignedCertificateDetailResource, SignedCertificateListResource, \ + RevocationListResource, IndexResource, ApplicationConfigurationResource, \ + CertificateStatusResource - class ThreadingWSGIServer(ThreadingMixIn, WSGIServer): + class ThreadingWSGIServer(ThreadingMixIn, WSGIServer): pass click.echo("Listening on %s:%d" % (listen, port)) - + app = falcon.API() + app.add_route("/api/{ca}/ocsp/", CertificateStatusResource(config)) + app.add_route("/api/{ca}/signed/{cn}/openvpn", ApplicationConfigurationResource(config)) + app.add_route("/api/{ca}/certificate/", CertificateAuthorityResource(config)) + app.add_route("/api/{ca}/revoked/", RevocationListResource(config)) app.add_route("/api/{ca}/signed/{cn}/", SignedCertificateDetailResource(config)) app.add_route("/api/{ca}/signed/", SignedCertificateListResource(config)) app.add_route("/api/{ca}/request/{cn}/", RequestDetailResource(config)) app.add_route("/api/{ca}/request/", RequestListResource(config)) - app.add_route("/api/{ca}/", CertificateAuthorityResource(config)) + app.add_route("/api/{ca}/", IndexResource(config)) httpd = make_server(listen, port, app, ThreadingWSGIServer) if user: _, _, uid, gid, gecos, root, shell = pwd.getpwnam(user) @@ -208,23 +776,27 @@ def serve(user, port, listen, enable_signature): click.echo("Switching to user %s (uid=%d, gid=%d)" % (user, uid, gid)) os.setgid(gid) os.setuid(uid) + os.umask(0o007) elif os.getuid() == 0: click.echo("Warning: running as root, this is not reccommended!") httpd.serve_forever() -@click.group(help="Certificate Authority management") -def ca(): pass +@click.group("openvpn", help="OpenVPN helpers") +def certidude_setup_openvpn(): pass -@click.group(help="Certificate management") -def cert(): pass - -cert.add_command(cert_list) -ca.add_command(ca_create) -ca.add_command(ca_list) +@click.group("setup", help="Getting started section") +def certidude_setup(): pass @click.group() def entry_point(): pass -entry_point.add_command(ca) -entry_point.add_command(cert) -entry_point.add_command(serve) +certidude_setup_openvpn.add_command(certidude_setup_openvpn_server) +certidude_setup_openvpn.add_command(certidude_setup_openvpn_client) +certidude_setup.add_command(certidude_setup_authority) +certidude_setup.add_command(certidude_setup_openvpn) +certidude_setup.add_command(certidude_setup_client) +entry_point.add_command(certidude_setup) +entry_point.add_command(certidude_serve) +entry_point.add_command(certidude_spawn) +entry_point.add_command(certidude_sign) +entry_point.add_command(certidude_list) diff --git a/certidude/mailer.py b/certidude/mailer.py new file mode 100644 index 0000000..df21f71 --- /dev/null +++ b/certidude/mailer.py @@ -0,0 +1,104 @@ + +import os +import smtplib +from time import sleep +from jinja2 import Environment, PackageLoader +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from urllib.parse import urlparse + +class Mailer(object): + def __init__(self, url): + scheme, netloc, path, params, query, fragment = urlparse(url) + scheme = scheme.lower() + + if path: + raise ValueError("Path for URL not supported") + if params: + raise ValueError("Parameters for URL not supported") + if query: + raise ValueError("Query for URL not supported") + if fragment: + raise ValueError("Fragment for URL not supported") + + + self.username = None + self.password = "" + + if scheme == "smtp": + self.secure = False + self.port = 25 + elif scheme == "smtps": + self.secure = True + self.port = 465 + else: + raise ValueError("Unknown scheme '%s', currently SMTP and SMTPS are only supported" % scheme) + + if "@" in netloc: + credentials, netloc = netloc.split("@") + + if ":" in credentials: + self.username, self.password = credentials.split(":") + else: + self.username = credentials + + if ":" in netloc: + self.server, port_str = netloc.split(":") + self.port = int(port_str) + else: + self.server = netloc + + self.env = Environment(loader=PackageLoader("certidude", "email_templates")) + self.conn = None + + def reconnect(self): + # Gmail employs some sort of IPS + # https://accounts.google.com/DisplayUnlockCaptcha + print("Connecting to:", self.server, self.port) + self.conn = smtplib.SMTP(self.server, self.port) + if self.secure: + self.conn.starttls() + if self.username and self.password: + self.conn.login(self.username, self.password) + + def enqueue(self, sender, recipients, subject, template, **context): + self.send(sender, recipients, subject, template, **context) + + + def send(self, sender, recipients, subject, template, **context): + + recipients = [j for j in recipients if j] + + if not recipients: + print("No recipients to send e-mail to!") + return + print("Sending e-mail to:", recipients, "body follows:") + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = sender + msg["To"] = ", ".join(recipients) + + text = self.env.get_template(template + ".txt").render(context) + html = self.env.get_template(template + ".html").render(context) + + print(text) + + part1 = MIMEText(text, "plain") + part2 = MIMEText(html, "html") + + msg.attach(part1) + msg.attach(part2) + + backoff = 1 + while True: + try: + if not self.conn: + self.reconnect() + self.conn.sendmail(sender, recipients, msg.as_string()) + return + except smtplib.SMTPServerDisconnected: + print("Connection to %s unexpectedly closed, probably TCP timeout, backing off for %d second" % (self.server, backoff)) + self.reconnect() + backoff = backoff * 2 + sleep(backoff) diff --git a/certidude/signer.py b/certidude/signer.py new file mode 100644 index 0000000..6c8dc88 --- /dev/null +++ b/certidude/signer.py @@ -0,0 +1,203 @@ + + +import random +import pwd +import socket +import os +import asyncore +import asynchat +from datetime import datetime +from OpenSSL import crypto + +""" +Signer processes are spawned per private key. +Private key should only be readable by root. +Signer process starts up as root, reads private key, +drops privileges and awaits for opcodes (sign-request, export-crl) at UNIX domain socket +under /run/certidude/signer/ +The main motivation behind the concept is to mitigate private key leaks +by confining it to a separate process. + +Note that signer process uses basicConstraints, keyUsage and extendedKeyUsage +attributes from openssl.cnf via CertificateAuthority wrapper class. +Hence it's possible only to sign such certificates via the signer process, +making it hard to take advantage of hacked Certidude server, eg. being able to sign +certificate authoirty (basicConstraints=CA:TRUE) or +TLS server certificates (extendedKeyUsage=serverAuth). +""" + +EXTENSION_WHITELIST = set(["subjectAltName"]) + +def raw_sign(private_key, ca_cert, request, basic_constraints, lifetime, key_usage=None, extended_key_usage=None): + """ + Sign certificate signing request directly with private key assuming it's readable by the process + """ + + cert = crypto.X509() + + + cert.set_pubkey(request.get_pubkey()) + + # TODO: Assert openssl.cnf policy for subject attributes +# if request.get_subject().O != ca_cert.get_subject().O: +# raise ValueError("Orgnization name mismatch!") +# if request.get_subject().C != ca_cert.get_subject().C: +# raise ValueError("Country mismatch!") + + # Copy attributes from CA + cert.get_subject().C = ca_cert.get_subject().C + cert.get_subject().ST = ca_cert.get_subject().ST + cert.get_subject().L = ca_cert.get_subject().L + cert.get_subject().O = ca_cert.get_subject().O + + # Copy attributes from request + cert.get_subject().CN = request.get_subject().CN + req_subject = request.get_subject() + if hasattr(req_subject, "OU") and req_subject.OU: + cert.get_subject().OU = req_subject.OU + + # Copy e-mail, key usage, extended key from request + for extension in request.get_extensions(): + cert.add_extensions([extension]) + + # TODO: Set keyUsage and extendedKeyUsage defaults if none has been provided in the request + + # Override basic constraints if nececssary + if basic_constraints: + cert.add_extensions([ + crypto.X509Extension( + b"basicConstraints", + True, + basic_constraints.encode("ascii"))]) + + if key_usage: + try: + cert.add_extensions([ + crypto.X509Extension( + b"keyUsage", + True, + key_usage.encode("ascii"))]) + except crypto.Error: + raise ValueError("Invalid value '%s' for keyUsage attribute" % key_usage) + + if extended_key_usage: + cert.add_extensions([ + crypto.X509Extension( + b"extendedKeyUsage", + True, + extended_key_usage.encode("ascii"))]) + + # Set certificate lifetime + cert.gmtime_adj_notBefore(0) + cert.gmtime_adj_notAfter(lifetime * 24 * 60 * 60) + + # Generate serial from 0x10000000000000000000 to 0xffffffffffffffffffff + cert.set_serial_number(random.randint( + 0x1000000000000000000000000000000000000000, + 0xffffffffffffffffffffffffffffffffffffffff)) + cert.sign(private_key, 'sha1') + return cert + + +class SignHandler(asynchat.async_chat): + def __init__(self, sock, server): + asynchat.async_chat.__init__(self, sock=sock) + self.buffer = [] + self.set_terminator(b"\n\n") + self.server = server + + def parse_command(self, cmd, body=""): + + if cmd == "export-crl": + """ + Generate CRL object based on certificate serial number and revocation timestamp + """ + crl = crypto.CRL() + + if body: + for line in body.split("\n"): + serial_number, timestamp = line.split(":") + # TODO: Assert serial against regex + revocation = crypto.Revoked() + revocation.set_rev_date(datetime.fromtimestamp(int(timestamp)).strftime("%Y%m%d%H%M%SZ").encode("ascii")) + revocation.set_reason(b"keyCompromise") + revocation.set_serial(serial_number.encode("ascii")) + crl.add_revoked(revocation) + + self.send(crl.export( + self.server.certificate, + self.server.private_key, + crypto.FILETYPE_PEM)) + + elif cmd == "ocsp-request": + NotImplemented # TODO: Implement OCSP + + elif cmd == "sign-request": + request = crypto.load_certificate_request(crypto.FILETYPE_PEM, body) + + for e in request.get_extensions(): + key = e.get_short_name().decode("ascii") + if key not in EXTENSION_WHITELIST: + raise ValueError("Certificte Signing Request contains extension '%s' which is not whitelisted" % key) + + # TODO: Potential exploits during PEM parsing? + cert = raw_sign( + self.server.private_key, + self.server.certificate, + request, + basic_constraints=self.server.basic_constraints, + key_usage=self.server.key_usage, + extended_key_usage=self.server.extended_key_usage, + lifetime=self.server.lifetime) + self.send(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) + else: + raise NotImplementedError("Unknown command: %s" % cmd) + + self.close_when_done() + + def found_terminator(self): + args = (b"".join(self.buffer)).decode("ascii").split("\n", 1) + self.parse_command(*args) + self.buffer = [] + + def collect_incoming_data(self, data): + self.buffer.append(data) + + +class SignServer(asyncore.dispatcher): + def __init__(self, socket_path, private_key, certificate, lifetime, basic_constraints, key_usage, extended_key_usage): + asyncore.dispatcher.__init__(self) + + # Bind to sockets + if os.path.exists(socket_path): + os.unlink(socket_path) + os.umask(0o007) + self.create_socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.bind(socket_path) + self.listen(5) + + # Load CA private key and certificate + self.private_key = crypto.load_privatekey(crypto.FILETYPE_PEM, open(private_key).read()) + self.certificate = crypto.load_certificate(crypto.FILETYPE_PEM, open(certificate).read()) + self.lifetime = lifetime + self.basic_constraints = basic_constraints + self.key_usage = key_usage + self.extended_key_usage = extended_key_usage + + + # Perhaps perform chroot as well, currently results in + # (:[('random number generator', 'SSLEAY_RAND_BYTES', 'PRNG not seeded') + # probably needs partially populated /dev in chroot + + # Dropping privileges + _, _, uid, gid, gecos, root, shell = pwd.getpwnam("nobody") + os.chroot("/run/certidude/signer/jail") + os.setgid(gid) + os.setuid(uid) + + def handle_accept(self): + pair = self.accept() + if pair is not None: + sock, addr = pair + handler = SignHandler(sock, self) + diff --git a/certidude/templates/client-to-site.ovpn b/certidude/templates/client-to-site.ovpn new file mode 100644 index 0000000..24944cf --- /dev/null +++ b/certidude/templates/client-to-site.ovpn @@ -0,0 +1,12 @@ +client +remote {{remote}} +proto {{proto}} +dev tap0 +nobind +key {{key_path}} +cert {{certificate_path}} +ca {{authority_path}} +comp-lzo +user nobody +group nogroup + diff --git a/certidude/templates/iconmonstr-certificate-15-icon.svg b/certidude/templates/iconmonstr-certificate-15-icon.svg index 97eef7d..8b27cec 100644 --- a/certidude/templates/iconmonstr-certificate-15-icon.svg +++ b/certidude/templates/iconmonstr-certificate-15-icon.svg @@ -4,7 +4,7 @@ + width="32px" height="32px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve"> + + + + + + + + + + + + + diff --git a/certidude/templates/iconmonstr-flag-3-icon.svg b/certidude/templates/iconmonstr-flag-3-icon.svg new file mode 100644 index 0000000..8e12498 --- /dev/null +++ b/certidude/templates/iconmonstr-flag-3-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/certidude/templates/iconmonstr-key-2-icon.svg b/certidude/templates/iconmonstr-key-2-icon.svg new file mode 100644 index 0000000..1301a5c --- /dev/null +++ b/certidude/templates/iconmonstr-key-2-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/certidude/templates/iconmonstr-time-13-icon.svg b/certidude/templates/iconmonstr-time-13-icon.svg index 173bdfc..189521b 100644 --- a/certidude/templates/iconmonstr-time-13-icon.svg +++ b/certidude/templates/iconmonstr-time-13-icon.svg @@ -5,7 +5,7 @@ You may NOT sub-license, resell, rent, redistribute or otherwise transfer the ic + width="32px" height="32px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve"> Certidude server @@ -140,18 +141,37 @@ {% set s = authority.certificate.subject %} -

To submit new certificate signing request:

+ + +

Assuming you have Certidude installed

+ +
+certidude setup client {{request.url}}
+
+ +

To set up OpenVPN server

+
+certidude setup openvpn server {{request.url}}
+
+ +

Or to set up OpenVPN client

+
+certidude setup openvpn client {{request.url}}
 

Pending requests

@@ -159,46 +179,123 @@ curl -f {{ request.url }}/signed/$CN > $CN.crt
    {% for j in authority.get_requests() %}
  • - {% include 'iconmonstr-time-13-icon.svg' %} - {{ j.get_dn() }} - {{ j.get_pubkey_fingerprint().upper() }} + Fetch + {% if j.signable %} + + {% else %} + + {% endif %} + - Fetch - - -
    - {{ j.key_length() }}-bit {{ j.key_type() }} + +
    + {% include 'iconmonstr-certificate-15-icon.svg' %} + {{j.distinguished_name}} +
    + + {% if j.email_address %} + + {% endif %} + +
    + {% include 'iconmonstr-key-2-icon.svg' %} + + {{ j.fingerprint() }} + + {{ j.key_length }}-bit + {{ j.key_type }} +
    + + {% set key_usage = j.key_usage %} + {% if key_usage %} +
    + {% include 'iconmonstr-flag-3-icon.svg' %} + {{j.key_usage}} +
    + {% endif %}
  • + {% else %} +
  • Great job! No certificate signing requests to sign.
  • {% endfor %}

Signed certificates

+ + +

You can fetch a certificate by common name signing the request

+ +
+curl -f {{request.url}}/signed/$CN > $CN.crt
+
+
    {% for j in authority.get_signed() | sort | reverse %}
  • - - {% include 'iconmonstr-certificate-15-icon.svg' %} - {{ j.serial}} {{ j.get_dn() }} - {{ j.get_pubkey_fingerprint() }} - - {{ j.key_length() }}-bit {{ j.key_type() }} Fetch - - {% for key, value in j.get_extensions() %} - {{key}}={{value}}, - {% endfor %} + +
    + {% include 'iconmonstr-certificate-15-icon.svg' %} + {{j.distinguished_name}} +
    + + {% if j.email_address %} + + {% endif %} + +
    + {% include 'iconmonstr-key-2-icon.svg' %} + + {{ j.fingerprint() }} + + {{ j.key_length }}-bit + {{ j.key_type }} +
    + +
    + {% include 'iconmonstr-flag-3-icon.svg' %} + {{j.key_usage}} +
    + +
  • {% endfor %}

Revoked certificates

+

To fetch certificate revocation list:

+
+curl {{request.url}}/revoked/ | openssl crl -text -noout
+
+
    - {% for serial, reason, timestamp in authority.get_revoked() %} -
  • {{ serial}} {{ reason }} {{ timestamp}}
  • + {% for j in authority.get_revoked() %} +
  • + {{j.changed}} + {{j.serial_number}} {{j.distinguished_name}} +
  • + {% else %} +
  • Great job! No certificate signing requests to sign.
  • {% endfor %}
+ + + + + + + diff --git a/certidude/templates/openssl.cnf b/certidude/templates/openssl.cnf new file mode 100644 index 0000000..ea27bdc --- /dev/null +++ b/certidude/templates/openssl.cnf @@ -0,0 +1,32 @@ +[CA_{{slug}}] +default_days = 1825 +dir = {{directory}} +private_key = $dir/ca_key.pem +certificate = $dir/ca_crt.pem +new_certs_dir = $dir/requests/ +revoked_certs_dir = $dir/revoked/ +certs = $dir/signed/ +crl = $dir/ca_crl.pem +serial = $dir/serial +{% if crl_distribution_points %} +crlDistributionPoints = {{crl_distribution_points}}{% endif %} +{% if email_address %} +emailAddress = {{email_address}}{% endif %} +x509_extensions = {{slug}}_cert +policy = poliy_{{slug}} +autosign_whitelist = 127. +inbox = {{inbox}} +outbox = {{outbox}} + +[policy_{{slug}}] +countryName = match +stateOrProvinceName = match +organizationName = match +organizationalUnitName = optional +commonName = supplied +emailAddress = optional + +[{{slug}}_cert] +basicConstraints = CA:FALSE +keyUsage = nonRepudiation,digitalSignature,keyEncipherment +extendedKeyUsage = clientAuth diff --git a/certidude/templates/site-to-client.ovpn b/certidude/templates/site-to-client.ovpn new file mode 100644 index 0000000..329f955 --- /dev/null +++ b/certidude/templates/site-to-client.ovpn @@ -0,0 +1,16 @@ +mode server +tls-server +proto {{proto}} +port {{port}} +dev tap0 +local {{local}} +key {{key_path}} +cert {{certificate_path}} +ca {{authority_path}} +comp-lzo +user nobody +group nogroup +ifconfig-pool-persist /tmp/openvpn-leases.txt +ifconfig {{subnet_first}} {{subnet.netmask}} +server-bridge {{subnet_first}} {{subnet.netmask}} {{subnet_second}} {{subnet_last}} + diff --git a/certidude/wrappers.py b/certidude/wrappers.py index 1d0eae3..e92efd4 100644 --- a/certidude/wrappers.py +++ b/certidude/wrappers.py @@ -1,62 +1,111 @@ import os -from OpenSSL import crypto -from datetime import datetime import hashlib -from Crypto.Util import asn1 +import logging import re import itertools +import click +import socket +import io +import urllib.request from configparser import RawConfigParser +from Crypto.Util import asn1 +from OpenSSL import crypto +from datetime import datetime +from jinja2 import Environment, PackageLoader, Template +from certidude.mailer import Mailer +from certidude.signer import raw_sign, EXTENSION_WHITELIST +env = Environment(loader=PackageLoader("certidude", "email_templates")) + +# https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/ # https://jamielinux.com/docs/openssl-certificate-authority/ +# http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py + +def notify(func): + # TODO: Implement e-mail and nginx notifications using hooks + def wrapped(instance, csr, *args, **kwargs): + cert = func(instance, csr, *args, **kwargs) + assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert)) + url_template = os.getenv("CERTIDUDE_EVENT_PUBLISH") + if url_template: + url = url_template % csr.fingerprint() + notification = urllib.request.Request(url, cert.dump().encode("ascii")) + notification.add_header("User-Agent", "Certidude API") + notification.add_header("Content-Type", "application/x-x509-user-cert") + click.echo("Submitting notification to %s, waiting for response..." % url) + response = urllib.request.urlopen(notification) + response.read() + return cert + +# TODO: Implement e-mailing + +# self.mailer.send( +# self.certificate.email_address, +# (self.certificate.email_address, cert.email_address), +# "Certificate %s signed" % cert.distinguished_name, +# "certificate-signed", +# old_cert=old_cert, +# cert=cert, +# ca=self.certificate) + + return wrapped + def subject2dn(subject): bits = [] - for j in "C", "S", "L", "O", "OU", "CN": + for j in "CN", "GN", "SN", "C", "S", "L", "O", "OU": if getattr(subject, j, None): bits.append("/%s=%s" % (j, getattr(subject, j))) return "".join(bits) -class SerialCounter(object): - def __init__(self, filename): - self.path = filename - with open(filename, "r") as fh: - self.value = int(fh.read(), 16) - - def increment(self): - self.value += 1 - with open(self.path, "w") as fh: - fh.write("%04x" % self.value) - return self.value - class CertificateAuthorityConfig(object): """ Attempt to parse CA-s from openssl.cnf """ - + def __init__(self, *args): self._config = RawConfigParser() for arg in args: self._config.readfp(itertools.chain(["[global]"], open(os.path.expanduser(arg)))) + def get(self, section, key, default=""): + if self._config.has_option(section, key): + return self._config.get(section, key) + else: + return default + def instantiate_authority(self, slug): section = "CA_" + slug - - dirs = dict([(key, self._config.get(section, key) - if self._config.has_option(section, key) else "") - for key in ("dir", "certificate", "crl", "certs", "new_certs_dir", "serial", "private_key", "revoked_certs_dir")]) - + + dirs = dict([(key, self.get(section, key)) + for key in ("dir", "certificate", "crl", "certs", "new_certs_dir", "private_key", "revoked_certs_dir", "autosign_whitelist")]) + # Variable expansion, eg $dir for key, value in dirs.items(): if "$" in value: dirs[key] = re.sub(r'\$([a-z]+)', lambda m:dirs[m.groups()[0]], value) - + dirs.pop("dir") - return CertificateAuthority(slug, **dirs) + dirs["email_address"] = self.get(section, "emailAddress") + dirs["inbox"] = self.get(section, "inbox") + dirs["outbox"] = self.get(section, "outbox") + dirs["lifetime"] = int(self.get(section, "default_days", "1825")) + + extensions_section = self.get(section, "x509_extensions") + if extensions_section: + dirs["basic_constraints"] = self.get(extensions_section, "basicConstraints") + dirs["key_usage"] = self.get(extensions_section, "keyUsage") + dirs["extended_key_usage"] = self.get(extensions_section, "extendedKeyUsage") + authority = CertificateAuthority(slug, **dirs) + return authority def all_authorities(self): for section in self._config: if section.startswith("CA_"): - yield self.instantiate_authority(section[3:]) + try: + yield self.instantiate_authority(section[3:]) + except FileNotFoundError: + pass def pop_certificate_authority(self): def wrapper(func): @@ -68,215 +117,461 @@ class CertificateAuthorityConfig(object): return wrapper class CertificateBase: - def get_issuer_dn(self): + @property + def given_name(self): + return self.subject.GN + + @given_name.setter + def given_name(self, value): + return setattr(self.subject, "GN", value) + + @property + def surname(self): + return self.subject.SN + + @surname.setter + def surname(self, value): + return setattr(self.subject, "SN", value) + + @property + def common_name(self): + return self.subject.CN + + @common_name.setter + def common_name(self, value): + return setattr(self._obj.get_subject(), "CN", value) + + @property + def country_code(self): + return getattr(self._obj.get_subject(), "C", None) + + @property + def state_or_county(self): + return getattr(self._obj.get_subject(), "S", None) + + @property + def city(self): + return getattr(self._obj.get_subject(), "L", None) + + @property + def organization(self): + return getattr(self._obj.get_subject(), "O", None) + + @property + def organizational_unit(self): + return getattr(self._obj.get_subject(), "OU", None) + + @country_code.setter + def country_code(self, value): + return setattr(self._obj.get_subject(), "C", value) + + @state_or_county.setter + def state_or_county(self, value): + return setattr(self._obj.get_subject(), "S", value) + + @city.setter + def city(self, value): + return setattr(self._obj.get_subject(), "L", value) + + @organization.setter + def organization(self, value): + return setattr(self._obj.get_subject(), "O", value) + + @organizational_unit.setter + def organizational_unit(self, value): + return setattr(self._obj.get_subject(), "OU", value) + + @property + def key_usage(self): + def iterate(): + for key, value, data in self.extensions: + if key == "keyUsage" or key == "extendedKeyUsage": + yield value + return ", ".join(iterate()) + + @property + def subject(self): + return self._obj.get_subject() + + @property + def issuer(self): + return self._obj.get_issuer() + + @property + def issuer_dn(self): return subject2dn(self.issuer) - def get_dn(self): + @property + def distinguished_name(self): return subject2dn(self.subject) - + + @property def key_length(self): return self._obj.get_pubkey().bits() - + + @property def key_type(self): if self._obj.get_pubkey().type() == 6: return "RSA" else: raise NotImplementedError() - - def get_pubkey(self): - pub_asn1=crypto.dump_privatekey(crypto.FILETYPE_ASN1, self._obj.get_pubkey()) - pub_der=asn1.DerSequence() - pub_der.decode(pub_asn1) - return pub_der[1] - - def get_pubkey_hex(self): - h = "%x" % self.get_pubkey() - assert len(h) * 4 == self.key_length(), "%s is not %s" % (len(h)*4, self.key_length()) - return ":".join(re.findall("..", "%x" % self.get_pubkey())) - def get_pubkey_fingerprint(self): + @property + def extensions(self): + for e in self._obj.get_extensions(): + yield e.get_short_name().decode("ascii"), str(e), e.get_data() + + def set_extensions(self, extensions): + # X509Req().add_extensions() first invocation takes only effect?! + assert self._obj.get_extensions() == [], "Extensions already set!" + + self._obj.add_extensions([ + crypto.X509Extension( + key.encode("ascii"), + critical, + value.encode("ascii")) for (key,value,critical) in extensions]) + + @property + def email_address(self): + for bit in self.subject_alt_name.split(", "): + if bit.startswith("email:"): + return bit[6:] + return "" + + @property + def subject_alt_name(self): + for key, value, data in self.extensions: + if key == "subjectAltName": + return value + return "" + + @subject_alt_name.setter + def subject_alt_name(self, value): + self.set_extension("subjectAltName", value, False) + + @property + def pubkey(self): + pubkey_asn1=crypto.dump_privatekey(crypto.FILETYPE_ASN1, self._obj.get_pubkey()) + pubkey_der=asn1.DerSequence() + pubkey_der.decode(pubkey_asn1) + zero, modulo, exponent = pubkey_der + return modulo, exponent + + @property + def pubkey_hex(self): + modulo, exponent = self.pubkey + h = "%x" % modulo + assert len(h) * 4 == self.key_length, "%s is not %s" % (len(h)*4, self.key_length) + return re.findall("\d\d", h) + + def fingerprint(self): import binascii - return ":".join(re.findall("..", hashlib.sha1(binascii.unhexlify("%x" % self.get_pubkey())).hexdigest())) + m, _ = self.pubkey + return ":".join(re.findall("..", hashlib.sha1(binascii.unhexlify("%x" % m)).hexdigest())) -class Certificate(CertificateBase): - def __init__(self, filename, authority=None): - self.path = os.path.realpath(filename) - try: - self._obj = crypto.load_certificate(crypto.FILETYPE_PEM, open(filename).read()) - except crypto.Error: - click.echo("Failed to parse certificate: %s" % filename) - raise - self.not_before = datetime.strptime(self._obj.get_notBefore().decode("ascii"), "%Y%m%d%H%M%SZ") - self.not_after = datetime.strptime(self._obj.get_notAfter().decode("ascii"), "%Y%m%d%H%M%SZ") - self.subject = self._obj.get_subject() - self.issuer = self._obj.get_issuer() - self.serial = self._obj.get_serial_number() - self.authority = authority - self.subject_key_identifier = None - - def get_extensions(self): - for i in range(1, self._obj.get_extension_count()): - ext = self._obj.get_extension(i) - yield ext.get_short_name(), str(ext) - - def digest(self): - return self._obj.digest("md5").decode("ascii") - - def __eq__(self, other): - return self.serial == other.serial - - def __gt__(self, other): - return self.serial > other.serial - - def __lt__(self, other): - return self.serial < other.serial - - def __gte__(self, other): - return self.serial >= other.serial - - def __lte__(self, other): - return self.serial <= other.serial - -def lock_crl(func): - def wrapped(ca, *args, **kwargs): - # TODO: Implement actual locking! - try: - crl = crypto.load_crl(crypto.FILETYPE_PEM, open(ca.revocation_list).read()) - except crypto.Error: - click.echo("Failed to parse CRL in %s" % ca.revocation_list) - raise - count = len(crl.get_revoked() or ()) - retval = func(ca, crl, *args, **kwargs) - if count != len(crl.get_revoked() or ()): - click.echo("Updating CRL") - partial = ca.revocation_list + ".part" - with open(partial, "wb") as fh: - fh.write(crl.export( - ca.certificate._obj, - crypto.load_privatekey(crypto.FILETYPE_PEM, open(ca.private_key).read()), - crypto.FILETYPE_PEM)) - os.rename(partial, ca.revocation_list) - return retval - return wrapped class Request(CertificateBase): - def __init__(self, request_path): - self.path = request_path - self._obj = crypto.load_certificate_request(crypto.FILETYPE_PEM, open(self.path).read()) - self.subject = self._obj.get_subject() + def __init__(self, mixed=None): + self.buf = None + self.path = NotImplemented + self.created = NotImplemented + + if isinstance(mixed, io.TextIOWrapper): + self.path = mixed.name + _, _, _, _, _, _, _, _, mtime, _ = os.stat(self.path) + self.created = datetime.fromtimestamp(mtime) + mixed = mixed.read() + if isinstance(mixed, bytes): + mixed = mixed.decode("ascii") + if isinstance(mixed, str): + try: + self.buf = mixed + mixed = crypto.load_certificate_request(crypto.FILETYPE_PEM, mixed) + except crypto.Error: + print("Failed to parse:", mixed) + raise + + if isinstance(mixed, crypto.X509Req): + self._obj = mixed + else: + raise ValueError("Can't parse %s as X.509 certificate signing request!" % mixed) + + assert not self.buf or self.buf == self.dump(), "%s is not %s" % (self.buf, self.dump()) + + @property + def signable(self): + for key, value, data in self.extensions: + if key not in EXTENSION_WHITELIST: + return False + return True + + def dump(self): + return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self._obj).decode("ascii") - """ - pub_asn1=crypto.dump_privatekey(crypto.FILETYPE_ASN1, self._obj.get_pubkey()) - pub_der=asn1.DerSequence() - pub_der.decode(pub_asn1) - n=pub_der[1] - # Get the modulus - print("public modulus: %x" % n) - import binascii - self.sha_hash = hashlib.sha1(binascii.unhexlify("%x" % n)).hexdigest() - """ - def __repr__(self): return "Request(%s)" % repr(self.path) + def create(self): + # Generate 4096-bit RSA key + key = crypto.PKey() + key.generate_key(crypto.TYPE_RSA, 4096) + + # Create request + req = crypto.X509Req() + req.set_pubkey(key) + return Request(req) + +class Certificate(CertificateBase): + def __init__(self, mixed): + self.buf = NotImplemented + self.path = NotImplemented + self.changed = NotImplemented + + if isinstance(mixed, io.TextIOWrapper): + self.path = mixed.name + _, _, _, _, _, _, _, _, _, ctime = os.stat(self.path) + self.changed = datetime.fromtimestamp(ctime) + mixed = mixed.read() + + if isinstance(mixed, str): + try: + self.buf = mixed + mixed = crypto.load_certificate(crypto.FILETYPE_PEM, mixed) + except crypto.Error: + print("Failed to parse:", mixed) + raise + + if isinstance(mixed, crypto.X509): + self._obj = mixed + else: + raise ValueError("Can't parse %s as X.509 certificate!" % mixed) + + assert not self.buf or self.buf == self.dump(), "%s is not %s" % (self.buf, self.dump()) + + @property + def extensions(self): + # WTF?! + for j in range(1, self._obj.get_extension_count()): + e = self._obj.get_extension(j) + yield e.get_short_name().decode("ascii"), str(e), e.get_data() + + @property + def serial_number(self): + return "%040x" % self._obj.get_serial_number() + + @property + def signed(self): + return datetime.strptime(self._obj.get_notBefore().decode("ascii"), "%Y%m%d%H%M%SZ") + + @property + def expires(self): + return datetime.strptime(self._obj.get_notAfter().decode("ascii"), "%Y%m%d%H%M%SZ") + + def dump(self): + return crypto.dump_certificate(crypto.FILETYPE_PEM, self._obj).decode("ascii") + + def digest(self): + return self._obj.digest("md5").decode("ascii") + + def __eq__(self, other): + return self.serial_number == other.serial_number + + def __gt__(self, other): + return self.signed > other.signed + + def __lt__(self, other): + return self.signed < other.signed + + def __gte__(self, other): + return self.signed >= other.signed + + def __lte__(self, other): + return self.signed <= other.signed + class CertificateAuthority(object): - def __init__(self, slug, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, serial=None, private_key=None): + + def __init__(self, slug, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, private_key=None, autosign=False, autosign_whitelist=None, email_address=None, inbox=None, outbox=None, basic_constraints="CA:FALSE", key_usage="digitalSignature,keyEncipherment", extended_key_usage="clientAuth", lifetime=5*365): self.slug = slug self.revocation_list = crl self.signed_dir = certs self.request_dir = new_certs_dir self.revoked_dir = revoked_certs_dir self.private_key = private_key - - if isinstance(certificate, str): - self.certificate = Certificate(certificate, self) - else: - self.certificate = certificate + self.autosign_whitelist = set([j for j in autosign_whitelist.split(" ") if j]) + self.certificate = Certificate(open(certificate)) + self.mailer = Mailer(outbox) if outbox else None + self.lifetime = lifetime + self.basic_constraints = basic_constraints + self.key_usage = key_usage + self.extended_key_usage = extended_key_usage - if isinstance(serial, str): - self.serial_counter=SerialCounter(serial) - else: - self.serial_counter=serial + def autosign_allowed(self, addr): + for j in self.autosign_whitelist: + if j.endswith(".") and addr.startswith(j): + return True + elif j == addr: + return True + return False + + def _signer_exec(self, cmd, *bits): + sock = self.connect_signer() + sock.send(cmd.encode("ascii")) + sock.send(b"\n") + for bit in bits: + sock.send(bit.encode("ascii")) + sock.sendall(b"\n\n") + buf = sock.recv(8192) + if not buf: + raise + return buf - def __repr__(self): return "CertificateAuthority(slug=%s)" % repr(self.slug) - - def get_request(self, cn): - return Request(os.path.join(self.request_dir, cn + ".pem")) - + def get_certificate(self, cn): - return Certificate(os.path.join(self.signed_dir, cn + ".pem")) - - @lock_crl - def revoke(self, crl, cn): - certificate = self.get_certificate(cn) - revocation = crypto.Revoked() - revocation.set_rev_date(datetime.now().strftime("%Y%m%d%H%M%SZ").encode("ascii")) - revocation.set_reason(b"keyCompromise") - revocation.set_serial(("%x" % certificate.serial).encode("ascii")) - if self.revoked_dir: - os.rename(certificate.path, self.revoked_dir) - else: - os.unlink(certificate.path) - crl.add_revoked(revocation) - - @lock_crl - def get_revoked(self, crl): - for revocation in crl.get_revoked() or (): - yield int(revocation.get_serial(), 16), \ - revocation.get_reason().decode("ascii"), \ - datetime.strptime(revocation.get_rev_date().decode("ascii"), "%Y%m%d%H%M%SZ") - + return open(os.path.join(self.signed_dir, cn + ".pem")).read() + + def connect_signer(self): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect("/run/certidude/signer/%s.sock" % self.slug) + return sock + + def revoke(self, cn): + cert = Certificate(open(os.path.join(self.signed_dir, cn + ".pem"))) + revoked_filename = os.path.join(self.revoked_dir, "%s.pem" % cert.serial_number) + os.rename(cert.path, revoked_filename) + + def get_revoked(self): + for root, dirs, files in os.walk(self.revoked_dir): + for filename in files: + if filename.endswith(".pem"): + yield Certificate(open(os.path.join(root, filename))) + break + def get_signed(self): for root, dirs, files in os.walk(self.signed_dir): for filename in files: - yield Certificate(os.path.join(root, filename)) + if filename.endswith(".pem"): + yield Certificate(open(os.path.join(root, filename))) break def get_requests(self): for root, dirs, files in os.walk(self.request_dir): for filename in files: - yield Request(os.path.join(root, filename)) - - def sign(self, request, lifetime=5*365*24*60*60): - cert = crypto.X509() - cert.add_extensions([ - crypto.X509Extension( - b"basicConstraints", - True, - b"CA:FALSE, pathlen:0"), - crypto.X509Extension( - b"keyUsage", - True, - b"digitalSignature, keyEncipherment"), - crypto.X509Extension( - b"subjectKeyIdentifier", - False, - b"hash", - subject = self.certificate._obj), - crypto.X509Extension( - b"authorityKeyIdentifier", - False, - b"keyid:always", - issuer = self.certificate._obj)]) - cert.set_pubkey(request._obj.get_pubkey()) - cert.set_subject(request._obj.get_subject()) - cert.gmtime_adj_notBefore(0) - cert.gmtime_adj_notAfter(lifetime) - cert.set_serial_number(self.serial_counter.increment()) - - pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, open(self.private_key).read()) - cert.sign(pkey, 'sha1') - - path = os.path.join(self.signed_dir, request.subject.CN + ".pem") - assert not os.path.exists(path), "File %s already exists!" % path + if filename.endswith(".pem"): + yield Request(open(os.path.join(root, filename))) + break + + def get_request(self, cn): + return Request(open(os.path.join(self.request_dir, cn + ".pem"))) + + def store_request(self, buf, overwrite=False): + request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf) + common_name = request.get_subject().CN + request_path = os.path.join(self.request_dir, common_name + ".pem") + + # If there is cert, check if it's the same + if os.path.exists(request_path): + if open(request_path, "rb").read() != buf: + print("Request already exists, not creating new request") + raise FileExistsError("Request already exists") + else: + with open(request_path + ".part", "wb") as fh: + fh.write(buf) + os.rename(request_path + ".part", request_path) + + return Request(open(request_path)) + + def request_exists(self, cn): + return os.path.exists(os.path.join(self.request_dir, cn + ".pem")) + + def delete_request(self, cn): + os.unlink(os.path.join(self.request_dir, cn + ".pem")) + + def create_bundle(self, common_name, organizational_unit=None, email_address=None, overwrite=True): + req = Request.create() + req.country = self.certificate.country + req.state_or_county = self.certificate.state_or_county + req.city = self.certificate.city + req.organization = self.certificate.organization + req.organizational_unit = organizational_unit or self.certificate.organizational_unit + req.common_name = common_name + req.email_address = email_address + cert_buf = self.sign(req, overwrite) + return crypto.dump_privatekey(crypto.FILETYPE_PEM, key).decode("ascii"), \ + req_buf, cert_buf + + @notify + def sign(self, req, overwrite=False, delete=True): + """ + Sign certificate signing request via signer process + """ + + cert_path = os.path.join(self.signed_dir, req.common_name + ".pem") + + # Move existing certificate if necessary + if os.path.exists(cert_path): + old_cert = Certificate(open(cert_path)) + if overwrite: + self.revoke(req.common_name) + elif req.pubkey == old_cert.pubkey: + return old_cert + else: + raise FileExistsError("Will not overwrite existing certificate") + + # Sign via signer process + cert_buf = self._signer_exec("sign-request", req.dump()) + with open(cert_path + ".part", "wb") as fh: + fh.write(cert_buf) + os.rename(cert_path + ".part", cert_path) + + return Certificate(open(cert_path)) + + @notify + def sign2(self, request, 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 openssl.cnf, + lifetime may be overridden on the command line, + other extensions are copied as is. + """ + cert = raw_sign( + crypto.load_privatekey(crypto.FILETYPE_PEM, open(self.private_key).read()), + self.certificate._obj, + request._obj, + self.basic_constraints, + lifetime=lifetime or self.lifetime) + + path = os.path.join(self.signed_dir, request.common_name + ".pem") + if os.path.exists(path): + if overwrite: + self.revoke(request.common_name) + else: + raise FileExistsError("File %s already exists!" % path) buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) with open(path + ".part", "wb") as fh: fh.write(buf) os.rename(path + ".part", path) - click.echo("Wrote certififcate to: %s" % path) - os.unlink(request.path) - click.echo("Deleted request: %s" % request.path) + click.echo("Wrote certificate to: %s" % path) + if delete: + os.unlink(request.path) + click.echo("Deleted request: %s" % request.path) + + return Certificate(open(path)) + + def export_crl(self): + sock = self.connect_signer() + sock.send(b"export-crl\n") + for filename in os.listdir(self.revoked_dir): + if not filename.endswith(".pem"): + continue + serial_number = filename[:-4] + # TODO: Assert serial against regex + revoked_path = os.path.join(self.revoked_dir, filename) + # TODO: Skip expired certificates + s = os.stat(revoked_path) + sock.send(("%s:%d\n" % (serial_number, s.st_ctime)).encode("ascii")) + sock.sendall(b"\n") + return sock.recv(32*1024*1024) diff --git a/setup.py b/setup.py index c9585d0..e4f0d88 100644 --- a/setup.py +++ b/setup.py @@ -17,9 +17,14 @@ setup( ], long_description=open("README.rst").read(), install_requires=[ + "setproctitle", "click", "falcon", - "jinja2" + "jinja2", + "netifaces", + "pyopenssl", + "pycountry", + "humanize" ], scripts=[ "misc/certidude"