diff --git a/certidude/api.py b/certidude/api.py index e322d31..895cb9d 100644 --- a/certidude/api.py +++ b/certidude/api.py @@ -21,6 +21,15 @@ RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0 def omit(**kwargs): return dict([(key,value) for (key, value) in kwargs.items() if value]) +def event_source(func): + def wrapped(self, req, resp, ca, *args, **kwargs): + if req.get_header("Accept") == "text/event-stream": + resp.status = falcon.HTTP_SEE_OTHER + resp.location = ca.push_server + "/ev/" + ca.uuid + print("Delegating EventSource handling to:", resp.location) + return func(self, req, resp, ca, *args, **kwargs) + return wrapped + def authorize_admin(func): def wrapped(self, req, resp, *args, **kwargs): authority = kwargs.get("ca") @@ -276,12 +285,12 @@ class RequestListResource(CertificateAuthorityBase): raise falcon.HTTPConflict( "CSR with such CN already exists", "Will not overwrite existing certificate signing request, explicitly delete CSR and try again") - + ca.event_publish("request_submitted", request.fingerprint()) # Wait the certificate to be signed if waiting is requested if req.get_param("wait"): - if ca.subscribe_certificate_url: + if ca.push_server: # Redirect to nginx pub/sub - url = ca.subscribe_certificate_url % dict(request_sha1sum=request.fingerprint()) + url = ca.push_server + "/lp/" + request.fingerprint() click.echo("Redirecting to: %s" % url) resp.status = falcon.HTTP_SEE_OTHER resp.append_header("Location", url) @@ -299,6 +308,8 @@ class RequestListResource(CertificateAuthorityBase): # 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 @@ -322,6 +333,7 @@ class IndexResource(CertificateAuthorityBase): @login_required @pop_certificate_authority @authorize_admin + @event_source @templatize("index.html") def on_get(self, req, resp, ca, user): return locals() diff --git a/certidude/auth.py b/certidude/auth.py index 95d4748..e69de29 100644 --- a/certidude/auth.py +++ b/certidude/auth.py @@ -1,60 +0,0 @@ - -import click -import falcon -import kerberos -import os -import re -import socket - -# Vanilla Kerberos provides only username. -# AD also embeds PAC (Privilege Attribute Certificate), which -# is supposed to be sent via HTTP headers and it contains -# the groups user is part of. -# Even then we would have to manually look up the e-mail -# address eg via LDAP, hence to keep things simple -# we simply use Kerberos to authenticate. - -FQDN = socket.getaddrinfo(socket.gethostname(), 0, flags=socket.AI_CANONNAME)[0][3] - -if not os.getenv("KRB5_KTNAME"): - click.echo("Kerberos keytab not specified, set environment variable 'KRB5_KTNAME'", err=True) - exit(250) - -try: - principal = kerberos.getServerPrincipalDetails("HTTP", FQDN) -except kerberos.KrbError as exc: - click.echo("Failed to initialize Kerberos, reason: %s" % exc, err=True) - exit(249) -else: - click.echo("Kerberos enabled, service principal is HTTP/%s" % FQDN) - -def login_required(func): - def wrapped(resource, req, resp, *args, **kwargs): - authorization = req.get_header("Authorization") - - if not authorization: - resp.append_header("WWW-Authenticate", "Negotiate") - raise falcon.HTTPUnauthorized("Unauthorized", "No Kerberos ticket offered?") - - token = ''.join(authorization.split()[1:]) - - rc, context = kerberos.authGSSServerInit("HTTP@" + FQDN) - if rc != kerberos.AUTH_GSS_COMPLETE: - raise falcon.HTTPForbidden("Forbidden", "Kerberos ticket expired?") - - rc = kerberos.authGSSServerStep(context, token) - kerberos_user = kerberos.authGSSServerUserName(context).split("@") - - # References lost beyond this point! Results in - # ValueError: PyCapsule_SetPointer called with null pointer - kerberos.authGSSServerClean(context) - - if rc == kerberos.AUTH_GSS_COMPLETE: - kwargs["user"] = kerberos_user - return func(resource, req, resp, *args, **kwargs) - elif rc == kerberos.AUTH_GSS_CONTINUE: - raise falcon.HTTPUnauthorized("Unauthorized", "Tried GSSAPI") - else: - raise falcon.HTTPForbidden("Forbidden", "Tried GSSAPI") - - return wrapped diff --git a/certidude/helpers.py b/certidude/helpers.py index b3ac730..2ad3b59 100644 --- a/certidude/helpers.py +++ b/certidude/helpers.py @@ -155,7 +155,10 @@ def certidude_request_certificate(url, key_path, request_path, certificate_path, assert buf, "Server responded with no body, status code %d" % response.code cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf) except crypto.Error: - raise ValueError("Failed to parse PEM: %s" % buf) + if buf == b'-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n': + raise ValueError("Server refused to sign the request") # TODO: Raise proper exception + else: + 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) diff --git a/certidude/static/js/certidude.js b/certidude/static/js/certidude.js new file mode 100644 index 0000000..446a4f2 --- /dev/null +++ b/certidude/static/js/certidude.js @@ -0,0 +1,30 @@ +$(document).ready(function() { + console.info("Opening EventSource from:", window.location.href); + + var source = new EventSource(window.location.href); + + source.onmessage = function(event) { + console.log("Received server-sent event:", event); + } + + source.addEventListener("request_deleted", function(e) { + console.log("Removing deleted request #" + e.data); + $("#request_" + e.data).remove(); + }); + + source.addEventListener("request_submitted", function(e) { + console.log("Request submitted:", e.data); + }); + + source.addEventListener("request_signed", function(e) { + console.log("Request signed:", e.data); + $("#request_" + e.data).remove(); + // TODO: Insert
  • to signed certs list + }); + + source.addEventListener("certificate_revoked", function(e) { + console.log("Removing revoked certificate #" + e.data); + $("#certificate_" + e.data).remove(); + }); + +}); diff --git a/certidude/templates/index.html b/certidude/templates/index.html index 5dbd024..049cec3 100644 --- a/certidude/templates/index.html +++ b/certidude/templates/index.html @@ -9,6 +9,7 @@ +
    @@ -75,7 +76,7 @@ certidude setup openvpn client {{request.url}}
      {% for j in ca.get_requests() %} -
    • +
    • Fetch {% if j.signable %} @@ -129,7 +130,7 @@ curl -f {{request.url}}/signed/$CN > $CN.crt
        {% for j in ca.get_signed() | sort | reverse %} -
      • +
      • Fetch @@ -177,7 +178,7 @@ openssl ocsp -issuer ca.pem -CAfile ca.pem -url {{request.url}}/ocsp/ -serial 0x -->
          {% for j in ca.get_revoked() %} -
        • +
        • {{j.changed}} {{j.serial_number}} {{j.distinguished_name}}
        • diff --git a/certidude/wrappers.py b/certidude/wrappers.py index 3c42886..19715b5 100644 --- a/certidude/wrappers.py +++ b/certidude/wrappers.py @@ -28,14 +28,17 @@ def publish_certificate(func): cert = func(instance, csr, *args, **kwargs) assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert)) - if instance.publish_certificate_url: - url = instance.publish_certificate_url % dict(request_sha1sum=csr.fingerprint()) + if instance.push_server: + url = instance.push_server + "/pub/?id=" + 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("Publishing certificate at %s, waiting for response..." % url) response = urllib.request.urlopen(notification) response.read() + + instance.event_publish("request_signed", csr.fingerprint()) + return cert # TODO: Implement e-mailing @@ -85,7 +88,7 @@ class CertificateAuthorityConfig(object): section = "CA_" + slug dirs = dict([(key, self.get(section, key)) - for key in ("dir", "certificate", "crl", "certs", "new_certs_dir", "private_key", "revoked_certs_dir", "request_subnets", "autosign_subnets", "admin_subnets", "admin_users", "publish_certificate_url", "subscribe_certificate_url", "inbox", "outbox")]) + for key in ("dir", "certificate", "crl", "certs", "new_certs_dir", "private_key", "revoked_certs_dir", "request_subnets", "autosign_subnets", "admin_subnets", "admin_users", "push_server", "inbox", "outbox")]) # Variable expansion, eg $dir for key, value in dirs.items(): @@ -299,6 +302,7 @@ class CertificateBase: def fingerprint(self): import binascii m, _ = self.pubkey + return "%x" % m return ":".join(re.findall("..", hashlib.sha1(binascii.unhexlify("%x" % m)).hexdigest())) class Request(CertificateBase): @@ -424,7 +428,14 @@ class Certificate(CertificateBase): return self.signed <= other.signed class CertificateAuthority(object): - def __init__(self, slug, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, private_key=None, autosign_subnets=None, request_subnets=None, admin_subnets=None, admin_users=None, email_address=None, inbox=None, outbox=None, basic_constraints="CA:FALSE", key_usage="digitalSignature,keyEncipherment", extended_key_usage="clientAuth", certificate_lifetime=5*365, revocation_list_lifetime=1, publish_certificate_url=None, subscribe_certificate_url=None): + def __init__(self, slug, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, private_key=None, autosign_subnets=None, request_subnets=None, admin_subnets=None, admin_users=None, email_address=None, inbox=None, outbox=None, basic_constraints="CA:FALSE", key_usage="digitalSignature,keyEncipherment", extended_key_usage="clientAuth", certificate_lifetime=5*365, revocation_list_lifetime=1, push_server=None): + + import hashlib + m = hashlib.sha512() + m.update(slug.encode("ascii")) + m.update(b"TODO:server-secret-goes-here") + self.uuid = m.hexdigest() + self.slug = slug self.revocation_list = crl self.signed_dir = certs @@ -438,8 +449,7 @@ class CertificateAuthority(object): self.certificate = Certificate(open(certificate)) self.mailer = Mailer(outbox) if outbox else None - self.publish_certificate_url = publish_certificate_url - self.subscribe_certificate_url = subscribe_certificate_url + self.push_server = push_server self.certificate_lifetime = certificate_lifetime self.revocation_list_lifetime = revocation_list_lifetime @@ -459,6 +469,29 @@ class CertificateAuthority(object): else: self.admin_users = set([j for j in admin_users.split(" ") if j]) + def event_publish(self, event_type, event_data): + """ + Publish event on push server + """ + url = self.push_server + "/pub?id=" + self.uuid # Derive CA's push channel URL + + notification = urllib.request.Request( + url, + event_data.encode("utf-8"), + {"Event-ID": b"TODO", "Event-Type":event_type.encode("ascii")}) + notification.add_header("User-Agent", "Certidude API") + click.echo("Posting event %s %s at %s, waiting for response..." % (repr(event_type), repr(event_data), repr(url))) + try: + response = urllib.request.urlopen(notification) + body = response.read() + except urllib.error.HTTPError as err: + if err.code == 404: + print("No subscribers on the channel") + else: + raise + else: + print("Push server returned:", response.code, body) + def _signer_exec(self, cmd, *bits): sock = self.connect_signer() sock.send(cmd.encode("ascii")) @@ -486,6 +519,7 @@ class CertificateAuthority(object): 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) + self.event_publish("certificate_revoked", cert.fingerprint()) def get_revoked(self): for root, dirs, files in os.walk(self.revoked_dir): @@ -532,7 +566,28 @@ class CertificateAuthority(object): 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")) + path = os.path.join(self.request_dir, cn + ".pem") + request_sha1sum = Request(open(path)).fingerprint() + os.unlink(path) + + # Publish event at CA channel + self.event_publish("request_deleted", request_sha1sum) + + # Write empty certificate to long-polling URL + url = self.push_server + "/pub/?id=" + request_sha1sum + publisher = urllib.request.Request(url, b"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n") + publisher.add_header("User-Agent", "Certidude API") + click.echo("POST-ing empty certificate at %s, waiting for response..." % url) + try: + response = urllib.request.urlopen(publisher) + body = response.read() + except urllib.error.HTTPError as err: + if err.code == 404: + print("No subscribers on the channel") + else: + raise + else: + print("Push server returned:", response.code, body) def create_bundle(self, common_name, organizational_unit=None, email_address=None, overwrite=True): req = Request.create()