From 0af381fc46ef996cc7a0b1e05456682675d84d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lauri=20V=C3=B5sandi?= Date: Sun, 12 Jul 2015 22:22:10 +0300 Subject: [PATCH] Initial commit --- .gitignore | 55 ++++ MANIFEST.in | 3 + README.rst | 34 +++ certidude/__init__.py | 0 certidude/api.py | 196 ++++++++++++ certidude/cli.py | 230 ++++++++++++++ .../iconmonstr-certificate-15-icon.svg | 35 +++ .../templates/iconmonstr-time-13-icon.svg | 18 ++ certidude/templates/index.html | 204 +++++++++++++ certidude/wrappers.py | 282 ++++++++++++++++++ misc/certidude | 6 + setup.py | 44 +++ 12 files changed, 1107 insertions(+) create mode 100644 .gitignore create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 certidude/__init__.py create mode 100644 certidude/api.py create mode 100755 certidude/cli.py create mode 100644 certidude/templates/iconmonstr-certificate-15-icon.svg create mode 100644 certidude/templates/iconmonstr-time-13-icon.svg create mode 100644 certidude/templates/index.html create mode 100644 certidude/wrappers.py create mode 100644 misc/certidude create mode 100644 setup.py 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 @@ + + + + + + + + + + Certidude server + + + +
+ +

Submit signing request

+ +{% set s = authority.certificate.subject %} + +

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
+
+ +

Pending requests

+ + + +

Signed certificates

+ + + +

Revoked certificates

+ + + diff --git a/certidude/wrappers.py b/certidude/wrappers.py new file mode 100644 index 0000000..1d0eae3 --- /dev/null +++ b/certidude/wrappers.py @@ -0,0 +1,282 @@ +import os +from OpenSSL import crypto +from datetime import datetime +import hashlib +from Crypto.Util import asn1 +import re +import itertools +from configparser import RawConfigParser + +# https://jamielinux.com/docs/openssl-certificate-authority/ + +def subject2dn(subject): + bits = [] + for j in "C", "S", "L", "O", "OU", "CN": + 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 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")]) + + # 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) + + def all_authorities(self): + for section in self._config: + if section.startswith("CA_"): + yield self.instantiate_authority(section[3:]) + + def pop_certificate_authority(self): + def wrapper(func): + def wrapped(*args, **kwargs): + slug = kwargs.pop("ca") + kwargs["ca"] = self.instantiate_authority(slug) + return func(*args, **kwargs) + return wrapped + return wrapper + +class CertificateBase: + def get_issuer_dn(self): + return subject2dn(self.issuer) + + def get_dn(self): + return subject2dn(self.subject) + + def key_length(self): + return self._obj.get_pubkey().bits() + + 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): + import binascii + return ":".join(re.findall("..", hashlib.sha1(binascii.unhexlify("%x" % self.get_pubkey())).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() + + """ + 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) + +class CertificateAuthority(object): + def __init__(self, slug, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, serial=None, private_key=None): + 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 + + if isinstance(serial, str): + self.serial_counter=SerialCounter(serial) + else: + self.serial_counter=serial + + + 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") + + 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)) + 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 + + 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) + diff --git a/misc/certidude b/misc/certidude new file mode 100644 index 0000000..97d7996 --- /dev/null +++ b/misc/certidude @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from certidude.cli import entry_point + +if __name__ == "__main__": + entry_point() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c9585d0 --- /dev/null +++ b/setup.py @@ -0,0 +1,44 @@ +#!/usr/bin/python +# coding: utf-8 +import os +from setuptools import setup + +setup( + name = "certidude", + version = "0.1.2", + author = u"Lauri Võsandi", + author_email = "lauri.vosandi@gmail.com", + description = "Certidude is a novel X.509 Certificate Authority management tool aiming to support PKCS#11 and in far future WebCrypto.", + license = "MIT", + keywords = "falcon http jinja2 x509 pkcs11 webcrypto", + url = "http://github.com/laurivosandi/certidude", + packages=[ + "certidude", + ], + long_description=open("README.rst").read(), + install_requires=[ + "click", + "falcon", + "jinja2" + ], + scripts=[ + "misc/certidude" + ], + include_package_data = True, + package_data={ + "certidude": ["certidude/templates/*.html"], + }, + classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: Freely Distributable", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + ], +) +