mirror of
				https://github.com/laurivosandi/certidude
				synced 2025-10-31 09:29:13 +00:00 
			
		
		
		
	Added preliminary event handling for front-end
This commit is contained in:
		| @@ -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() | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										30
									
								
								certidude/static/js/certidude.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								certidude/static/js/certidude.js
									
									
									
									
									
										Normal 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(); | ||||
|     }); | ||||
|  | ||||
| }); | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
		Reference in New Issue
	
	Block a user