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):
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<serial no in hex> -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()

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

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=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/certidude.js"></script>
</head>
<body>
<div id="container">
@ -75,7 +76,7 @@ certidude setup openvpn client {{request.url}}
<ul>
{% 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>
{% if j.signable %}
<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>
{% 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>
<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>
{% for j in ca.get_revoked() %}
<li>
<li id="certificate_{{ j.fingerprint() }}">
{{j.changed}}
{{j.serial_number}} <span class="monospace">{{j.distinguished_name}}</span>
</li>

View File

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