diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a41edc8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +.goutputstream* + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c4dc582 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include README.rst +include certidude/templates/*.html +include certidude/templates/*.svg diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..5aeeb14 --- /dev/null +++ b/README.rst @@ -0,0 +1,34 @@ +Certidude +========= + +Certidude is a novel X.509 Certificate Authority management tool aiming to +support PKCS#11 and in far future WebCrypto + +Install +------- + +To install Certidude: + +.. code:: bash + + apt-get install python3-openssl + pip3 install certidude + + +Setting up CA +-------------- + +Certidude can set up CA relatively easily: + +.. code:: bash + + certidude ca create /path/to/directory + +Tweak command-line options until you meet your requirements and +finally insert corresponding segment to your /etc/ssl/openssl.cnf + +Finally serve the certificate authority via web: + +.. code:: bash + + certidude serve diff --git a/certidude/__init__.py b/certidude/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/certidude/api.py b/certidude/api.py new file mode 100644 index 0000000..65eb58c --- /dev/null +++ b/certidude/api.py @@ -0,0 +1,196 @@ +import re +import falcon +import os +import json +import types +from datetime import datetime, date +from OpenSSL import crypto +from jinja2 import Environment, PackageLoader +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" + if isinstance(obj, date): + 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 + """ + def wrapped(instance, req, resp, **kwargs): + assert not req.get_param("unicode") or req.get_param("unicode") == u"✓", "Unicode sanity check failed" + 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 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') + resp.body = json.dumps(r, cls=MyEncoder) + return r + return wrapped + +def templatize(path): + template = env.get_template(path) + def wrapper(func): + def wrapped(instance, req, resp, **kwargs): + assert not req.get_param("unicode") or req.get_param("unicode") == u"✓", "Unicode sanity check failed" + r = func(instance, req, resp, **kwargs) + if not resp.body: + if req.get_header("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"); + 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.body = template.render(request=req, **r) + return r + return wrapped + return wrapper + +class CertificateAuthorityBase(object): + def __init__(self, config): + self.config = config + +class SignedCertificateDetailResource(CertificateAuthorityBase): + @pop_certificate_authority + @validate_common_name + def on_get(self, req, resp, ca, cn): + path = os.path.join(ca.signed_dir, cn + ".pem") + if not os.path.exists(path): + 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): + ca.revoke(cn) + +class SignedCertificateListResource(CertificateAuthorityBase): + @serialize + @pop_certificate_authority + @validate_common_name + 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()) + +class RequestDetailResource(CertificateAuthorityBase): + @pop_certificate_authority + @validate_common_name + def on_get(self, req, resp, ca, cn): + """ + Fetch certificate signing request as PEM + """ + path = os.path.join(ca.request_dir, cn + ".pem") + if not os.path.exists(path): + raise falcon.HTTPNotFound() + resp.stream = open(path, "rb") + resp.append_header("Content-Disposition", "attachment; filename=%s.csr" % cn) + + @pop_certificate_authority + @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)) + resp.body = "Certificate successfully signed" + resp.status = falcon.HTTP_201 + resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn) + + +class RequestListResource(CertificateAuthorityBase): + @serialize + @pop_certificate_authority + 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()) + + @pop_certificate_authority + def on_post(self, req, resp, ca): + + 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? + 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)) + +class CertificateAuthorityResource(CertificateAuthorityBase): + @templatize("index.html") + def on_get(self, req, resp, ca): + return { + "authority": self.config.instantiate_authority(ca)} + + diff --git a/certidude/cli.py b/certidude/cli.py new file mode 100755 index 0000000..a3c6ccf --- /dev/null +++ b/certidude/cli.py @@ -0,0 +1,230 @@ +#!/usr/bin/python3 +# coding: utf-8 + +import socket +import click +import os +import time +import os +import re +from datetime import datetime +from OpenSSL import crypto +from certidude.wrappers import CertificateAuthorityConfig, \ + CertificateAuthority, SerialCounter, Certificate, subject2dn + +# 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 + +# http://www.mad-hacking.net/documentation/linux/security/ssl-tls/creating-ca.xml + +config = CertificateAuthorityConfig("/etc/ssl/openssl.cnf") + +NOW = datetime.utcnow().replace(tzinfo=None) + + +@click.command("create", help="Set up Certificate Authority in a directory") +@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("--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("--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.argument("directory") +def ca_create(parent, country, state, locality, organization, organizational_unit, common_name, directory, crl_age, lifetime, pkcs11): + 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) + ca = crypto.X509() + ca.set_version(3) + ca.set_serial_number(1) + ca.get_subject().CN = common_name + ca.get_subject().C = country + ca.get_subject().ST = state + ca.get_subject().L = locality + 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.set_issuer(ca.get_subject()) + ca.set_pubkey(key) + ca.add_extensions([ + crypto.X509Extension( + b"basicConstraints", + True, + b"CA:TRUE, pathlen:0"), + crypto.X509Extension( + b"keyUsage", + True, + b"keyCertSign, cRLSign"), + crypto.X509Extension( + b"subjectKeyIdentifier", + False, + b"hash", + subject = ca), + crypto.X509Extension( + b"crlDistributionPoints", + False, + crl_distribution_points.encode("ascii")) + ]) + + click.echo("Signing %s..." % subject2dn(ca.get_subject())) + ca.sign(key, "sha1") + + if not os.path.exists(directory): + os.makedirs(directory) + 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: + 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") + + + 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.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() + 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") + else: + click.echo(" ✗ Certificate authority not valid yet") + + if os.path.exists(ca.private_key): + click.echo(" ✓ Private key %s okay" % ca.private_key) + else: + click.echo(" ✗ Private key %s does not exist" % ca.private_key) + + if os.path.isdir(ca.signed_dir): + click.echo(" ✓ Signed certificates directory %s okay" % ca.signed_dir) + 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() + +@click.command("list", help="List Certificate Authorities") +@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(): + mapping[serial] = None, reason + + for certificate in ca.get_signed(): + mapping[certificate.serial] = certificate, None + + 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: + click.echo(" %03d. Revoked due to: %s" % (serial, reason)) + + for request in ca.get_requests(): + click.echo(" ⌛ %s" % request.subject.CN) + +@click.command("serve", help="Run built-in HTTP server") +@click.option("-u", "--user", default=None, 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): + click.echo("Serving API at %s:%d" % (listen, port)) + import pwd + import falcon + from wsgiref.simple_server import make_server, WSGIServer + from socketserver import ThreadingMixIn + from certidude.api import CertificateAuthorityResource, \ + RequestDetailResource, RequestListResource, \ + SignedCertificateDetailResource, SignedCertificateListResource + + class ThreadingWSGIServer(ThreadingMixIn, WSGIServer): + pass + click.echo("Listening on %s:%d" % (listen, port)) + + app = falcon.API() + 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)) + httpd = make_server(listen, port, app, ThreadingWSGIServer) + if user: + _, _, uid, gid, gecos, root, shell = pwd.getpwnam(user) + if uid == 0: + click.echo("Please specify unprivileged user") + exit(254) + click.echo("Switching to user %s (uid=%d, gid=%d)" % (user, uid, gid)) + os.setgid(gid) + os.setuid(uid) + 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(help="Certificate management") +def cert(): pass + +cert.add_command(cert_list) +ca.add_command(ca_create) +ca.add_command(ca_list) + +@click.group() +def entry_point(): pass + +entry_point.add_command(ca) +entry_point.add_command(cert) +entry_point.add_command(serve) diff --git a/certidude/templates/iconmonstr-certificate-15-icon.svg b/certidude/templates/iconmonstr-certificate-15-icon.svg new file mode 100644 index 0000000..97eef7d --- /dev/null +++ b/certidude/templates/iconmonstr-certificate-15-icon.svg @@ -0,0 +1,35 @@ + + + + + + diff --git a/certidude/templates/iconmonstr-time-13-icon.svg b/certidude/templates/iconmonstr-time-13-icon.svg new file mode 100644 index 0000000..173bdfc --- /dev/null +++ b/certidude/templates/iconmonstr-time-13-icon.svg @@ -0,0 +1,18 @@ + + + + + + diff --git a/certidude/templates/index.html b/certidude/templates/index.html new file mode 100644 index 0000000..d8bafe3 --- /dev/null +++ b/certidude/templates/index.html @@ -0,0 +1,204 @@ + + +
+ + + + + + +To submit new certificate signing request:
++export CN=$(hostname) +openssl genrsa -out $CN.key 4096 +openssl req -new -sha256 -key $CN.key -out $CN.csr -subj "{% if s.C %}/C={{ s.C}}{% endif %}{% if s.ST %}/ST={{ s.ST}}{% endif %}{% if s.L %}/L={{s.L}}{% endif %}{% if s.O %}/O={{ s.O}}{% endif %}{% if s.OU %}/OU={{ s.OU}}{% endif %}/CN=$CN" +curl -H "Content-Type: application/pkcs10" -X POST -d "$(cat $CN.csr)" {{ request.url }}/request/ ++ +
After signing the request
+ ++curl -f {{ request.url }}/signed/$CN > $CN.crt ++ +