Added preliminary event handling for front-end

This commit is contained in:
Lauri Võsandi 2015-10-28 11:46:36 +01:00
parent f1c0a3925d
commit a413a15854
6 changed files with 115 additions and 74 deletions

View File

@ -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): def omit(**kwargs):
return dict([(key,value) for (key, value) in kwargs.items() if value]) 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 authorize_admin(func):
def wrapped(self, req, resp, *args, **kwargs): def wrapped(self, req, resp, *args, **kwargs):
authority = kwargs.get("ca") authority = kwargs.get("ca")
@ -276,12 +285,12 @@ class RequestListResource(CertificateAuthorityBase):
raise falcon.HTTPConflict( raise falcon.HTTPConflict(
"CSR with such CN already exists", "CSR with such CN already exists",
"Will not overwrite existing certificate signing request, explicitly delete CSR and try again") "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 # Wait the certificate to be signed if waiting is requested
if req.get_param("wait"): if req.get_param("wait"):
if ca.subscribe_certificate_url: if ca.push_server:
# Redirect to nginx pub/sub # 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) click.echo("Redirecting to: %s" % url)
resp.status = falcon.HTTP_SEE_OTHER resp.status = falcon.HTTP_SEE_OTHER
resp.append_header("Location", url) resp.append_header("Location", url)
@ -299,6 +308,8 @@ class RequestListResource(CertificateAuthorityBase):
# Request was accepted, but not processed # Request was accepted, but not processed
resp.status = falcon.HTTP_202 resp.status = falcon.HTTP_202
class CertificateStatusResource(CertificateAuthorityBase): class CertificateStatusResource(CertificateAuthorityBase):
""" """
openssl ocsp -issuer CAcert_class1.pem -serial 0x<serial no in hex> -url http://localhost -CAfile cacert_both.pem openssl ocsp -issuer CAcert_class1.pem -serial 0x<serial no in hex> -url http://localhost -CAfile cacert_both.pem
@ -322,6 +333,7 @@ class IndexResource(CertificateAuthorityBase):
@login_required @login_required
@pop_certificate_authority @pop_certificate_authority
@authorize_admin @authorize_admin
@event_source
@templatize("index.html") @templatize("index.html")
def on_get(self, req, resp, ca, user): def on_get(self, req, resp, ca, user):
return locals() return locals()

View File

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

View File

@ -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 assert buf, "Server responded with no body, status code %d" % response.code
cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf) cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf)
except crypto.Error: 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: except urllib.error.HTTPError as e:
if e.code == 409: if e.code == 409:
click.echo("Different signing request with same CN is already present on server, server refuses to overwrite", err=True) click.echo("Different signing request with same CN is already present on server, server refuses to overwrite", err=True)

View File

@ -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 <li> to signed certs list
});
source.addEventListener("certificate_revoked", function(e) {
console.log("Removing revoked certificate #" + e.data);
$("#certificate_" + e.data).remove();
});
});

View File

@ -9,6 +9,7 @@
<link href="//fonts.googleapis.com/css?family=Gentium" rel="stylesheet" type="text/css"/> <link href="//fonts.googleapis.com/css?family=Gentium" rel="stylesheet" type="text/css"/>
<link href="//fonts.googleapis.com/css?family=PT+Sans+Narrow" rel="stylesheet" type="text/css"/> <link href="//fonts.googleapis.com/css?family=PT+Sans+Narrow" rel="stylesheet" type="text/css"/>
<script type="text/javascript" src="/js/jquery-2.1.4.min.js"></script> <script type="text/javascript" src="/js/jquery-2.1.4.min.js"></script>
<script type="text/javascript" src="/js/certidude.js"></script>
</head> </head>
<body> <body>
<div id="container"> <div id="container">
@ -75,7 +76,7 @@ certidude setup openvpn client {{request.url}}
<ul> <ul>
{% for j in ca.get_requests() %} {% for j in ca.get_requests() %}
<li> <li id="request_{{ j.fingerprint() }}">
<a class="button" href="/api/{{ca.slug}}/request/{{j.common_name}}/">Fetch</a> <a class="button" href="/api/{{ca.slug}}/request/{{j.common_name}}/">Fetch</a>
{% if j.signable %} {% if j.signable %}
<button onClick="javascript:$.ajax({url:'/api/{{ca.slug}}/request/{{j.common_name}}/',type:'patch'});">Sign</button> <button onClick="javascript:$.ajax({url:'/api/{{ca.slug}}/request/{{j.common_name}}/',type:'patch'});">Sign</button>
@ -129,7 +130,7 @@ curl -f {{request.url}}/signed/$CN > $CN.crt
<ul> <ul>
{% for j in ca.get_signed() | sort | reverse %} {% for j in ca.get_signed() | sort | reverse %}
<li> <li id="certificate_{{ j.fingerprint() }}">
<a class="button" href="/api/{{ca.slug}}/signed/{{j.subject.CN}}/">Fetch</a> <a class="button" href="/api/{{ca.slug}}/signed/{{j.subject.CN}}/">Fetch</a>
<button onClick="javascript:$.ajax({url:'/api/{{ca.slug}}/signed/{{j.subject.CN}}/',type:'delete'});">Revoke</button> <button onClick="javascript:$.ajax({url:'/api/{{ca.slug}}/signed/{{j.subject.CN}}/',type:'delete'});">Revoke</button>
@ -177,7 +178,7 @@ openssl ocsp -issuer ca.pem -CAfile ca.pem -url {{request.url}}/ocsp/ -serial 0x
--> -->
<ul> <ul>
{% for j in ca.get_revoked() %} {% for j in ca.get_revoked() %}
<li> <li id="certificate_{{ j.fingerprint() }}">
{{j.changed}} {{j.changed}}
{{j.serial_number}} <span class="monospace">{{j.distinguished_name}}</span> {{j.serial_number}} <span class="monospace">{{j.distinguished_name}}</span>
</li> </li>

View File

@ -28,14 +28,17 @@ def publish_certificate(func):
cert = func(instance, csr, *args, **kwargs) cert = func(instance, csr, *args, **kwargs)
assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert)) assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert))
if instance.publish_certificate_url: if instance.push_server:
url = instance.publish_certificate_url % dict(request_sha1sum=csr.fingerprint()) url = instance.push_server + "/pub/?id=" + csr.fingerprint()
notification = urllib.request.Request(url, cert.dump().encode("ascii")) notification = urllib.request.Request(url, cert.dump().encode("ascii"))
notification.add_header("User-Agent", "Certidude API") notification.add_header("User-Agent", "Certidude API")
notification.add_header("Content-Type", "application/x-x509-user-cert") notification.add_header("Content-Type", "application/x-x509-user-cert")
click.echo("Publishing certificate at %s, waiting for response..." % url) click.echo("Publishing certificate at %s, waiting for response..." % url)
response = urllib.request.urlopen(notification) response = urllib.request.urlopen(notification)
response.read() response.read()
instance.event_publish("request_signed", csr.fingerprint())
return cert return cert
# TODO: Implement e-mailing # TODO: Implement e-mailing
@ -85,7 +88,7 @@ class CertificateAuthorityConfig(object):
section = "CA_" + slug section = "CA_" + slug
dirs = dict([(key, self.get(section, key)) 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 # Variable expansion, eg $dir
for key, value in dirs.items(): for key, value in dirs.items():
@ -299,6 +302,7 @@ class CertificateBase:
def fingerprint(self): def fingerprint(self):
import binascii import binascii
m, _ = self.pubkey m, _ = self.pubkey
return "%x" % m
return ":".join(re.findall("..", hashlib.sha1(binascii.unhexlify("%x" % m)).hexdigest())) return ":".join(re.findall("..", hashlib.sha1(binascii.unhexlify("%x" % m)).hexdigest()))
class Request(CertificateBase): class Request(CertificateBase):
@ -424,7 +428,14 @@ class Certificate(CertificateBase):
return self.signed <= other.signed return self.signed <= other.signed
class CertificateAuthority(object): 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.slug = slug
self.revocation_list = crl self.revocation_list = crl
self.signed_dir = certs self.signed_dir = certs
@ -438,8 +449,7 @@ class CertificateAuthority(object):
self.certificate = Certificate(open(certificate)) self.certificate = Certificate(open(certificate))
self.mailer = Mailer(outbox) if outbox else None self.mailer = Mailer(outbox) if outbox else None
self.publish_certificate_url = publish_certificate_url self.push_server = push_server
self.subscribe_certificate_url = subscribe_certificate_url
self.certificate_lifetime = certificate_lifetime self.certificate_lifetime = certificate_lifetime
self.revocation_list_lifetime = revocation_list_lifetime self.revocation_list_lifetime = revocation_list_lifetime
@ -459,6 +469,29 @@ class CertificateAuthority(object):
else: else:
self.admin_users = set([j for j in admin_users.split(" ") if j]) 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): def _signer_exec(self, cmd, *bits):
sock = self.connect_signer() sock = self.connect_signer()
sock.send(cmd.encode("ascii")) sock.send(cmd.encode("ascii"))
@ -486,6 +519,7 @@ class CertificateAuthority(object):
cert = Certificate(open(os.path.join(self.signed_dir, cn + ".pem"))) cert = Certificate(open(os.path.join(self.signed_dir, cn + ".pem")))
revoked_filename = os.path.join(self.revoked_dir, "%s.pem" % cert.serial_number) revoked_filename = os.path.join(self.revoked_dir, "%s.pem" % cert.serial_number)
os.rename(cert.path, revoked_filename) os.rename(cert.path, revoked_filename)
self.event_publish("certificate_revoked", cert.fingerprint())
def get_revoked(self): def get_revoked(self):
for root, dirs, files in os.walk(self.revoked_dir): 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")) return os.path.exists(os.path.join(self.request_dir, cn + ".pem"))
def delete_request(self, cn): 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): def create_bundle(self, common_name, organizational_unit=None, email_address=None, overwrite=True):
req = Request.create() req = Request.create()