mirror of
				https://github.com/laurivosandi/certidude
				synced 2025-10-31 01:19:11 +00:00 
			
		
		
		
	Refactor
* Remove PyOpenSSL based wrapper classes * Remove unused API calls * Add certificate renewal via X-Renewal-Signature header * Remove (extended) key usage handling * Clean up OpenVPN and nginx server setup code * Use UDP port 51900 for OpenVPN by default * Add basic auth fallback for iOS in addition to Android * Reduce complexity
This commit is contained in:
		| @@ -5,13 +5,14 @@ import mimetypes | ||||
| import logging | ||||
| import os | ||||
| import click | ||||
| import hashlib | ||||
| from datetime import datetime | ||||
| from time import sleep | ||||
| from certidude import authority, mailer | ||||
| from certidude.auth import login_required, authorize_admin | ||||
| from certidude.user import User | ||||
| from certidude.decorators import serialize, event_source, csrf_protection | ||||
| from certidude.wrappers import Request, Certificate | ||||
| from cryptography.x509.oid import NameOID | ||||
| from certidude import const, config | ||||
|  | ||||
| logger = logging.getLogger("api") | ||||
| @@ -44,6 +45,33 @@ class SessionResource(object): | ||||
|     @login_required | ||||
|     @event_source | ||||
|     def on_get(self, req, resp): | ||||
|         def serialize_requests(g): | ||||
|             for common_name, path, buf, obj, server in g(): | ||||
|                 yield dict( | ||||
|                     common_name = common_name, | ||||
|                     server = server, | ||||
|                     md5sum = hashlib.md5(buf).hexdigest(), | ||||
|                     sha1sum = hashlib.sha1(buf).hexdigest(), | ||||
|                     sha256sum = hashlib.sha256(buf).hexdigest(), | ||||
|                     sha512sum = hashlib.sha512(buf).hexdigest() | ||||
|                 ) | ||||
|  | ||||
|         def serialize_certificates(g): | ||||
|             for common_name, path, buf, obj, server in g(): | ||||
|                 yield dict( | ||||
|                     serial_number = "%x" % obj.serial_number, | ||||
|                     common_name = common_name, | ||||
|                     server = server, | ||||
|                     # TODO: key type, key length, key exponent, key modulo | ||||
|                     signed = obj.not_valid_before, | ||||
|                     expires = obj.not_valid_after, | ||||
|                     sha256sum = hashlib.sha256(buf).hexdigest() | ||||
|                 ) | ||||
|  | ||||
|         if req.context.get("user").is_admin(): | ||||
|             logger.info("Logged in authority administrator %s" % req.context.get("user")) | ||||
|         else: | ||||
|             logger.info("Logged in authority user %s" % req.context.get("user")) | ||||
|         return dict( | ||||
|             user = dict( | ||||
|                 name=req.context.get("user").name, | ||||
| @@ -51,29 +79,31 @@ class SessionResource(object): | ||||
|                 sn=req.context.get("user").surname, | ||||
|                 mail=req.context.get("user").mail | ||||
|             ), | ||||
|             request_submission_allowed = sum( # Dirty hack! | ||||
|                 [req.context.get("remote_addr") in j | ||||
|                     for j in config.REQUEST_SUBNETS]), | ||||
|             request_submission_allowed = config.REQUEST_SUBMISSION_ALLOWED, | ||||
|             authority = dict( | ||||
|                 common_name = authority.ca_cert.subject.get_attributes_for_oid( | ||||
|                     NameOID.COMMON_NAME)[0].value, | ||||
|                 outbox = dict( | ||||
|                     server = config.OUTBOX, | ||||
|                     name = config.OUTBOX_NAME, | ||||
|                     mail = config.OUTBOX_MAIL | ||||
|                 ), | ||||
|                 user_certificate_enrollment=config.USER_CERTIFICATE_ENROLLMENT, | ||||
|                 user_mutliple_certificates=config.USER_MULTIPLE_CERTIFICATES, | ||||
|                 certificate = authority.certificate, | ||||
|                 machine_enrollment_allowed=config.MACHINE_ENROLLMENT_ALLOWED, | ||||
|                 user_enrollment_allowed=config.USER_ENROLLMENT_ALLOWED, | ||||
|                 user_multiple_certificates=config.USER_MULTIPLE_CERTIFICATES, | ||||
|                 events = config.EVENT_SOURCE_SUBSCRIBE % config.EVENT_SOURCE_TOKEN, | ||||
|                 requests=authority.list_requests(), | ||||
|                 signed=authority.list_signed(), | ||||
|                 revoked=authority.list_revoked(), | ||||
|                 requests=serialize_requests(authority.list_requests), | ||||
|                 signed=serialize_certificates(authority.list_signed), | ||||
|                 revoked=serialize_certificates(authority.list_revoked), | ||||
|                 users=User.objects.all(), | ||||
|                 admin_users = User.objects.filter_admins(), | ||||
|                 user_subnets = config.USER_SUBNETS, | ||||
|                 autosign_subnets = config.AUTOSIGN_SUBNETS, | ||||
|                 request_subnets = config.REQUEST_SUBNETS, | ||||
|                 admin_subnets=config.ADMIN_SUBNETS, | ||||
|                 signature = dict( | ||||
|                     certificate_lifetime=config.CERTIFICATE_LIFETIME, | ||||
|                     server_certificate_lifetime=config.SERVER_CERTIFICATE_LIFETIME, | ||||
|                     client_certificate_lifetime=config.CLIENT_CERTIFICATE_LIFETIME, | ||||
|                     revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME | ||||
|                 ) | ||||
|             ) if req.context.get("user").is_admin() else None, | ||||
| @@ -88,7 +118,6 @@ class StaticResource(object): | ||||
|         self.root = os.path.realpath(root) | ||||
|  | ||||
|     def __call__(self, req, resp): | ||||
|  | ||||
|         path = os.path.realpath(os.path.join(self.root, req.path[1:])) | ||||
|         if not path.startswith(self.root): | ||||
|             raise falcon.HTTPForbidden | ||||
| @@ -124,7 +153,7 @@ def certidude_app(): | ||||
|     from certidude import config | ||||
|     from .bundle import BundleResource | ||||
|     from .revoked import RevocationListResource | ||||
|     from .signed import SignedCertificateListResource, SignedCertificateDetailResource | ||||
|     from .signed import SignedCertificateDetailResource | ||||
|     from .request import RequestListResource, RequestDetailResource | ||||
|     from .lease import LeaseResource, StatusFileLeaseResource | ||||
|     from .whois import WhoisResource | ||||
| @@ -138,7 +167,6 @@ def certidude_app(): | ||||
|     app.add_route("/api/certificate/", CertificateAuthorityResource()) | ||||
|     app.add_route("/api/revoked/", RevocationListResource()) | ||||
|     app.add_route("/api/signed/{cn}/", SignedCertificateDetailResource()) | ||||
|     app.add_route("/api/signed/", SignedCertificateListResource()) | ||||
|     app.add_route("/api/request/{cn}/", RequestDetailResource()) | ||||
|     app.add_route("/api/request/", RequestListResource()) | ||||
|     app.add_route("/api/", SessionResource()) | ||||
| @@ -151,7 +179,7 @@ def certidude_app(): | ||||
|         app.add_route("/api/whois/", WhoisResource()) | ||||
|  | ||||
|     # Optional user enrollment API call | ||||
|     if config.USER_CERTIFICATE_ENROLLMENT: | ||||
|     if config.USER_ENROLLMENT_ALLOWED: | ||||
|         app.add_route("/api/bundle/", BundleResource()) | ||||
|  | ||||
|     if config.TAGGING_BACKEND == "sql": | ||||
|   | ||||
| @@ -1,5 +1,3 @@ | ||||
|  | ||||
|  | ||||
| import logging | ||||
| import hashlib | ||||
| from certidude import config, authority | ||||
|   | ||||
| @@ -4,91 +4,125 @@ import falcon | ||||
| import logging | ||||
| import ipaddress | ||||
| import os | ||||
| import hashlib | ||||
| from base64 import b64decode | ||||
| from certidude import config, authority, helpers, push, errors | ||||
| from certidude.auth import login_required, login_optional, authorize_admin | ||||
| from certidude.decorators import serialize, csrf_protection | ||||
| from certidude.wrappers import Request, Certificate | ||||
| from certidude.firewall import whitelist_subnets, whitelist_content_types | ||||
|  | ||||
| from cryptography import x509 | ||||
| from cryptography.hazmat.backends import default_backend | ||||
| from cryptography.hazmat.primitives import hashes | ||||
| from cryptography.hazmat.primitives.asymmetric import padding | ||||
| from cryptography.exceptions import InvalidSignature | ||||
| from cryptography.x509.oid import NameOID | ||||
| from datetime import datetime | ||||
|  | ||||
| logger = logging.getLogger("api") | ||||
|  | ||||
| class RequestListResource(object): | ||||
|     @serialize | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_get(self, req, resp): | ||||
|         return authority.list_requests() | ||||
|  | ||||
|  | ||||
|     @login_optional | ||||
|     @whitelist_subnets(config.REQUEST_SUBNETS) | ||||
|     @whitelist_content_types("application/pkcs10") | ||||
|     def on_post(self, req, resp): | ||||
|         """ | ||||
|         Submit certificate signing request (CSR) in PEM format | ||||
|         Validate and parse certificate signing request | ||||
|         """ | ||||
|  | ||||
|         body = req.stream.read(req.content_length) | ||||
|  | ||||
|         # Normalize body, TODO: newlines | ||||
|         if not body.endswith("\n"): | ||||
|             body += "\n" | ||||
|  | ||||
|         csr = Request(body) | ||||
|  | ||||
|         if not csr.common_name: | ||||
|         csr = x509.load_pem_x509_csr(body, default_backend()) | ||||
|         try: | ||||
|             common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) | ||||
|         except: # ValueError? | ||||
|             logger.warning(u"Rejected signing request without common name from %s", | ||||
|                 req.context.get("remote_addr")) | ||||
|             raise falcon.HTTPBadRequest( | ||||
|                 "Bad request", | ||||
|                 "No common name specified!") | ||||
|  | ||||
|         """ | ||||
|         Handle domain computer automatic enrollment | ||||
|         """ | ||||
|         machine = req.context.get("machine") | ||||
|         if machine: | ||||
|             if csr.common_name != machine: | ||||
|         if config.MACHINE_ENROLLMENT_ALLOWED and machine: | ||||
|             if common_name.value != machine: | ||||
|                 raise falcon.HTTPBadRequest( | ||||
|                     "Bad request", | ||||
|                     "Common name %s differs from Kerberos credential %s!" % (csr.common_name, machine)) | ||||
|                     "Common name %s differs from Kerberos credential %s!" % (common_name.value, machine)) | ||||
|  | ||||
|             # Automatic enroll with Kerberos machine cerdentials | ||||
|             resp.set_header("Content-Type", "application/x-x509-user-cert") | ||||
|             resp.body = authority.sign(csr, overwrite=True).dump() | ||||
|             cert, resp.body = authority._sign(csr, body, overwrite=True) | ||||
|             logger.info(u"Automatically enrolled Kerberos authenticated machine %s from %s", | ||||
|                 machine, req.context.get("remote_addr")) | ||||
|             return | ||||
|  | ||||
|  | ||||
|         # Check if this request has been already signed and return corresponding certificte if it has been signed | ||||
|         """ | ||||
|         Attempt to renew certificate using currently valid key pair | ||||
|         """ | ||||
|         try: | ||||
|             cert = authority.get_signed(csr.common_name) | ||||
|             path, buf, cert = authority.get_signed(common_name.value) | ||||
|         except EnvironmentError: | ||||
|             pass | ||||
|         else: | ||||
|             if cert.pubkey == csr.pubkey: | ||||
|                 resp.status = falcon.HTTP_SEE_OTHER | ||||
|                 resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name) | ||||
|                 return | ||||
|             if cert.public_key().public_numbers() == csr.public_key().public_numbers(): | ||||
|                 try: | ||||
|                     renewal_signature = b64decode(req.get_header("X-Renewal-Signature")) | ||||
|                 except TypeError, ValueError: # No header supplied, redirect to signed API call | ||||
|                     resp.status = falcon.HTTP_SEE_OTHER | ||||
|                     resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", common_name.value) | ||||
|                     return | ||||
|                 else: | ||||
|                     try: | ||||
|                         verifier = cert.public_key().verifier( | ||||
|                             renewal_signature, | ||||
|                             padding.PSS( | ||||
|                                 mgf=padding.MGF1(hashes.SHA512()), | ||||
|                                 salt_length=padding.PSS.MAX_LENGTH | ||||
|                             ), | ||||
|                             hashes.SHA512() | ||||
|                         ) | ||||
|                         verifier.update(buf) | ||||
|                         verifier.update(body) | ||||
|                         verifier.verify() | ||||
|                     except InvalidSignature: | ||||
|                         logger.error("Renewal failed, invalid signature supplied for %s", common_name.value) | ||||
|                     else: | ||||
|                         # At this point renewal signature was valid but we need to perform some extra checks | ||||
|                         if datetime.utcnow() > cert.not_valid_after: | ||||
|                             logger.error("Renewal failed, current certificate for %s has expired", common_name.value) | ||||
|                             # Put on hold | ||||
|                         elif not config.CERTIFICATE_RENEWAL_ALLOWED: | ||||
|                             logger.error("Renewal requested for %s, but not allowed by authority settings", common_name.value) | ||||
|                             # Put on hold | ||||
|                         else: | ||||
|                             resp.set_header("Content-Type", "application/x-x509-user-cert") | ||||
|                             _, resp.body = authority._sign(csr, body, overwrite=True) | ||||
|                             logger.info("Renewed certificate for %s", common_name.value) | ||||
|                             return | ||||
|  | ||||
|         # TODO: check for revoked certificates and return HTTP 410 Gone | ||||
|  | ||||
|         # Process automatic signing if the IP address is whitelisted, autosigning was requested and certificate can be automatically signed | ||||
|         if req.get_param_as_bool("autosign") and csr.is_client: | ||||
|         """ | ||||
|         Process automatic signing if the IP address is whitelisted, | ||||
|         autosigning was requested and certificate can be automatically signed | ||||
|         """ | ||||
|         if req.get_param_as_bool("autosign") and "." not in common_name.value: | ||||
|             for subnet in config.AUTOSIGN_SUBNETS: | ||||
|                 if req.context.get("remote_addr") in subnet: | ||||
|                     try: | ||||
|                         resp.set_header("Content-Type", "application/x-x509-user-cert") | ||||
|                         resp.body = authority.sign(csr).dump() | ||||
|                         _, resp.body = authority._sign(csr, body) | ||||
|                         logger.info("Autosigned %s as %s is whitelisted", common_name.value, req.context.get("remote_addr")) | ||||
|                         return | ||||
|                     except EnvironmentError: # Certificate already exists, try to save the request | ||||
|                         pass | ||||
|                     except EnvironmentError: | ||||
|                         logger.info("Autosign for %s failed, signed certificate already exists", | ||||
|                             common_name.value, req.context.get("remote_addr")) | ||||
|                     break | ||||
|  | ||||
|         # Attempt to save the request otherwise | ||||
|         try: | ||||
|             csr = authority.store_request(body) | ||||
|         except errors.RequestExists: | ||||
|             # We should stil redirect client to long poll URL below | ||||
|             # We should still redirect client to long poll URL below | ||||
|             pass | ||||
|         except errors.DuplicateCommonNameError: | ||||
|             # TODO: Certificate renewal | ||||
| @@ -98,12 +132,13 @@ class RequestListResource(object): | ||||
|                 "CSR with such CN already exists", | ||||
|                 "Will not overwrite existing certificate signing request, explicitly delete CSR and try again") | ||||
|         else: | ||||
|             push.publish("request-submitted", csr.common_name) | ||||
|             push.publish("request-submitted", common_name.value) | ||||
|  | ||||
|         # Wait the certificate to be signed if waiting is requested | ||||
|         logger.info(u"Signing request %s from %s stored", common_name.value, req.context.get("remote_addr")) | ||||
|         if req.get_param("wait"): | ||||
|             # Redirect to nginx pub/sub | ||||
|             url = config.LONG_POLL_SUBSCRIBE % csr.fingerprint() | ||||
|             url = config.LONG_POLL_SUBSCRIBE % hashlib.sha256(body).hexdigest() | ||||
|             click.echo("Redirecting to: %s"  % url) | ||||
|             resp.status = falcon.HTTP_SEE_OTHER | ||||
|             resp.set_header("Location", url.encode("ascii")) | ||||
| @@ -111,20 +146,17 @@ class RequestListResource(object): | ||||
|         else: | ||||
|             # Request was accepted, but not processed | ||||
|             resp.status = falcon.HTTP_202 | ||||
|             logger.info(u"Signing request from %s stored", req.context.get("remote_addr")) | ||||
|  | ||||
|  | ||||
| class RequestDetailResource(object): | ||||
|     @serialize | ||||
|     def on_get(self, req, resp, cn): | ||||
|         """ | ||||
|         Fetch certificate signing request as PEM | ||||
|         """ | ||||
|         csr = authority.get_request(cn) | ||||
|         resp.set_header("Content-Type", "application/pkcs10") | ||||
|         _, resp.body, _ = authority.get_request(cn) | ||||
|         logger.debug(u"Signing request %s was downloaded by %s", | ||||
|             csr.common_name, req.context.get("remote_addr")) | ||||
|         return csr | ||||
|  | ||||
|             cn, req.context.get("remote_addr")) | ||||
|  | ||||
|     @csrf_protection | ||||
|     @login_required | ||||
| @@ -133,16 +165,15 @@ class RequestDetailResource(object): | ||||
|         """ | ||||
|         Sign a certificate signing request | ||||
|         """ | ||||
|         csr = authority.get_request(cn) | ||||
|         cert = authority.sign(csr, overwrite=True, delete=True) | ||||
|         os.unlink(csr.path) | ||||
|         cert, buf = authority.sign(cn, overwrite=True) | ||||
|         # Mailing and long poll publishing implemented in the function above | ||||
|  | ||||
|         resp.body = "Certificate successfully signed" | ||||
|         resp.status = falcon.HTTP_201 | ||||
|         resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn) | ||||
|         logger.info(u"Signing request %s signed by %s from %s", csr.common_name, | ||||
|         logger.info(u"Signing request %s signed by %s from %s", cn, | ||||
|             req.context.get("user"), req.context.get("remote_addr")) | ||||
|  | ||||
|  | ||||
|     @csrf_protection | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
|  | ||||
| import click | ||||
| import falcon | ||||
| import json | ||||
| import logging | ||||
| from certidude import const, config | ||||
| from certidude.authority import export_crl, list_revoked | ||||
| from certidude.decorators import MyEncoder | ||||
| from cryptography import x509 | ||||
| from cryptography.hazmat.backends import default_backend | ||||
| from cryptography.hazmat.primitives.serialization import Encoding | ||||
| @@ -31,16 +31,13 @@ class RevocationListResource(object): | ||||
|                 resp.status = falcon.HTTP_SEE_OTHER | ||||
|                 resp.set_header("Location", url.encode("ascii")) | ||||
|                 logger.debug(u"Redirecting to CRL request to %s", url) | ||||
|                 resp.body = "Redirecting to %s" % url | ||||
|             else: | ||||
|                 resp.set_header("Content-Type", "application/x-pem-file") | ||||
|                 resp.append_header( | ||||
|                     "Content-Disposition", | ||||
|                     ("attachment; filename=%s-crl.pem" % const.HOSTNAME).encode("ascii")) | ||||
|                 resp.body = export_crl() | ||||
|         elif req.accept.startswith("application/json"): | ||||
|             resp.set_header("Content-Type", "application/json") | ||||
|             resp.set_header("Content-Disposition", "inline") | ||||
|             resp.body = json.dumps(list_revoked(), cls=MyEncoder) | ||||
|         else: | ||||
|             raise falcon.HTTPUnsupportedMediaType( | ||||
|                 "Client did not accept application/x-pkcs7-crl or application/x-pem-file") | ||||
|   | ||||
| @@ -1,38 +1,46 @@ | ||||
|  | ||||
| import falcon | ||||
| import logging | ||||
| import json | ||||
| import hashlib | ||||
| from certidude import authority | ||||
| from certidude.auth import login_required, authorize_admin | ||||
| from certidude.decorators import serialize, csrf_protection | ||||
| from certidude.decorators import csrf_protection | ||||
|  | ||||
| logger = logging.getLogger("api") | ||||
|  | ||||
| class SignedCertificateListResource(object): | ||||
|     @serialize | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_get(self, req, resp): | ||||
|         return {"signed":authority.list_signed()} | ||||
|  | ||||
|  | ||||
| class SignedCertificateDetailResource(object): | ||||
|     @serialize | ||||
|     def on_get(self, req, resp, cn): | ||||
|         # Compensate for NTP lag | ||||
| #        from time import sleep | ||||
| #        sleep(5) | ||||
|  | ||||
|         preferred_type = req.client_prefers(("application/json", "application/x-pem-file")) | ||||
|         try: | ||||
|             cert = authority.get_signed(cn) | ||||
|             path, buf, cert = authority.get_signed(cn) | ||||
|         except EnvironmentError: | ||||
|             logger.warning(u"Failed to serve non-existant certificate %s to %s", | ||||
|                 cn, req.context.get("remote_addr")) | ||||
|             resp.body = "No certificate CN=%s found" % cn | ||||
|             raise falcon.HTTPNotFound() | ||||
|             raise falcon.HTTPNotFound("No certificate CN=%s found" % cn) | ||||
|         else: | ||||
|             logger.debug(u"Served certificate %s to %s", | ||||
|                 cn, req.context.get("remote_addr")) | ||||
|             return cert | ||||
|  | ||||
|             if preferred_type == "application/x-pem-file": | ||||
|                 resp.set_header("Content-Type", "application/x-pem-file") | ||||
|                 resp.set_header("Content-Disposition", ("attachment; filename=%s.pem" % cn)) | ||||
|                 resp.body = buf | ||||
|                 logger.debug(u"Served certificate %s to %s as application/x-pem-file", | ||||
|                     cn, req.context.get("remote_addr")) | ||||
|             elif preferred_type == "application/json": | ||||
|                 resp.set_header("Content-Type", "application/json") | ||||
|                 resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % cn)) | ||||
|                 resp.body = json.dumps(dict( | ||||
|                     common_name = cn, | ||||
|                     serial_number = "%x" % cert.serial_number, | ||||
|                     signed = cert.not_valid_before.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", | ||||
|                     expires = cert.not_valid_after.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", | ||||
|                     sha256sum = hashlib.sha256(buf).hexdigest())) | ||||
|                 logger.debug(u"Served certificate %s to %s as application/json", | ||||
|                     cn, req.context.get("remote_addr")) | ||||
|             else: | ||||
|                 logger.debug("Client did not accept application/json or application/x-pem-file") | ||||
|                 raise falcon.HTTPUnsupportedMediaType( | ||||
|                     "Client did not accept application/json or application/x-pem-file") | ||||
|  | ||||
|     @csrf_protection | ||||
|     @login_required | ||||
| @@ -40,5 +48,5 @@ class SignedCertificateDetailResource(object): | ||||
|     def on_delete(self, req, resp, cn): | ||||
|         logger.info(u"Revoked certificate %s by %s from %s", | ||||
|             cn, req.context.get("user"), req.context.get("remote_addr")) | ||||
|         authority.revoke_certificate(cn) | ||||
|         authority.revoke(cn) | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user