mirror of
				https://github.com/laurivosandi/certidude
				synced 2025-10-31 01:19:11 +00:00 
			
		
		
		
	Refactor wrappers
Completely remove wrapper class for CA, use certidude.authority module instead.
This commit is contained in:
		| @@ -67,7 +67,7 @@ To install Certidude: | ||||
|  | ||||
| .. code:: bash | ||||
|  | ||||
|     apt-get install -y python3 python3-pip python3-dev cython3 build-essential libffi-dev libssl-dev libkrb5-dev | ||||
|     apt-get install -y python3 python3-pip python3-dev python3-mysql.connector cython3 build-essential libffi-dev libssl-dev libkrb5-dev | ||||
|     pip3 install certidude | ||||
|  | ||||
| Make sure you're running PyOpenSSL 0.15+ and netifaces 0.10.4+ from PyPI, | ||||
| @@ -87,6 +87,8 @@ First make sure the machine used for CA has fully qualified | ||||
| domain name set up properly. | ||||
| You can check it with: | ||||
|  | ||||
| .. code:: bash | ||||
|  | ||||
|   hostname -f | ||||
|  | ||||
| The command should return ca.example.co | ||||
|   | ||||
							
								
								
									
										588
									
								
								certidude/api.py
									
									
									
									
									
								
							
							
						
						
									
										588
									
								
								certidude/api.py
									
									
									
									
									
								
							| @@ -1,588 +0,0 @@ | ||||
| import re | ||||
| import datetime | ||||
| import falcon | ||||
| import ipaddress | ||||
| import mimetypes | ||||
| import os | ||||
| import json | ||||
| import types | ||||
| import click | ||||
| from time import sleep | ||||
| from certidude.wrappers import Request, Certificate, CertificateAuthority, \ | ||||
|     CertificateAuthorityConfig | ||||
| from certidude.auth import login_required | ||||
| from OpenSSL import crypto | ||||
| from pyasn1.codec.der import decoder | ||||
| from datetime import datetime, date | ||||
| from jinja2 import Environment, PackageLoader, Template | ||||
|  | ||||
| # TODO: Restrictive filesystem permissions result in TemplateNotFound exceptions | ||||
| env = Environment(loader=PackageLoader("certidude", "templates")) | ||||
|  | ||||
| RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" | ||||
|  | ||||
|  | ||||
| OIDS = { | ||||
|     (2, 5, 4,  3) : 'CN',   # common name | ||||
|     (2, 5, 4,  6) : 'C',    # country | ||||
|     (2, 5, 4,  7) : 'L',    # locality | ||||
|     (2, 5, 4,  8) : 'ST',   # stateOrProvince | ||||
|     (2, 5, 4, 10) : 'O',    # organization | ||||
|     (2, 5, 4, 11) : 'OU',   # organizationalUnit | ||||
| } | ||||
|  | ||||
| def parse_dn(data): | ||||
|     chunks, remainder = decoder.decode(data) | ||||
|     dn = "" | ||||
|     if remainder: | ||||
|         raise ValueError() | ||||
|     # TODO: Check for duplicate entries? | ||||
|     def generate(): | ||||
|         for chunk in chunks: | ||||
|             for chunkette in chunk: | ||||
|                 key, value = chunkette | ||||
|                 yield str(OIDS[key] + "=" + value) | ||||
|     return ", ".join(generate()) | ||||
|  | ||||
| def omit(**kwargs): | ||||
|     return dict([(key,value) for (key, value) in kwargs.items() if value]) | ||||
|  | ||||
| def event_source(func): | ||||
|     def wrapped(self, req, resp, *args, **kwargs): | ||||
|         if req.get_header("Accept") == "text/event-stream": | ||||
|             resp.status = falcon.HTTP_SEE_OTHER | ||||
|             resp.location = req.context.get("ca").push_server + "/ev/" + req.context.get("ca").uuid | ||||
|             resp.body = "Redirecting to:" + resp.location | ||||
|             print("Delegating EventSource handling to:", resp.location) | ||||
|         return func(self, req, resp, *args, **kwargs) | ||||
|     return wrapped | ||||
|  | ||||
| def authorize_admin(func): | ||||
|     def wrapped(self, req, resp, *args, **kwargs): | ||||
|         authority = req.context.get("ca") | ||||
|  | ||||
|         # Parse remote IPv4/IPv6 address | ||||
|         remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"]) | ||||
|  | ||||
|         # Check for administration subnet whitelist | ||||
|         print("Comparing:", authority.admin_subnets, "To:", remote_addr) | ||||
|         for subnet in authority.admin_subnets: | ||||
|             if subnet.overlaps(remote_addr): | ||||
|                 break | ||||
|         else: | ||||
|             raise falcon.HTTPForbidden("Forbidden", "Remote address %s not whitelisted" % remote_addr) | ||||
|  | ||||
|         # Check for username whitelist | ||||
|         kerberos_username, kerberos_realm = req.context.get("user") | ||||
|         if kerberos_username not in authority.admin_users: | ||||
|             raise falcon.HTTPForbidden("Forbidden", "User %s not whitelisted" % kerberos_username) | ||||
|  | ||||
|         # Retain username, TODO: Better abstraction with username, e-mail, sn, gn? | ||||
|  | ||||
|         return func(self, req, resp, *args, **kwargs) | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| def pop_certificate_authority(func): | ||||
|     def wrapped(self, req, resp, *args, **kwargs): | ||||
|         req.context["ca"] = self.config.instantiate_authority(req.env["HTTP_HOST"]) | ||||
|         return func(self, req, resp, *args, **kwargs) | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| def validate_common_name(func): | ||||
|     def wrapped(*args, **kwargs): | ||||
|         if not re.match(RE_HOSTNAME, kwargs["cn"]): | ||||
|             raise falcon.HTTPBadRequest("Invalid CN", "Common name supplied with request didn't pass the validation regex") | ||||
|         return func(*args, **kwargs) | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| class MyEncoder(json.JSONEncoder): | ||||
|     REQUEST_ATTRIBUTES = "signable", "identity", "changed", "common_name", \ | ||||
|         "organizational_unit", "given_name", "surname", "fqdn", "email_address", \ | ||||
|         "key_type", "key_length", "md5sum", "sha1sum", "sha256sum", "key_usage" | ||||
|  | ||||
|     CERTIFICATE_ATTRIBUTES = "revokable", "identity", "changed", "common_name", \ | ||||
|         "organizational_unit", "given_name", "surname", "fqdn", "email_address", \ | ||||
|         "key_type", "key_length", "sha256sum", "serial_number", "key_usage" | ||||
|  | ||||
|     def default(self, obj): | ||||
|         if isinstance(obj, crypto.X509Name): | ||||
|             try: | ||||
|                 return ", ".join(["%s=%s" % (k.decode("ascii"),v.decode("utf-8")) for k, v in obj.get_components()]) | ||||
|             except UnicodeDecodeError: # Work around old buggy pyopenssl | ||||
|                 return ", ".join(["%s=%s" % (k.decode("ascii"),v.decode("iso8859")) for k, v in obj.get_components()]) | ||||
|         if isinstance(obj, ipaddress._IPAddressBase): | ||||
|             return str(obj) | ||||
|         if isinstance(obj, set): | ||||
|             return tuple(obj) | ||||
|         if isinstance(obj, datetime): | ||||
|             return obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" | ||||
|         if isinstance(obj, date): | ||||
|             return obj.strftime("%Y-%m-%d") | ||||
|         if isinstance(obj, map): | ||||
|             return tuple(obj) | ||||
|         if isinstance(obj, types.GeneratorType): | ||||
|             return tuple(obj) | ||||
|         if isinstance(obj, Request): | ||||
|             return dict([(key, getattr(obj, key)) for key in self.REQUEST_ATTRIBUTES \ | ||||
|                 if hasattr(obj, key) and getattr(obj, key)]) | ||||
|         if isinstance(obj, Certificate): | ||||
|             return dict([(key, getattr(obj, key)) for key in self.CERTIFICATE_ATTRIBUTES \ | ||||
|                 if hasattr(obj, key) and getattr(obj, key)]) | ||||
|         if isinstance(obj, CertificateAuthority): | ||||
|             return dict( | ||||
|                 event_channel = obj.push_server + "/ev/" + obj.uuid, | ||||
|                 common_name = obj.common_name, | ||||
|                 certificate = obj.certificate, | ||||
|                 admin_users = obj.admin_users, | ||||
|                 autosign_subnets = obj.autosign_subnets, | ||||
|                 request_subnets = obj.request_subnets, | ||||
|                 admin_subnets=obj.admin_subnets, | ||||
|                 requests=obj.get_requests(), | ||||
|                 signed=obj.get_signed(), | ||||
|                 revoked=obj.get_revoked() | ||||
|             ) | ||||
|         if hasattr(obj, "serialize"): | ||||
|             return obj.serialize() | ||||
|         return json.JSONEncoder.default(self, obj) | ||||
|  | ||||
|  | ||||
| def serialize(func): | ||||
|     """ | ||||
|     Falcon response serialization | ||||
|     """ | ||||
|     def wrapped(instance, req, resp, **kwargs): | ||||
|         assert not req.get_param("unicode") or req.get_param("unicode") == u"✓", "Unicode sanity check failed" | ||||
|         resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate"); | ||||
|         resp.set_header("Pragma", "no-cache"); | ||||
|         resp.set_header("Expires", "0"); | ||||
|         r = func(instance, req, resp, **kwargs) | ||||
|         if resp.body is None: | ||||
|             if req.get_header("Accept").split(",")[0] == "application/json": | ||||
|                 resp.set_header("Content-Type", "application/json") | ||||
|                 resp.append_header("Content-Disposition", "inline") | ||||
|                 resp.body = json.dumps(r, cls=MyEncoder) | ||||
|             else: | ||||
|                 resp.body = repr(r) | ||||
|         return r | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| def templatize(path): | ||||
|     template = env.get_template(path) | ||||
|     def wrapper(func): | ||||
|         def wrapped(instance, req, resp, *args, **kwargs): | ||||
|             assert not req.get_param("unicode") or req.get_param("unicode") == u"✓", "Unicode sanity check failed" | ||||
|             r = func(instance, req, resp, *args, **kwargs) | ||||
|             r.pop("self") | ||||
|             if not resp.body: | ||||
|                 if  req.get_header("Accept") == "application/json": | ||||
|                     resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate"); | ||||
|                     resp.set_header("Pragma", "no-cache"); | ||||
|                     resp.set_header("Expires", "0"); | ||||
|                     resp.set_header("Content-Type", "application/json") | ||||
|                     r.pop("req") | ||||
|                     r.pop("resp") | ||||
|                     resp.body = json.dumps(r, cls=MyEncoder) | ||||
|                     return r | ||||
|                 else: | ||||
|                     resp.set_header("Content-Type", "text/html") | ||||
|                     resp.body = template.render(request=req, **r) | ||||
|                     return r | ||||
|         return wrapped | ||||
|     return wrapper | ||||
|  | ||||
|  | ||||
| class CertificateAuthorityBase(object): | ||||
|     def __init__(self, config): | ||||
|         self.config = config | ||||
|  | ||||
|  | ||||
| class RevocationListResource(CertificateAuthorityBase): | ||||
|     @pop_certificate_authority | ||||
|     def on_get(self, req, resp): | ||||
|         resp.set_header("Content-Type", "application/x-pkcs7-crl") | ||||
|         resp.append_header("Content-Disposition", "attachment; filename=%s.crl" % req.context.get("ca").common_name) | ||||
|         resp.body = req.context.get("ca").export_crl() | ||||
|  | ||||
|  | ||||
| class SignedCertificateDetailResource(CertificateAuthorityBase): | ||||
|     @serialize | ||||
|     @pop_certificate_authority | ||||
|     @validate_common_name | ||||
|     def on_get(self, req, resp, cn): | ||||
|         path = os.path.join(req.context.get("ca").signed_dir, cn + ".pem") | ||||
|         if not os.path.exists(path): | ||||
|             raise falcon.HTTPNotFound() | ||||
|  | ||||
|         resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % cn) | ||||
|         return Certificate(open(path)) | ||||
|  | ||||
|     @login_required | ||||
|     @pop_certificate_authority | ||||
|     @authorize_admin | ||||
|     @validate_common_name | ||||
|     def on_delete(self, req, resp, cn): | ||||
|         req.context.get("ca").revoke(cn) | ||||
|  | ||||
| class LeaseResource(CertificateAuthorityBase): | ||||
|     @serialize | ||||
|     @login_required | ||||
|     @pop_certificate_authority | ||||
|     @authorize_admin | ||||
|     def on_get(self, req, resp): | ||||
|         from ipaddress import ip_address | ||||
|  | ||||
|         # BUGBUG | ||||
|         SQL_LEASES = """ | ||||
|             SELECT | ||||
|                 acquired, | ||||
|                 released, | ||||
|                 address, | ||||
|                 identities.data as identity | ||||
|             FROM | ||||
|                 addresses | ||||
|             RIGHT JOIN | ||||
|                 identities | ||||
|             ON | ||||
|                 identities.id = addresses.identity | ||||
|             WHERE | ||||
|                 addresses.released <> 1 | ||||
|         """ | ||||
|         cnx = req.context.get("ca").database.get_connection() | ||||
|         cursor = cnx.cursor() | ||||
|         query = (SQL_LEASES) | ||||
|         cursor.execute(query) | ||||
|  | ||||
|         for acquired, released, address, identity in cursor: | ||||
|             yield { | ||||
|                 "acquired": datetime.utcfromtimestamp(acquired), | ||||
|                 "released": datetime.utcfromtimestamp(released) if released else None, | ||||
|                 "address":  ip_address(bytes(address)), | ||||
|                 "identity": parse_dn(bytes(identity)) | ||||
|             } | ||||
|  | ||||
|  | ||||
| class SignedCertificateListResource(CertificateAuthorityBase): | ||||
|     @serialize | ||||
|     @pop_certificate_authority | ||||
|     @authorize_admin | ||||
|     @validate_common_name | ||||
|     def on_get(self, req, resp): | ||||
|         for j in authority.get_signed(): | ||||
|             yield omit( | ||||
|                 key_type=j.key_type, | ||||
|                 key_length=j.key_length, | ||||
|                 identity=j.identity, | ||||
|                 cn=j.common_name, | ||||
|                 c=j.country_code, | ||||
|                 st=j.state_or_county, | ||||
|                 l=j.city, | ||||
|                 o=j.organization, | ||||
|                 ou=j.organizational_unit, | ||||
|                 fingerprint=j.fingerprint()) | ||||
|  | ||||
|  | ||||
| class RequestDetailResource(CertificateAuthorityBase): | ||||
|     @serialize | ||||
|     @pop_certificate_authority | ||||
|     @validate_common_name | ||||
|     def on_get(self, req, resp, cn): | ||||
|         """ | ||||
|         Fetch certificate signing request as PEM | ||||
|         """ | ||||
|         path = os.path.join(req.context.get("ca").request_dir, cn + ".pem") | ||||
|         if not os.path.exists(path): | ||||
|             raise falcon.HTTPNotFound() | ||||
|  | ||||
|         resp.append_header("Content-Type", "application/x-x509-user-cert") | ||||
|         resp.append_header("Content-Disposition", "attachment; filename=%s.csr" % cn) | ||||
|         return Request(open(path)) | ||||
|  | ||||
|     @login_required | ||||
|     @pop_certificate_authority | ||||
|     @authorize_admin | ||||
|     @validate_common_name | ||||
|     def on_patch(self, req, resp, cn): | ||||
|         """ | ||||
|         Sign a certificate signing request | ||||
|         """ | ||||
|         csr = req.context.get("ca").get_request(cn) | ||||
|         cert = req.context.get("ca").sign(csr, overwrite=True, delete=True) | ||||
|         os.unlink(csr.path) | ||||
|         resp.body = "Certificate successfully signed" | ||||
|         resp.status = falcon.HTTP_201 | ||||
|         resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn) | ||||
|  | ||||
|     @login_required | ||||
|     @pop_certificate_authority | ||||
|     @authorize_admin | ||||
|     def on_delete(self, req, resp, cn): | ||||
|         req.context.get("ca").delete_request(cn) | ||||
|  | ||||
|  | ||||
| class RequestListResource(CertificateAuthorityBase): | ||||
|     @serialize | ||||
|     @pop_certificate_authority | ||||
|     @authorize_admin | ||||
|     def on_get(self, req, resp): | ||||
|         for j in req.context.get("ca").get_requests(): | ||||
|             yield omit( | ||||
|                 key_type=j.key_type, | ||||
|                 key_length=j.key_length, | ||||
|                 identity=j.identity, | ||||
|                 cn=j.common_name, | ||||
|                 c=j.country_code, | ||||
|                 st=j.state_or_county, | ||||
|                 l=j.city, | ||||
|                 o=j.organization, | ||||
|                 ou=j.organizational_unit, | ||||
|                 fingerprint=j.fingerprint()) | ||||
|  | ||||
|     @pop_certificate_authority | ||||
|     def on_post(self, req, resp): | ||||
|         """ | ||||
|         Submit certificate signing request (CSR) in PEM format | ||||
|         """ | ||||
|         # Parse remote IPv4/IPv6 address | ||||
|         remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"]) | ||||
|         ca = req.context.get("ca") | ||||
|  | ||||
|         # Check for CSR submission whitelist | ||||
|         if ca.request_subnets: | ||||
|             for subnet in ca.request_subnets: | ||||
|                 if subnet.overlaps(remote_addr): | ||||
|                     break | ||||
|             else: | ||||
|                raise falcon.HTTPForbidden("Forbidden", "IP address %s not whitelisted" % remote_addr) | ||||
|  | ||||
|         if req.get_header("Content-Type") != "application/pkcs10": | ||||
|             raise falcon.HTTPUnsupportedMediaType( | ||||
|                 "This API call accepts only application/pkcs10 content type") | ||||
|  | ||||
|         body = req.stream.read(req.content_length) | ||||
|         csr = Request(body) | ||||
|  | ||||
|         # Check if this request has been already signed and return corresponding certificte if it has been signed | ||||
|         try: | ||||
|             cert_buf = ca.get_certificate(csr.common_name) | ||||
|         except FileNotFoundError: | ||||
|             pass | ||||
|         else: | ||||
|             cert = Certificate(cert_buf) | ||||
|             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 | ||||
|  | ||||
|         # TODO: check for revoked certificates and return HTTP 410 Gone | ||||
|  | ||||
|         # Process automatic signing if the IP address is whitelisted and autosigning was requested | ||||
|         if req.get_param_as_bool("autosign"): | ||||
|             for subnet in ca.autosign_subnets: | ||||
|                 if subnet.overlaps(remote_addr): | ||||
|                     try: | ||||
|                         resp.append_header("Content-Type", "application/x-x509-user-cert") | ||||
|                         resp.body = ca.sign(csr).dump() | ||||
|                         return | ||||
|                     except FileExistsError: # Certificate already exists, try to save the request | ||||
|                         pass | ||||
|                     break | ||||
|  | ||||
|         # Attempt to save the request otherwise | ||||
|         try: | ||||
|             request = ca.store_request(body) | ||||
|         except FileExistsError: | ||||
|             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.push_server: | ||||
|                 # Redirect to nginx pub/sub | ||||
|                 url = ca.push_server + "/lp/" + request.fingerprint() | ||||
|                 click.echo("Redirecting to: %s"  % url) | ||||
|                 resp.status = falcon.HTTP_SEE_OTHER | ||||
|                 resp.append_header("Location", url) | ||||
|             else: | ||||
|                 click.echo("Using dummy streaming mode, please switch to nginx in production!", err=True) | ||||
|                 # Dummy streaming mode | ||||
|                 while True: | ||||
|                     sleep(1) | ||||
|                     if not ca.request_exists(csr.common_name): | ||||
|                         resp.append_header("Content-Type", "application/x-x509-user-cert") | ||||
|                         resp.status = falcon.HTTP_201 # Certificate was created | ||||
|                         resp.body = ca.get_certificate(csr.common_name) | ||||
|                         break | ||||
|         else: | ||||
|             # 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 | ||||
|     """ | ||||
|     def on_post(self, req, resp): | ||||
|         ocsp_request = req.stream.read(req.content_length) | ||||
|         for component in decoder.decode(ocsp_request): | ||||
|             click.echo(component) | ||||
|         resp.append_header("Content-Type", "application/ocsp-response") | ||||
|         resp.status = falcon.HTTP_200 | ||||
|         raise NotImplementedError() | ||||
|  | ||||
| class CertificateAuthorityResource(CertificateAuthorityBase): | ||||
|     @pop_certificate_authority | ||||
|     def on_get(self, req, resp): | ||||
|         path = os.path.join(req.context.get("ca").certificate.path) | ||||
|         resp.stream = open(path, "rb") | ||||
|         resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % req.context.get("ca").common_name) | ||||
|  | ||||
| class IndexResource(CertificateAuthorityBase): | ||||
|     @serialize | ||||
|     @login_required | ||||
|     @pop_certificate_authority | ||||
|     @authorize_admin | ||||
|     @event_source | ||||
|     def on_get(self, req, resp): | ||||
|         return req.context.get("ca") | ||||
|  | ||||
| class SessionResource(CertificateAuthorityBase): | ||||
|     @serialize | ||||
|     @login_required | ||||
|     def on_get(self, req, resp): | ||||
|         return dict( | ||||
|             authorities=(self.config.ca_list), # TODO: Check if user is CA admin | ||||
|             username=req.context.get("user")[0] | ||||
|         ) | ||||
|  | ||||
| def address_to_identity(cnx, addr): | ||||
|     """ | ||||
|     Translate currently online client's IP-address to distinguished name | ||||
|     """ | ||||
|  | ||||
|     SQL_LEASES = """ | ||||
|         SELECT | ||||
|             acquired, | ||||
|             released, | ||||
|             identities.data as identity | ||||
|         FROM | ||||
|             addresses | ||||
|         RIGHT JOIN | ||||
|             identities | ||||
|         ON | ||||
|             identities.id = addresses.identity | ||||
|         WHERE | ||||
|             address = %s AND | ||||
|             released IS NOT NULL | ||||
|     """ | ||||
|  | ||||
|     cursor = cnx.cursor() | ||||
|     query = (SQL_LEASES) | ||||
|     import struct | ||||
|     cursor.execute(query, (struct.pack("!L", int(addr)),)) | ||||
|  | ||||
|     for acquired, released, identity in cursor: | ||||
|         return { | ||||
|             "acquired": datetime.utcfromtimestamp(acquired), | ||||
|             "identity": parse_dn(bytes(identity)) | ||||
|         } | ||||
|     return None | ||||
|  | ||||
|  | ||||
| class WhoisResource(CertificateAuthorityBase): | ||||
|     @serialize | ||||
|     @pop_certificate_authority | ||||
|     def on_get(self, req, resp): | ||||
|         identity = address_to_identity( | ||||
|             req.context.get("ca").database.get_connection(), | ||||
|             ipaddress.ip_address(req.get_param("address") or req.env["REMOTE_ADDR"]) | ||||
|         ) | ||||
|  | ||||
|         if identity: | ||||
|             return identity | ||||
|         else: | ||||
|             resp.status = falcon.HTTP_403 | ||||
|             resp.body = "Failed to look up node %s" % req.env["REMOTE_ADDR"] | ||||
|  | ||||
|  | ||||
| class ApplicationConfigurationResource(CertificateAuthorityBase): | ||||
|     @pop_certificate_authority | ||||
|     @validate_common_name | ||||
|     def on_get(self, req, resp, cn): | ||||
|         ctx = dict( | ||||
|             cn = cn, | ||||
|             certificate = req.context.get("ca").get_certificate(cn), | ||||
|             ca_certificate = open(req.context.get("ca").certificate.path, "r").read()) | ||||
|         resp.append_header("Content-Type", "application/ovpn") | ||||
|         resp.append_header("Content-Disposition", "attachment; filename=%s.ovpn" % cn) | ||||
|         resp.body = Template(open("/etc/openvpn/%s.template" % req.context.get("ca").common_name).read()).render(ctx) | ||||
|  | ||||
|     @login_required | ||||
|     @pop_certificate_authority | ||||
|     @authorize_admin | ||||
|     @validate_common_name | ||||
|     def on_put(self, req, resp, cn=None): | ||||
|         pkey_buf, req_buf, cert_buf = req.context.get("ca").create_bundle(cn) | ||||
|  | ||||
|         ctx = dict( | ||||
|             private_key = pkey_buf, | ||||
|             certificate = cert_buf, | ||||
|             ca_certificate = req.context.get("ca").certificate.dump()) | ||||
|  | ||||
|         resp.append_header("Content-Type", "application/ovpn") | ||||
|         resp.append_header("Content-Disposition", "attachment; filename=%s.ovpn" % cn) | ||||
|         resp.body = Template(open("/etc/openvpn/%s.template" % req.context.get("ca").common_name).read()).render(ctx) | ||||
|  | ||||
|  | ||||
| class StaticResource(object): | ||||
|     def __init__(self, root): | ||||
|         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 | ||||
|  | ||||
|         if os.path.isdir(path): | ||||
|             path = os.path.join(path, "index.html") | ||||
|         print("Serving:", path) | ||||
|  | ||||
|         if os.path.exists(path): | ||||
|             content_type, content_encoding = mimetypes.guess_type(path) | ||||
|             if content_type: | ||||
|                 resp.append_header("Content-Type", content_type) | ||||
|             if content_encoding: | ||||
|                 resp.append_header("Content-Encoding", content_encoding) | ||||
|             resp.stream = open(path, "rb") | ||||
|         else: | ||||
|             resp.status = falcon.HTTP_404 | ||||
|             resp.body = "File '%s' not found" % req.path | ||||
|  | ||||
|  | ||||
|  | ||||
| def certidude_app(): | ||||
|     config = CertificateAuthorityConfig() | ||||
|  | ||||
|     app = falcon.API() | ||||
|  | ||||
|     # Certificate authority API calls | ||||
|     app.add_route("/api/ocsp/", CertificateStatusResource(config)) | ||||
|     app.add_route("/api/signed/{cn}/openvpn", ApplicationConfigurationResource(config)) | ||||
|     app.add_route("/api/certificate/", CertificateAuthorityResource(config)) | ||||
|     app.add_route("/api/revoked/", RevocationListResource(config)) | ||||
|     app.add_route("/api/signed/{cn}/", SignedCertificateDetailResource(config)) | ||||
|     app.add_route("/api/signed/", SignedCertificateListResource(config)) | ||||
|     app.add_route("/api/request/{cn}/", RequestDetailResource(config)) | ||||
|     app.add_route("/api/request/", RequestListResource(config)) | ||||
|     app.add_route("/api/", IndexResource(config)) | ||||
|     app.add_route("/api/session/", SessionResource(config)) | ||||
|  | ||||
|     # Gateway API calls, should this be moved to separate project? | ||||
|     app.add_route("/api/lease/", LeaseResource(config)) | ||||
|     app.add_route("/api/whois/", WhoisResource(config)) | ||||
|     return app | ||||
							
								
								
									
										98
									
								
								certidude/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								certidude/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| import falcon | ||||
| import mimetypes | ||||
| import os | ||||
| import click | ||||
| from time import sleep | ||||
| from certidude import authority | ||||
| from certidude.auth import login_required, authorize_admin | ||||
| from certidude.decorators import serialize, event_source | ||||
| from certidude.wrappers import Request, Certificate | ||||
| from certidude import config | ||||
|  | ||||
| class CertificateStatusResource(object): | ||||
|     """ | ||||
|     openssl ocsp -issuer CAcert_class1.pem -serial 0x<serial no in hex> -url http://localhost -CAfile cacert_both.pem | ||||
|     """ | ||||
|     def on_post(self, req, resp): | ||||
|         ocsp_request = req.stream.read(req.content_length) | ||||
|         for component in decoder.decode(ocsp_request): | ||||
|             click.echo(component) | ||||
|         resp.append_header("Content-Type", "application/ocsp-response") | ||||
|         resp.status = falcon.HTTP_200 | ||||
|         raise NotImplementedError() | ||||
|  | ||||
|  | ||||
| class CertificateAuthorityResource(object): | ||||
|     def on_get(self, req, resp): | ||||
|         resp.stream = open(config.AUTHORITY_CERTIFICATE_PATH, "rb") | ||||
|         resp.append_header("Content-Disposition", "attachment; filename=ca.crt") | ||||
|  | ||||
|  | ||||
| class SessionResource(object): | ||||
|     @serialize | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     @event_source | ||||
|     def on_get(self, req, resp): | ||||
|         return dict( | ||||
|             username=req.context.get("user")[0], | ||||
|             event_channel = config.PUSH_EVENT_SOURCE % config.PUSH_TOKEN, | ||||
|             autosign_subnets = config.AUTOSIGN_SUBNETS, | ||||
|             request_subnets = config.REQUEST_SUBNETS, | ||||
|             admin_subnets=config.ADMIN_SUBNETS, | ||||
|             admin_users=config.ADMIN_USERS, | ||||
|             requests=authority.list_requests(), | ||||
|             signed=authority.list_signed(), | ||||
|             revoked=authority.list_revoked()) | ||||
|  | ||||
|  | ||||
| class StaticResource(object): | ||||
|     def __init__(self, root): | ||||
|         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 | ||||
|  | ||||
|         if os.path.isdir(path): | ||||
|             path = os.path.join(path, "index.html") | ||||
|         print("Serving:", path) | ||||
|  | ||||
|         if os.path.exists(path): | ||||
|             content_type, content_encoding = mimetypes.guess_type(path) | ||||
|             if content_type: | ||||
|                 resp.append_header("Content-Type", content_type) | ||||
|             if content_encoding: | ||||
|                 resp.append_header("Content-Encoding", content_encoding) | ||||
|             resp.stream = open(path, "rb") | ||||
|         else: | ||||
|             resp.status = falcon.HTTP_404 | ||||
|             resp.body = "File '%s' not found" % req.path | ||||
|  | ||||
|  | ||||
| def certidude_app(): | ||||
|     from .revoked import RevocationListResource | ||||
|     from .signed import SignedCertificateListResource, SignedCertificateDetailResource | ||||
|     from .request import RequestListResource, RequestDetailResource | ||||
|     from .lease import LeaseResource | ||||
|     from .whois import WhoisResource | ||||
|  | ||||
|     app = falcon.API() | ||||
|  | ||||
|     # Certificate authority API calls | ||||
|     app.add_route("/api/ocsp/", CertificateStatusResource()) | ||||
|     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()) | ||||
|  | ||||
|     # Gateway API calls, should this be moved to separate project? | ||||
|     app.add_route("/api/lease/", LeaseResource()) | ||||
|     app.add_route("/api/whois/", WhoisResource()) | ||||
|  | ||||
|     return app | ||||
							
								
								
									
										65
									
								
								certidude/api/lease.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								certidude/api/lease.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
|  | ||||
| from datetime import datetime | ||||
| from pyasn1.codec.der import decoder | ||||
| from certidude import config | ||||
| from certidude.auth import login_required, authorize_admin | ||||
| from certidude.decorators import serialize | ||||
|  | ||||
| OIDS = { | ||||
|     (2, 5, 4,  3) : 'CN',   # common name | ||||
|     (2, 5, 4,  6) : 'C',    # country | ||||
|     (2, 5, 4,  7) : 'L',    # locality | ||||
|     (2, 5, 4,  8) : 'ST',   # stateOrProvince | ||||
|     (2, 5, 4, 10) : 'O',    # organization | ||||
|     (2, 5, 4, 11) : 'OU',   # organizationalUnit | ||||
| } | ||||
|  | ||||
| def parse_dn(data): | ||||
|     chunks, remainder = decoder.decode(data) | ||||
|     dn = "" | ||||
|     if remainder: | ||||
|         raise ValueError() | ||||
|     # TODO: Check for duplicate entries? | ||||
|     def generate(): | ||||
|         for chunk in chunks: | ||||
|             for chunkette in chunk: | ||||
|                 key, value = chunkette | ||||
|                 yield str(OIDS[key] + "=" + value) | ||||
|     return ", ".join(generate()) | ||||
|  | ||||
|  | ||||
| class LeaseResource(object): | ||||
|     @serialize | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_get(self, req, resp): | ||||
|         from ipaddress import ip_address | ||||
|  | ||||
|         # BUGBUG | ||||
|         SQL_LEASES = """ | ||||
|             SELECT | ||||
|                 acquired, | ||||
|                 released, | ||||
|                 address, | ||||
|                 identities.data as identity | ||||
|             FROM | ||||
|                 addresses | ||||
|             RIGHT JOIN | ||||
|                 identities | ||||
|             ON | ||||
|                 identities.id = addresses.identity | ||||
|             WHERE | ||||
|                 addresses.released <> 1 | ||||
|         """ | ||||
|         cnx = config.DATABASE_POOL.get_connection() | ||||
|         cursor = cnx.cursor() | ||||
|         cursor.execute(SQL_LEASES) | ||||
|  | ||||
|         for acquired, released, address, identity in cursor: | ||||
|             yield { | ||||
|                 "acquired": datetime.utcfromtimestamp(acquired), | ||||
|                 "released": datetime.utcfromtimestamp(released) if released else None, | ||||
|                 "address":  ip_address(bytes(address)), | ||||
|                 "identity": parse_dn(bytes(identity)) | ||||
|             } | ||||
|  | ||||
							
								
								
									
										119
									
								
								certidude/api/request.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								certidude/api/request.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,119 @@ | ||||
|  | ||||
| import click | ||||
| import falcon | ||||
| import ipaddress | ||||
| import os | ||||
| from certidude import config, authority, helpers, push | ||||
| from certidude.auth import login_required, authorize_admin | ||||
| from certidude.decorators import serialize | ||||
| from certidude.wrappers import Request, Certificate | ||||
|  | ||||
| class RequestListResource(object): | ||||
|     @serialize | ||||
|     @authorize_admin | ||||
|     def on_get(self, req, resp): | ||||
|         return helpers.list_requests() | ||||
|  | ||||
|     def on_post(self, req, resp): | ||||
|         """ | ||||
|         Submit certificate signing request (CSR) in PEM format | ||||
|         """ | ||||
|         # Parse remote IPv4/IPv6 address | ||||
|         remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"]) | ||||
|  | ||||
|         # Check for CSR submission whitelist | ||||
|         if config.REQUEST_SUBNETS: | ||||
|             for subnet in config.REQUEST_SUBNETS: | ||||
|                 if subnet.overlaps(remote_addr): | ||||
|                     break | ||||
|             else: | ||||
|                raise falcon.HTTPForbidden("Forbidden", "IP address %s not whitelisted" % remote_addr) | ||||
|  | ||||
|         if req.get_header("Content-Type") != "application/pkcs10": | ||||
|             raise falcon.HTTPUnsupportedMediaType( | ||||
|                 "This API call accepts only application/pkcs10 content type") | ||||
|  | ||||
|         body = req.stream.read(req.content_length) | ||||
|         csr = Request(body) | ||||
|  | ||||
|         # Check if this request has been already signed and return corresponding certificte if it has been signed | ||||
|         try: | ||||
|             cert = authority.get_signed(csr.common_name) | ||||
|         except FileNotFoundError: | ||||
|             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 | ||||
|  | ||||
|         # TODO: check for revoked certificates and return HTTP 410 Gone | ||||
|  | ||||
|         # Process automatic signing if the IP address is whitelisted and autosigning was requested | ||||
|         if req.get_param_as_bool("autosign"): | ||||
|             for subnet in config.AUTOSIGN_SUBNETS: | ||||
|                 if subnet.overlaps(remote_addr): | ||||
|                     try: | ||||
|                         resp.set_header("Content-Type", "application/x-x509-user-cert") | ||||
|                         resp.body = authority.sign(csr).dump() | ||||
|                         return | ||||
|                     except FileExistsError: # Certificate already exists, try to save the request | ||||
|                         pass | ||||
|                     break | ||||
|  | ||||
|         # Attempt to save the request otherwise | ||||
|         try: | ||||
|             csr = authority.store_request(body) | ||||
|         except FileExistsError: | ||||
|             raise falcon.HTTPConflict( | ||||
|                 "CSR with such CN already exists", | ||||
|                 "Will not overwrite existing certificate signing request, explicitly delete CSR and try again") | ||||
|         push.publish("request_submitted", csr.common_name) | ||||
|  | ||||
|         # Wait the certificate to be signed if waiting is requested | ||||
|         if req.get_param("wait"): | ||||
|             # Redirect to nginx pub/sub | ||||
|             url = config.PUSH_LONG_POLL % csr.fingerprint() | ||||
|             click.echo("Redirecting to: %s"  % url) | ||||
|             resp.status = falcon.HTTP_SEE_OTHER | ||||
|             resp.set_header("Location", url) | ||||
|         else: | ||||
|             # Request was accepted, but not processed | ||||
|             resp.status = falcon.HTTP_202 | ||||
|  | ||||
|  | ||||
| class RequestDetailResource(object): | ||||
|     @serialize | ||||
|     def on_get(self, req, resp, cn): | ||||
|         """ | ||||
|         Fetch certificate signing request as PEM | ||||
|         """ | ||||
|         csr = authority.get_request(cn) | ||||
| #        if not os.path.exists(path): | ||||
| #            raise falcon.HTTPNotFound() | ||||
|  | ||||
|         resp.set_header("Content-Type", "application/pkcs10") | ||||
|         resp.set_header("Content-Disposition", "attachment; filename=%s.csr" % csr.common_name) | ||||
|         return csr | ||||
|  | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_patch(self, req, resp, cn): | ||||
|         """ | ||||
|         Sign a certificate signing request | ||||
|         """ | ||||
|         csr = authority.get_request(cn) | ||||
|         cert = authority.sign(csr, overwrite=True, delete=True) | ||||
|         os.unlink(csr.path) | ||||
|         resp.body = "Certificate successfully signed" | ||||
|         resp.status = falcon.HTTP_201 | ||||
|         resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn) | ||||
|  | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_delete(self, req, resp, cn): | ||||
|         try: | ||||
|             authority.delete_request(cn) | ||||
|         except FileNotFoundError: | ||||
|             resp.body = "No certificate CN=%s found" % cn | ||||
|             raise falcon.HTTPNotFound() | ||||
							
								
								
									
										9
									
								
								certidude/api/revoked.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								certidude/api/revoked.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
|  | ||||
| from certidude.authority import export_crl | ||||
|  | ||||
| class RevocationListResource(object): | ||||
|     def on_get(self, req, resp): | ||||
|         resp.set_header("Content-Type", "application/x-pkcs7-crl") | ||||
|         resp.append_header("Content-Disposition", "attachment; filename=ca.crl") | ||||
|         resp.body = export_crl() | ||||
|  | ||||
							
								
								
									
										38
									
								
								certidude/api/signed.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								certidude/api/signed.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
|  | ||||
| import falcon | ||||
| from certidude import authority | ||||
| from certidude.auth import login_required, authorize_admin | ||||
| from certidude.decorators import serialize | ||||
|  | ||||
| class SignedCertificateListResource(object): | ||||
|     @serialize | ||||
|     @authorize_admin | ||||
|     def on_get(self, req, resp): | ||||
|         for j in authority.list_signed(): | ||||
|             yield omit( | ||||
|                 key_type=j.key_type, | ||||
|                 key_length=j.key_length, | ||||
|                 identity=j.identity, | ||||
|                 cn=j.common_name, | ||||
|                 c=j.country_code, | ||||
|                 st=j.state_or_county, | ||||
|                 l=j.city, | ||||
|                 o=j.organization, | ||||
|                 ou=j.organizational_unit, | ||||
|                 fingerprint=j.fingerprint()) | ||||
|  | ||||
|  | ||||
| class SignedCertificateDetailResource(object): | ||||
|     @serialize | ||||
|     def on_get(self, req, resp, cn): | ||||
|         try: | ||||
|             return authority.get_signed(cn) | ||||
|         except FileNotFoundError: | ||||
|             resp.body = "No certificate CN=%s found" % cn | ||||
|             raise falcon.HTTPNotFound() | ||||
|  | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_delete(self, req, resp, cn): | ||||
|         authority.revoke_certificate(cn) | ||||
|  | ||||
							
								
								
									
										52
									
								
								certidude/api/whois.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								certidude/api/whois.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
|  | ||||
| import falcon | ||||
| import ipaddress | ||||
| from certidude import config | ||||
| from certidude.decorators import serialize | ||||
|  | ||||
| def address_to_identity(cnx, addr): | ||||
|     """ | ||||
|     Translate currently online client's IP-address to distinguished name | ||||
|     """ | ||||
|  | ||||
|     SQL_LEASES = """ | ||||
|         SELECT | ||||
|             acquired, | ||||
|             released, | ||||
|             identities.data as identity | ||||
|         FROM | ||||
|             addresses | ||||
|         RIGHT JOIN | ||||
|             identities | ||||
|         ON | ||||
|             identities.id = addresses.identity | ||||
|         WHERE | ||||
|             address = %s AND | ||||
|             released IS NOT NULL | ||||
|     """ | ||||
|  | ||||
|     cursor = cnx.cursor() | ||||
|     import struct | ||||
|     cursor.execute(SQL_LEASES, (struct.pack("!L", int(addr)),)) | ||||
|  | ||||
|     for acquired, released, identity in cursor: | ||||
|         return { | ||||
|             "acquired": datetime.utcfromtimestamp(acquired), | ||||
|             "identity": parse_dn(bytes(identity)) | ||||
|         } | ||||
|     return None | ||||
|  | ||||
|  | ||||
| class WhoisResource(object): | ||||
|     @serialize | ||||
|     def on_get(self, req, resp): | ||||
|         identity = address_to_identity( | ||||
|             config.DATABASE_POOL.get_connection(), | ||||
|             ipaddress.ip_address(req.get_param("address") or req.env["REMOTE_ADDR"]) | ||||
|         ) | ||||
|  | ||||
|         if identity: | ||||
|             return identity | ||||
|         else: | ||||
|             resp.status = falcon.HTTP_403 | ||||
|             resp.body = "Failed to look up node %s" % req.env["REMOTE_ADDR"] | ||||
| @@ -1,6 +1,7 @@ | ||||
|  | ||||
| import click | ||||
| import falcon | ||||
| import ipaddress | ||||
| import kerberos | ||||
| import os | ||||
| import re | ||||
| @@ -70,3 +71,28 @@ def login_required(func): | ||||
|             raise falcon.HTTPForbidden("Forbidden", "Tried GSSAPI") | ||||
|  | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| def authorize_admin(func): | ||||
|     def wrapped(self, req, resp, *args, **kwargs): | ||||
|         from certidude import config | ||||
|         # Parse remote IPv4/IPv6 address | ||||
|         remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"]) | ||||
|  | ||||
|         # Check for administration subnet whitelist | ||||
|         print("Comparing:", config.ADMIN_SUBNETS, "To:", remote_addr) | ||||
|         for subnet in config.ADMIN_SUBNETS: | ||||
|             if subnet.overlaps(remote_addr): | ||||
|                 break | ||||
|         else: | ||||
|             raise falcon.HTTPForbidden("Forbidden", "Remote address %s not whitelisted" % remote_addr) | ||||
|  | ||||
|         # Check for username whitelist | ||||
|         kerberos_username, kerberos_realm = req.context.get("user") | ||||
|         if kerberos_username not in config.ADMIN_USERS: | ||||
|             raise falcon.HTTPForbidden("Forbidden", "User %s not whitelisted" % kerberos_username) | ||||
|  | ||||
|         # Retain username, TODO: Better abstraction with username, e-mail, sn, gn? | ||||
|  | ||||
|         return func(self, req, resp, *args, **kwargs) | ||||
|     return wrapped | ||||
|   | ||||
							
								
								
									
										223
									
								
								certidude/authority.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								certidude/authority.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,223 @@ | ||||
|  | ||||
| import click | ||||
| import os | ||||
| import re | ||||
| import socket | ||||
| import urllib.request | ||||
| from OpenSSL import crypto | ||||
| from certidude import config, push | ||||
| from certidude.wrappers import Certificate, Request | ||||
| from certidude.signer import raw_sign | ||||
|  | ||||
| RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" | ||||
|  | ||||
| # https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/ | ||||
| # https://jamielinux.com/docs/openssl-certificate-authority/ | ||||
| # http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py | ||||
|  | ||||
| def publish_certificate(func): | ||||
|     # TODO: Implement e-mail and nginx notifications using hooks | ||||
|     def wrapped(csr, *args, **kwargs): | ||||
|         cert = func(csr, *args, **kwargs) | ||||
|         assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert)) | ||||
|  | ||||
|         if config.PUSH_PUBLISH: | ||||
|             url = config.PUSH_PUBLISH % 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() | ||||
|             push.publish("request_signed", csr.common_name) | ||||
|         return cert | ||||
|     return wrapped | ||||
|  | ||||
| def get_request(common_name): | ||||
|     if not re.match(RE_HOSTNAME, common_name): | ||||
|         raise ValueError("Invalid common name") | ||||
|     return Request(open(os.path.join(config.REQUESTS_DIR, common_name + ".pem"))) | ||||
|  | ||||
| def get_signed(common_name): | ||||
|     if not re.match(RE_HOSTNAME, common_name): | ||||
|         raise ValueError("Invalid common name") | ||||
|     return Certificate(open(os.path.join(config.SIGNED_DIR, common_name + ".pem"))) | ||||
|  | ||||
| def get_revoked(common_name): | ||||
|     if not re.match(RE_HOSTNAME, common_name): | ||||
|         raise ValueError("Invalid common name") | ||||
|     return Certificate(open(os.path.join(config.SIGNED_DIR, common_name + ".pem"))) | ||||
|  | ||||
| def store_request(buf, overwrite=False): | ||||
|     """ | ||||
|     Store CSR for later processing | ||||
|     """ | ||||
|     request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf) | ||||
|     common_name = request.get_subject().CN | ||||
|     request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") | ||||
|  | ||||
|     if not re.match(RE_HOSTNAME, common_name): | ||||
|         raise ValueError("Invalid common name") | ||||
|  | ||||
|     # If there is cert, check if it's the same | ||||
|     if os.path.exists(request_path): | ||||
|         if open(request_path, "rb").read() != buf: | ||||
|             print("Request already exists, not creating new request") | ||||
|             raise FileExistsError("Request already exists") | ||||
|     else: | ||||
|         with open(request_path + ".part", "wb") as fh: | ||||
|             fh.write(buf) | ||||
|         os.rename(request_path + ".part", request_path) | ||||
|  | ||||
|     return Request(open(request_path)) | ||||
|  | ||||
|  | ||||
| def signer_exec(cmd, *bits): | ||||
|     sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | ||||
|     sock.connect(config.SIGNER_SOCKET_PATH) | ||||
|     sock.send(cmd.encode("ascii")) | ||||
|     sock.send(b"\n") | ||||
|     for bit in bits: | ||||
|         sock.send(bit.encode("ascii")) | ||||
|     sock.sendall(b"\n\n") | ||||
|     buf = sock.recv(8192) | ||||
|     if not buf: | ||||
|         raise | ||||
|     return buf | ||||
|  | ||||
|  | ||||
| def revoke_certificate(common_name): | ||||
|     """ | ||||
|     Revoke valid certificate | ||||
|     """ | ||||
|     cert = get_signed(common_name) | ||||
|     revoked_filename = os.path.join(config.REVOKED_DIR, "%s.pem" % cert.serial_number) | ||||
|     os.rename(cert.path, revoked_filename) | ||||
|     push.publish("certificate_revoked", cert.fingerprint()) | ||||
|  | ||||
|  | ||||
| def list_requests(directory=config.REQUESTS_DIR): | ||||
|     for filename in os.listdir(directory): | ||||
|         if filename.endswith(".pem"): | ||||
|             yield Request(open(os.path.join(directory, filename))) | ||||
|  | ||||
|  | ||||
| def list_signed(directory=config.SIGNED_DIR): | ||||
|     for filename in os.listdir(directory): | ||||
|         if filename.endswith(".pem"): | ||||
|             yield Certificate(open(os.path.join(directory, filename))) | ||||
|  | ||||
|  | ||||
| def list_revoked(directory=config.REVOKED_DIR): | ||||
|     for filename in os.listdir(directory): | ||||
|         if filename.endswith(".pem"): | ||||
|             yield Certificate(open(os.path.join(directory, filename))) | ||||
|  | ||||
|  | ||||
| def export_crl(self): | ||||
|     sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | ||||
|     sock.connect(config.SIGNER_SOCKET_PATH) | ||||
|     sock.send(b"export-crl\n") | ||||
|     for filename in os.listdir(self.revoked_dir): | ||||
|         if not filename.endswith(".pem"): | ||||
|             continue | ||||
|         serial_number = filename[:-4] | ||||
|         # TODO: Assert serial against regex | ||||
|         revoked_path = os.path.join(self.revoked_dir, filename) | ||||
|         # TODO: Skip expired certificates | ||||
|         s = os.stat(revoked_path) | ||||
|         sock.send(("%s:%d\n" % (serial_number, s.st_ctime)).encode("ascii")) | ||||
|     sock.sendall(b"\n") | ||||
|     return sock.recv(32*1024*1024) | ||||
|  | ||||
|  | ||||
| def delete_request(common_name): | ||||
|     # Validate CN | ||||
|     if not re.match(RE_HOSTNAME, common_name): | ||||
|         raise ValueError("Invalid common name") | ||||
|  | ||||
|     path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") | ||||
|     request_sha1sum = Request(open(path)).fingerprint() | ||||
|     os.unlink(path) | ||||
|  | ||||
|     # Publish event at CA channel | ||||
|     push.publish("request_deleted", request_sha1sum) | ||||
|  | ||||
|     # Write empty certificate to long-polling URL | ||||
|     url = config.PUSH_PUBLISH % request_sha1sum | ||||
|     click.echo("POST-ing empty certificate at %s, waiting for response..." % url) | ||||
|     publisher = urllib.request.Request(url, b"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n") | ||||
|     publisher.add_header("User-Agent", "Certidude API") | ||||
|  | ||||
|     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) | ||||
|  | ||||
|  | ||||
| @publish_certificate | ||||
| def sign(req, overwrite=False, delete=True): | ||||
|     """ | ||||
|     Sign certificate signing request via signer process | ||||
|     """ | ||||
|  | ||||
|     cert_path = os.path.join(config.SIGNED_DIR, req.common_name + ".pem") | ||||
|  | ||||
|     # Move existing certificate if necessary | ||||
|     if os.path.exists(cert_path): | ||||
|         old_cert = Certificate(open(cert_path)) | ||||
|         if overwrite: | ||||
|             revoke_certificate(req.common_name) | ||||
|         elif req.pubkey == old_cert.pubkey: | ||||
|             return old_cert | ||||
|         else: | ||||
|             raise FileExistsError("Will not overwrite existing certificate") | ||||
|  | ||||
|     # Sign via signer process | ||||
|     cert_buf = signer_exec("sign-request", req.dump()) | ||||
|     with open(cert_path + ".part", "wb") as fh: | ||||
|         fh.write(cert_buf) | ||||
|     os.rename(cert_path + ".part", cert_path) | ||||
|  | ||||
|     return Certificate(open(cert_path)) | ||||
|  | ||||
|  | ||||
| @publish_certificate | ||||
| def sign2(request, overwrite=False, delete=True, lifetime=None): | ||||
|     """ | ||||
|     Sign directly using private key, this is usually done by root. | ||||
|     Basic constraints and certificate lifetime are copied from config, | ||||
|     lifetime may be overridden on the command line, | ||||
|     other extensions are copied as is. | ||||
|     """ | ||||
|     cert = raw_sign( | ||||
|         crypto.load_privatekey(crypto.FILETYPE_PEM, open(config.AUTHORITY_PRIVATE_KEY_PATH).read()), | ||||
|         crypto.load_certificate(crypto.FILETYPE_PEM, open(config.AUTHORITY_CERTIFICATE_PATH).read()), | ||||
|         request._obj, | ||||
|         config.CERTIFICATE_BASIC_CONSTRAINTS, | ||||
|         lifetime=lifetime or config.CERTIFICATE_LIFETIME) | ||||
|  | ||||
|     path = os.path.join(config.SIGNED_DIR, request.common_name + ".pem") | ||||
|     if os.path.exists(path): | ||||
|         if overwrite: | ||||
|             revoke(request.common_name) | ||||
|         else: | ||||
|             raise FileExistsError("File %s already exists!" % path) | ||||
|  | ||||
|     buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) | ||||
|     with open(path + ".part", "wb") as fh: | ||||
|         fh.write(buf) | ||||
|     os.rename(path + ".part", path) | ||||
|     click.echo("Wrote certificate to: %s" % path) | ||||
|     if delete: | ||||
|         os.unlink(request.path) | ||||
|         click.echo("Deleted request: %s" % request.path) | ||||
|  | ||||
|     return Certificate(open(path)) | ||||
|  | ||||
							
								
								
									
										340
									
								
								certidude/cli.py
									
									
									
									
									
								
							
							
						
						
									
										340
									
								
								certidude/cli.py
									
									
									
									
									
								
							| @@ -13,10 +13,9 @@ import signal | ||||
| import socket | ||||
| import subprocess | ||||
| import sys | ||||
| from certidude.helpers import expand_paths, \ | ||||
|     certidude_request_certificate | ||||
| from certidude import authority | ||||
| from certidude.signer import SignServer | ||||
| from certidude.wrappers import CertificateAuthorityConfig, subject2dn | ||||
| from certidude.common import expand_paths | ||||
| from datetime import datetime | ||||
| from humanize import naturaltime | ||||
| from ipaddress import ip_network, ip_address | ||||
| @@ -62,20 +61,15 @@ if os.getuid() >= 1000: | ||||
|         FIRST_NAME = gecos | ||||
|  | ||||
|  | ||||
| def load_config(): | ||||
|     path = os.getenv('CERTIDUDE_CONF') | ||||
|     if path and os.path.isfile(path): | ||||
|         return CertificateAuthorityConfig(path) | ||||
|     return CertificateAuthorityConfig() | ||||
|  | ||||
|  | ||||
| @click.command("spawn", help="Run privilege isolated signer processes") | ||||
| @click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instances") | ||||
| @click.command("spawn", help="Run privilege isolated signer process") | ||||
| @click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instance") | ||||
| @click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys") | ||||
| def certidude_spawn(kill, no_interaction): | ||||
|     """ | ||||
|     Spawn processes for signers | ||||
|     """ | ||||
|     from certidude import config | ||||
|  | ||||
|     # Check whether we have privileges | ||||
|     os.umask(0o027) | ||||
|     uid = os.getuid() | ||||
| @@ -84,13 +78,12 @@ def certidude_spawn(kill, no_interaction): | ||||
|  | ||||
|     # Process directories | ||||
|     run_dir = "/run/certidude" | ||||
|     signer_dir = os.path.join(run_dir, "signer") | ||||
|     chroot_dir = os.path.join(signer_dir, "jail") | ||||
|     chroot_dir = os.path.join(run_dir, "jail") | ||||
|  | ||||
|     # Prepare signer PID-s directory | ||||
|     if not os.path.exists(signer_dir): | ||||
|         click.echo("Creating: %s" % signer_dir) | ||||
|         os.makedirs(signer_dir) | ||||
|     if not os.path.exists(run_dir): | ||||
|         click.echo("Creating: %s" % run_dir) | ||||
|         os.makedirs(run_dir) | ||||
|  | ||||
|     # Preload charmap encoding for byte_string() function of pyOpenSSL | ||||
|     # in order to enable chrooting | ||||
| @@ -104,54 +97,49 @@ def certidude_spawn(kill, no_interaction): | ||||
|         os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom")) | ||||
|  | ||||
|     ca_loaded = False | ||||
|     config = load_config() | ||||
|     for ca in config.all_authorities(): | ||||
|         socket_path = os.path.join(signer_dir, ca.common_name + ".sock") | ||||
|         pidfile_path = os.path.join(signer_dir, ca.common_name + ".pid") | ||||
|  | ||||
|         try: | ||||
|             with open(pidfile_path) as fh: | ||||
|                 pid = int(fh.readline()) | ||||
|                 os.kill(pid, 0) | ||||
|                 click.echo("Found process with PID %d for %s" % (pid, ca.common_name)) | ||||
|         except (ValueError, ProcessLookupError, FileNotFoundError): | ||||
|             pid = 0 | ||||
|  | ||||
|         if pid > 0: | ||||
|             if kill: | ||||
|                 try: | ||||
|                     click.echo("Killing %d" % pid) | ||||
|                     os.kill(pid, signal.SIGTERM) | ||||
|                     sleep(1) | ||||
|                     os.kill(pid, signal.SIGKILL) | ||||
|                     sleep(1) | ||||
|                 except ProcessLookupError: | ||||
|                     pass | ||||
|                 ca_loaded = True | ||||
|             else: | ||||
|                 ca_loaded = True | ||||
|                 continue | ||||
|     try: | ||||
|         with open(config.SIGNER_PID_PATH) as fh: | ||||
|             pid = int(fh.readline()) | ||||
|             os.kill(pid, 0) | ||||
|             click.echo("Found process with PID %d" % pid) | ||||
|     except (ValueError, ProcessLookupError, FileNotFoundError): | ||||
|         pid = 0 | ||||
|  | ||||
|         child_pid = os.fork() | ||||
|     if pid > 0: | ||||
|         if kill: | ||||
|             try: | ||||
|                 click.echo("Killing %d" % pid) | ||||
|                 os.kill(pid, signal.SIGTERM) | ||||
|                 sleep(1) | ||||
|                 os.kill(pid, signal.SIGKILL) | ||||
|                 sleep(1) | ||||
|             except ProcessLookupError: | ||||
|                 pass | ||||
|  | ||||
|         if child_pid == 0: | ||||
|             with open(pidfile_path, "w") as fh: | ||||
|                 fh.write("%d\n" % os.getpid()) | ||||
|     child_pid = os.fork() | ||||
|  | ||||
|             setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name)) | ||||
|             logging.basicConfig( | ||||
|                 filename="/var/log/certidude-%s.log" % ca.common_name, | ||||
|                 level=logging.INFO) | ||||
|             server = SignServer(socket_path, ca.private_key, ca.certificate.path, | ||||
|                 ca.certificate_lifetime, ca.basic_constraints, ca.key_usage, | ||||
|                 ca.extended_key_usage, ca.revocation_list_lifetime) | ||||
|             asyncore.loop() | ||||
|         else: | ||||
|             click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, socket_path)) | ||||
|         ca_loaded = True | ||||
|     if child_pid == 0: | ||||
|         with open(config.SIGNER_PID_PATH, "w") as fh: | ||||
|             fh.write("%d\n" % os.getpid()) | ||||
|  | ||||
|     if not ca_loaded: | ||||
|         raise click.ClickException("No CA sections defined in configuration: {}".format(config.path)) | ||||
| #        setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name)) | ||||
|         logging.basicConfig( | ||||
|             filename="/var/log/signer.log", | ||||
|             level=logging.INFO) | ||||
|         server = SignServer( | ||||
|             config.SIGNER_SOCKET_PATH, | ||||
|             config.AUTHORITY_PRIVATE_KEY_PATH, | ||||
|             config.AUTHORITY_CERTIFICATE_PATH, | ||||
|             config.CERTIFICATE_LIFETIME, | ||||
|             config.CERTIFICATE_BASIC_CONSTRAINTS, | ||||
|             config.CERTIFICATE_KEY_USAGE_FLAGS, | ||||
|             config.CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS, | ||||
|             config.REVOCATION_LIST_LIFETIME) | ||||
|         asyncore.loop() | ||||
|     else: | ||||
|         click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH)) | ||||
|  | ||||
|  | ||||
| @click.command("client", help="Setup X.509 certificates for application") | ||||
| @@ -171,6 +159,7 @@ def certidude_spawn(kill, no_interaction): | ||||
| @click.option("--certificate-path", "-c", default=HOSTNAME + ".crt", help="Certificate path, %s.crt by default" % HOSTNAME) | ||||
| @click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt by default") | ||||
| def certidude_setup_client(quiet, **kwargs): | ||||
|     from certidude.helpers import certidude_request_certificate | ||||
|     return certidude_request_certificate(**kwargs) | ||||
|  | ||||
|  | ||||
| @@ -197,6 +186,7 @@ def certidude_setup_client(quiet, **kwargs): | ||||
| @expand_paths() | ||||
| def certidude_setup_openvpn_server(url, config, subnet, route, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, dhparam_path, local, proto, port): | ||||
|     # TODO: Intelligent way of getting last IP address in the subnet | ||||
|     from certidude.helpers import certidude_request_certificate | ||||
|     subnet_first = None | ||||
|     subnet_last = None | ||||
|     subnet_second = None | ||||
| @@ -213,7 +203,6 @@ def certidude_setup_openvpn_server(url, config, subnet, route, email_address, co | ||||
|         click.echo("use following command to sign on Certidude server instead of web interface:") | ||||
|         click.echo() | ||||
|         click.echo("  certidude sign %s" % common_name) | ||||
|  | ||||
|     retval = certidude_request_certificate( | ||||
|         url, | ||||
|         key_path, | ||||
| @@ -536,19 +525,14 @@ def certidude_setup_production(username, hostname, push_server, nginx_config, uw | ||||
| @click.option("--pkcs11", default=False, is_flag=True, help="Use PKCS#11 token instead of files") | ||||
| @click.option("--crl-distribution-url", default=None, help="CRL distribution URL") | ||||
| @click.option("--ocsp-responder-url", default=None, help="OCSP responder URL") | ||||
| @click.option("--email-address", default=EMAIL, help="CA e-mail address") | ||||
| @click.option("--inbox", default="imap://user:pass@host:port/INBOX", help="Inbound e-mail server") | ||||
| @click.option("--outbox", default="smtp://localhost", help="Outbound e-mail server") | ||||
| @click.option("--push-server", default="", help="Streaming nginx push server") | ||||
| @click.option("--directory", default=None, help="Directory for authority files, /var/lib/certidude/<common-name>/ by default") | ||||
| def certidude_setup_authority(parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, pkcs11, crl_distribution_url, ocsp_responder_url, email_address, inbox, outbox, push_server): | ||||
|  | ||||
|     if not directory: | ||||
|         directory = os.path.join("/var/lib/certidude", common_name) | ||||
| @click.option("--email-address", default="certidude@" + FQDN, help="E-mail address of the CA") | ||||
| @click.option("--directory", default=os.path.join("/var/lib/certidude", FQDN), help="Directory for authority files, /var/lib/certidude/ by default") | ||||
| def certidude_setup_authority(parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, pkcs11, crl_distribution_url, ocsp_responder_url, push_server, email_address): | ||||
|  | ||||
|     # Make sure common_name is valid | ||||
|     if not re.match(r"^[\._a-zA-Z0-9]+$", common_name): | ||||
|         raise click.ClickException("CA name can contain only alphanumeric and '_' characters") | ||||
|     if not re.match(r"^[\.\-_a-zA-Z0-9]+$", common_name): | ||||
|         raise click.ClickException("CA name can contain only alphanumeric, '_' and '-' characters") | ||||
|  | ||||
|     if os.path.lexists(directory): | ||||
|         raise click.ClickException("Output directory {} already exists.".format(directory)) | ||||
| @@ -612,14 +596,13 @@ def certidude_setup_authority(parent, country, state, locality, organization, or | ||||
|             crl_distribution_points.encode("ascii")) | ||||
|     ]) | ||||
|  | ||||
|     if email_address: | ||||
|         subject_alt_name = "email:%s" % email_address | ||||
|         ca.add_extensions([ | ||||
|             crypto.X509Extension( | ||||
|                 b"subjectAltName", | ||||
|                 False, | ||||
|                 subject_alt_name.encode("ascii")) | ||||
|         ]) | ||||
|     subject_alt_name = "email:%s" % email_address | ||||
|     ca.add_extensions([ | ||||
|         crypto.X509Extension( | ||||
|             b"subjectAltName", | ||||
|             False, | ||||
|             subject_alt_name.encode("ascii")) | ||||
|     ]) | ||||
|  | ||||
|     if ocsp_responder_url: | ||||
|         raise NotImplementedError() | ||||
| @@ -635,7 +618,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or | ||||
|         ]) | ||||
|     """ | ||||
|  | ||||
|     click.echo("Signing %s..." % subject2dn(ca.get_subject())) | ||||
|     click.echo("Signing %s..." % ca.get_subject()) | ||||
|  | ||||
|     # openssl x509 -in ca_crt.pem -outform DER | sha256sum | ||||
|     # openssl x509 -fingerprint -in ca_crt.pem | ||||
| @@ -665,13 +648,9 @@ def certidude_setup_authority(parent, country, state, locality, organization, or | ||||
|     with open(ca_key, "wb") as fh: | ||||
|         fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) | ||||
|  | ||||
|     ssl_cnf_example = os.path.join(directory, "openssl.cnf.example") | ||||
|     with open(ssl_cnf_example, "w") as fh: | ||||
|         fh.write(env.get_template("openssl.cnf").render(locals())) | ||||
|  | ||||
|     click.echo("You need to copy the contents of the '%s'" % ssl_cnf_example) | ||||
|     click.echo("to system-wide OpenSSL configuration file, usually located") | ||||
|     click.echo("at /etc/ssl/openssl.cnf") | ||||
|     certidude_conf = os.path.join("/etc/certidude.conf") | ||||
|     with open(certidude_conf, "w") as fh: | ||||
|         fh.write(env.get_template("certidude.conf").render(locals())) | ||||
|  | ||||
|     click.echo() | ||||
|     click.echo("Use following commands to inspect the newly created files:") | ||||
| @@ -691,7 +670,6 @@ def certidude_setup_authority(parent, country, state, locality, organization, or | ||||
|  | ||||
|  | ||||
| @click.command("list", help="List certificates") | ||||
| @click.argument("ca", nargs=-1) | ||||
| @click.option("--verbose", "-v", default=False, is_flag=True, help="Verbose output") | ||||
| @click.option("--show-key-type", "-k", default=False, is_flag=True, help="Show key type and length") | ||||
| @click.option("--show-path", "-p", default=False, is_flag=True, help="Show filesystem paths") | ||||
| @@ -699,7 +677,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or | ||||
| @click.option("--hide-requests", "-h", default=False, is_flag=True, help="Hide signing requests") | ||||
| @click.option("--show-signed", "-s", default=False, is_flag=True, help="Show signed certificates") | ||||
| @click.option("--show-revoked", "-r", default=False, is_flag=True, help="Show revoked certificates") | ||||
| def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_signed, show_revoked, hide_requests): | ||||
| def certidude_list(verbose, show_key_type, show_extensions, show_path, show_signed, show_revoked, hide_requests): | ||||
|     # Statuses: | ||||
|     #   s - submitted | ||||
|     #   v - valid | ||||
| @@ -738,147 +716,99 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_ | ||||
|             if j.fqdn: | ||||
|                 click.echo("Associated hostname: " + j.fqdn) | ||||
|  | ||||
|     config = load_config() | ||||
|  | ||||
|     wanted_list = None | ||||
|     if ca: | ||||
|         missing = list(set(ca) - set(config.ca_list)) | ||||
|         if missing: | ||||
|             raise click.NoSuchOption(option_name='', message="Unable to find certificate authority.", possibilities=config.ca_list) | ||||
|         wanted_list = ca | ||||
|     if not hide_requests: | ||||
|         for j in authority.list_requests(): | ||||
|  | ||||
|     for ca in config.all_authorities(wanted_list): | ||||
|         if not hide_requests: | ||||
|             for j in ca.get_requests(): | ||||
|                 if not verbose: | ||||
|                     click.echo("s " + j.path + " " + j.identity) | ||||
|                     continue | ||||
|                 click.echo(click.style(j.common_name, fg="blue")) | ||||
|                 click.echo("=" * len(j.common_name)) | ||||
|                 click.echo("State: ? " + click.style("submitted", fg="yellow") + " " + naturaltime(j.created) + click.style(", %s" %j.created,  fg="white")) | ||||
|             if not verbose: | ||||
|                 click.echo("s " + j.path + " " + j.identity) | ||||
|                 continue | ||||
|             click.echo(click.style(j.common_name, fg="blue")) | ||||
|             click.echo("=" * len(j.common_name)) | ||||
|             click.echo("State: ? " + click.style("submitted", fg="yellow") + " " + naturaltime(j.created) + click.style(", %s" %j.created,  fg="white")) | ||||
|  | ||||
|                 dump_common(j) | ||||
|             dump_common(j) | ||||
|  | ||||
|                 # Calculate checksums for cross-checking | ||||
|                 import hashlib | ||||
|                 md5sum = hashlib.md5() | ||||
|                 sha1sum = hashlib.sha1() | ||||
|                 sha256sum = hashlib.sha256() | ||||
|                 with open(j.path, "rb") as fh: | ||||
|                     buf = fh.read() | ||||
|                     md5sum.update(buf) | ||||
|                     sha1sum.update(buf) | ||||
|                     sha256sum.update(buf) | ||||
|                 click.echo("MD5 checksum: %s" % md5sum.hexdigest()) | ||||
|                 click.echo("SHA-1 checksum: %s" % sha1sum.hexdigest()) | ||||
|                 click.echo("SHA-256 checksum: %s" % sha256sum.hexdigest()) | ||||
|             # Calculate checksums for cross-checking | ||||
|             import hashlib | ||||
|             md5sum = hashlib.md5() | ||||
|             sha1sum = hashlib.sha1() | ||||
|             sha256sum = hashlib.sha256() | ||||
|             with open(j.path, "rb") as fh: | ||||
|                 buf = fh.read() | ||||
|                 md5sum.update(buf) | ||||
|                 sha1sum.update(buf) | ||||
|                 sha256sum.update(buf) | ||||
|             click.echo("MD5 checksum: %s" % md5sum.hexdigest()) | ||||
|             click.echo("SHA-1 checksum: %s" % sha1sum.hexdigest()) | ||||
|             click.echo("SHA-256 checksum: %s" % sha256sum.hexdigest()) | ||||
|  | ||||
|                 if show_path: | ||||
|                     click.echo("Details: openssl req -in %s -text -noout" % j.path) | ||||
|                     click.echo("Sign: certidude sign %s" % j.path) | ||||
|                 click.echo() | ||||
|  | ||||
|         if show_signed: | ||||
|             for j in ca.get_signed(): | ||||
|                 if not verbose: | ||||
|                     if j.signed < NOW and j.expires > NOW: | ||||
|                         click.echo("v " + j.path + " " + j.identity) | ||||
|                     elif NOW > j.expires: | ||||
|                         click.echo("e " + j.path + " " + j.identity) | ||||
|                     else: | ||||
|                         click.echo("y " + j.path + " " + j.identity) | ||||
|                     continue | ||||
|  | ||||
|                 click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white")) | ||||
|                 click.echo("="*(len(j.common_name)+60)) | ||||
|             if show_path: | ||||
|                 click.echo("Details: openssl req -in %s -text -noout" % j.path) | ||||
|                 click.echo("Sign: certidude sign %s" % j.path) | ||||
|             click.echo() | ||||
|  | ||||
|     if show_signed: | ||||
|         for j in authority.list_signed(): | ||||
|             if not verbose: | ||||
|                 if j.signed < NOW and j.expires > NOW: | ||||
|                     click.echo("Status: \u2713 " + click.style("valid", fg="green") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires,  fg="white")) | ||||
|                     click.echo("v " + j.path + " " + j.identity) | ||||
|                 elif NOW > j.expires: | ||||
|                     click.echo("Status: \u2717 " + click.style("expired", fg="red") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires,  fg="white")) | ||||
|                     click.echo("e " + j.path + " " + j.identity) | ||||
|                 else: | ||||
|                     click.echo("Status: \u2717 " + click.style("not valid yet", fg="red") + click.style(", %s" %j.expires,  fg="white")) | ||||
|                 dump_common(j) | ||||
|                     click.echo("y " + j.path + " " + j.identity) | ||||
|                 continue | ||||
|  | ||||
|                 if show_path: | ||||
|                     click.echo("Details: openssl x509 -in %s -text -noout" % j.path) | ||||
|                     click.echo("Revoke: certidude revoke %s" % j.path) | ||||
|                 click.echo() | ||||
|             click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white")) | ||||
|             click.echo("="*(len(j.common_name)+60)) | ||||
|  | ||||
|         if show_revoked: | ||||
|             for j in ca.get_revoked(): | ||||
|                 if not verbose: | ||||
|                     click.echo("r " + j.path + " " + j.identity) | ||||
|                     continue | ||||
|                 click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white")) | ||||
|                 click.echo("="*(len(j.common_name)+60)) | ||||
|                 click.echo("Status: \u2717 " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-j.changed), click.style(", %s" % j.changed, fg="white"))) | ||||
|                 dump_common(j) | ||||
|                 if show_path: | ||||
|                     click.echo("Details: openssl x509 -in %s -text -noout" % j.path) | ||||
|                 click.echo() | ||||
|             if j.signed < NOW and j.expires > NOW: | ||||
|                 click.echo("Status: \u2713 " + click.style("valid", fg="green") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires,  fg="white")) | ||||
|             elif NOW > j.expires: | ||||
|                 click.echo("Status: \u2717 " + click.style("expired", fg="red") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires,  fg="white")) | ||||
|             else: | ||||
|                 click.echo("Status: \u2717 " + click.style("not valid yet", fg="red") + click.style(", %s" %j.expires,  fg="white")) | ||||
|             dump_common(j) | ||||
|  | ||||
|         click.echo() | ||||
|             if show_path: | ||||
|                 click.echo("Details: openssl x509 -in %s -text -noout" % j.path) | ||||
|                 click.echo("Revoke: certidude revoke %s" % j.path) | ||||
|             click.echo() | ||||
|  | ||||
| @click.command("list", help="List Certificate Authorities") | ||||
| @click.argument("ca") | ||||
| #@config.pop_certificate_authority() | ||||
| def cert_list(ca): | ||||
|     if show_revoked: | ||||
|         for j in authority.list_revoked(): | ||||
|             if not verbose: | ||||
|                 click.echo("r " + j.path + " " + j.identity) | ||||
|                 continue | ||||
|             click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white")) | ||||
|             click.echo("="*(len(j.common_name)+60)) | ||||
|             click.echo("Status: \u2717 " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-j.changed), click.style(", %s" % j.changed, fg="white"))) | ||||
|             dump_common(j) | ||||
|             if show_path: | ||||
|                 click.echo("Details: openssl x509 -in %s -text -noout" % j.path) | ||||
|             click.echo() | ||||
|  | ||||
|     mapping = {} | ||||
|     click.echo() | ||||
|  | ||||
|     config = load_config() | ||||
|  | ||||
|     click.echo("Listing certificates for: %s" % ca.certificate.subject.CN) | ||||
|  | ||||
|     for serial, reason, timestamp in ca.get_revoked(): | ||||
|         mapping[serial] = None, reason | ||||
|  | ||||
|     for certificate in ca.get_signed(): | ||||
|         mapping[certificate.serial] = certificate, None | ||||
|  | ||||
|     for serial, (certificate, reason) in sorted(mapping.items(), key=lambda j:j[0]): | ||||
|         if not reason: | ||||
|             click.echo("  %03d. %s %s" % (serial, certificate.subject.CN, (certificate.not_after-NOW))) | ||||
|         else: | ||||
|             click.echo("  %03d. Revoked due to: %s" % (serial, reason)) | ||||
|  | ||||
|     for request in ca.get_requests(): | ||||
|         click.echo("  ⌛  %s" % request.subject.CN) | ||||
|  | ||||
| @click.command("sign", help="Sign certificates") | ||||
| @click.argument("common_name") | ||||
| @click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN") | ||||
| @click.option("--lifetime", "-l", help="Lifetime") | ||||
| def certidude_sign(common_name, overwrite, lifetime): | ||||
|     config = load_config() | ||||
|     def iterate(): | ||||
|         for ca in config.all_authorities(): | ||||
|             for request in ca.get_requests(): | ||||
|                 if request.common_name != common_name: | ||||
|                     continue | ||||
|                 print(request.fingerprint(), request.common_name, request.path, request.key_usage) | ||||
|                 yield ca, request | ||||
|     request = authority.get_request(common_name) | ||||
|     if request.signable: | ||||
|         # Sign via signer process | ||||
|         cert = authority.sign(request) | ||||
|     else: | ||||
|         # Sign directly using private key | ||||
|         cert = authority.sign2(request, overwrite, True, lifetime) | ||||
|  | ||||
|     results = tuple(iterate()) | ||||
|     click.echo("Signed %s" % cert.identity) | ||||
|     for key, value, data in cert.extensions: | ||||
|         click.echo("Added extension %s: %s" % (key, value)) | ||||
|     click.echo() | ||||
|  | ||||
|     click.echo("Press Ctrl-C to cancel singing these requests...") | ||||
|     sys.stdin.readline() | ||||
|  | ||||
|     for ca, request in results: | ||||
|         if request.signable: | ||||
|             # Sign via signer process | ||||
|             cert = ca.sign(request) | ||||
|         else: | ||||
|             # Sign directly using private key | ||||
|             cert = ca.sign2(request, overwrite, True, lifetime) | ||||
|  | ||||
|         click.echo("Signed %s" % cert.identity) | ||||
|         for key, value, data in cert.extensions: | ||||
|             click.echo("Added extension %s: %s" % (key, value)) | ||||
|         click.echo() | ||||
|  | ||||
| @click.command("serve", help="Run built-in HTTP server") | ||||
| @click.option("-u", "--user", default="certidude", help="Run as user") | ||||
| @click.option("-p", "--port", default=80, help="Listen port") | ||||
|   | ||||
							
								
								
									
										27
									
								
								certidude/common.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								certidude/common.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
|  | ||||
| def expand_paths(): | ||||
|     """ | ||||
|     Prefix '..._path' keyword arguments of target function with 'directory' keyword argument | ||||
|     and create the directory if necessary | ||||
|  | ||||
|     TODO: Move to separate file | ||||
|     """ | ||||
|     def wrapper(func): | ||||
|         def wrapped(**arguments): | ||||
|             d = arguments.get("directory") | ||||
|             for key, value in arguments.items(): | ||||
|                 if key.endswith("_path"): | ||||
|                     if d: | ||||
|                         value = os.path.join(d, value) | ||||
|                     value = os.path.realpath(value) | ||||
|                     parent = os.path.dirname(value) | ||||
|                     if not os.path.exists(parent): | ||||
|                         click.echo("Making directory %s for %s" % (repr(parent), repr(key))) | ||||
|                         os.makedirs(parent) | ||||
|                     elif not os.path.isdir(parent): | ||||
|                         raise Exception("Path %s is not directory!" % parent) | ||||
|                     arguments[key] = value | ||||
|             return func(**arguments) | ||||
|         return wrapped | ||||
|     return wrapper | ||||
|  | ||||
							
								
								
									
										61
									
								
								certidude/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								certidude/config.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
|  | ||||
| import click | ||||
| import configparser | ||||
| import ipaddress | ||||
| import os | ||||
| import string | ||||
| from random import choice | ||||
|  | ||||
| cp = configparser.ConfigParser() | ||||
| cp.read("/etc/certidude.conf") | ||||
|  | ||||
| ADMIN_USERS = set([j for j in  cp.get("authorization", "admin_users").split(" ") if j]) | ||||
| ADMIN_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "admin_subnets").split(" ") if j]) | ||||
| AUTOSIGN_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "autosign_subnets").split(" ") if j]) | ||||
| REQUEST_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "request_subnets").split(" ") if j]).union(AUTOSIGN_SUBNETS) | ||||
|  | ||||
| SIGNER_SOCKET_PATH = "/run/certidude/signer.sock" | ||||
| SIGNER_PID_PATH = "/run/certidude/signer.pid" | ||||
|  | ||||
| AUTHORITY_DIR = "/var/lib/certidude" | ||||
| AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private_key_path") | ||||
| AUTHORITY_CERTIFICATE_PATH = cp.get("authority", "certificate_path") | ||||
| REQUESTS_DIR = cp.get("authority", "requests_dir") | ||||
| SIGNED_DIR = cp.get("authority", "signed_dir") | ||||
| REVOKED_DIR = cp.get("authority", "revoked_dir") | ||||
|  | ||||
| CERTIFICATE_BASIC_CONSTRAINTS = "CA:FALSE" | ||||
| CERTIFICATE_KEY_USAGE_FLAGS = "nonRepudiation,digitalSignature,keyEncipherment" | ||||
| CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS = "clientAuth" | ||||
| CERTIFICATE_LIFETIME = int(cp.get("signature", "certificate_lifetime")) | ||||
|  | ||||
| REVOCATION_LIST_LIFETIME = int(cp.get("signature", "revocation_list_lifetime")) | ||||
|  | ||||
| PUSH_TOKEN = "".join([choice(string.ascii_letters + string.digits) for j in range(0,32)]) | ||||
|  | ||||
| PUSH_TOKEN = "ca" | ||||
|  | ||||
| try: | ||||
|     PUSH_EVENT_SOURCE = cp.get("push", "event_source") | ||||
|     PUSH_LONG_POLL = cp.get("push", "long_poll") | ||||
|     PUSH_PUBLISH = cp.get("push", "publish") | ||||
| except configparser.NoOptionError: | ||||
|     PUSH_SERVER = cp.get("push", "server") | ||||
|     PUSH_EVENT_SOURCE = PUSH_SERVER + "/ev/%s" | ||||
|     PUSH_LONG_POLL = PUSH_SERVER + "/lp/%s" | ||||
|     PUSH_PUBLISH = PUSH_SERVER + "/pub?id=%s" | ||||
|  | ||||
|  | ||||
| from urllib.parse import urlparse | ||||
| o = urlparse(cp.get("authority", "database")) | ||||
| if o.scheme == "mysql": | ||||
|     import mysql.connector | ||||
|     DATABASE_POOL = mysql.connector.pooling.MySQLConnectionPool( | ||||
|         pool_size = 3, | ||||
|         user=o.username, | ||||
|         password=o.password, | ||||
|         host=o.hostname, | ||||
|         database=o.path[1:]) | ||||
| else: | ||||
|     raise NotImplementedError("Unsupported database scheme %s, currently only mysql://user:pass@host/database is supported" % o.scheme) | ||||
|  | ||||
							
								
								
									
										78
									
								
								certidude/decorators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								certidude/decorators.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
|  | ||||
| import falcon | ||||
| import ipaddress | ||||
| import json | ||||
| import re | ||||
| import types | ||||
| from datetime import date, time, datetime | ||||
| from OpenSSL import crypto | ||||
| from certidude.wrappers import Request, Certificate | ||||
|  | ||||
| def event_source(func): | ||||
|     def wrapped(self, req, resp, *args, **kwargs): | ||||
|         if req.get_header("Accept") == "text/event-stream": | ||||
|             resp.status = falcon.HTTP_SEE_OTHER | ||||
|             resp.location = req.context.get("ca").push_server + "/ev/" + req.context.get("ca").uuid | ||||
|             resp.body = "Redirecting to:" + resp.location | ||||
|             print("Delegating EventSource handling to:", resp.location) | ||||
|         return func(self, req, resp, *args, **kwargs) | ||||
|     return wrapped | ||||
|  | ||||
| class MyEncoder(json.JSONEncoder): | ||||
|     REQUEST_ATTRIBUTES = "signable", "identity", "changed", "common_name", \ | ||||
|         "organizational_unit", "given_name", "surname", "fqdn", "email_address", \ | ||||
|         "key_type", "key_length", "md5sum", "sha1sum", "sha256sum", "key_usage" | ||||
|  | ||||
|     CERTIFICATE_ATTRIBUTES = "revokable", "identity", "changed", "common_name", \ | ||||
|         "organizational_unit", "given_name", "surname", "fqdn", "email_address", \ | ||||
|         "key_type", "key_length", "sha256sum", "serial_number", "key_usage" | ||||
|  | ||||
|     def default(self, obj): | ||||
|         if isinstance(obj, crypto.X509Name): | ||||
|             try: | ||||
|                 return ", ".join(["%s=%s" % (k.decode("ascii"),v.decode("utf-8")) for k, v in obj.get_components()]) | ||||
|             except UnicodeDecodeError: # Work around old buggy pyopenssl | ||||
|                 return ", ".join(["%s=%s" % (k.decode("ascii"),v.decode("iso8859")) for k, v in obj.get_components()]) | ||||
|         if isinstance(obj, ipaddress._IPAddressBase): | ||||
|             return str(obj) | ||||
|         if isinstance(obj, set): | ||||
|             return tuple(obj) | ||||
|         if isinstance(obj, datetime): | ||||
|             return obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" | ||||
|         if isinstance(obj, date): | ||||
|             return obj.strftime("%Y-%m-%d") | ||||
|         if isinstance(obj, map): | ||||
|             return tuple(obj) | ||||
|         if isinstance(obj, types.GeneratorType): | ||||
|             return tuple(obj) | ||||
|         if isinstance(obj, Request): | ||||
|             return dict([(key, getattr(obj, key)) for key in self.REQUEST_ATTRIBUTES \ | ||||
|                 if hasattr(obj, key) and getattr(obj, key)]) | ||||
|         if isinstance(obj, Certificate): | ||||
|             return dict([(key, getattr(obj, key)) for key in self.CERTIFICATE_ATTRIBUTES \ | ||||
|                 if hasattr(obj, key) and getattr(obj, key)]) | ||||
|         if hasattr(obj, "serialize"): | ||||
|             return obj.serialize() | ||||
|         return json.JSONEncoder.default(self, obj) | ||||
|  | ||||
|  | ||||
| def serialize(func): | ||||
|     """ | ||||
|     Falcon response serialization | ||||
|     """ | ||||
|     def wrapped(instance, req, resp, **kwargs): | ||||
|         assert not req.get_param("unicode") or req.get_param("unicode") == u"✓", "Unicode sanity check failed" | ||||
|         resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate"); | ||||
|         resp.set_header("Pragma", "no-cache"); | ||||
|         resp.set_header("Expires", "0"); | ||||
|         r = func(instance, req, resp, **kwargs) | ||||
|         if resp.body is None: | ||||
|             if req.get_header("Accept").split(",")[0] == "application/json": | ||||
|                 resp.set_header("Content-Type", "application/json") | ||||
|                 resp.set_header("Content-Disposition", "inline") | ||||
|                 resp.body = json.dumps(r, cls=MyEncoder) | ||||
|             else: | ||||
|                 resp.body = repr(r) | ||||
|         return r | ||||
|     return wrapped | ||||
|  | ||||
| @@ -2,34 +2,9 @@ | ||||
| import click | ||||
| import os | ||||
| import urllib.request | ||||
| from certidude.wrappers import Certificate, Request | ||||
| from certidude import config | ||||
| from OpenSSL import crypto | ||||
|  | ||||
| def expand_paths(): | ||||
|     """ | ||||
|     Prefix '..._path' keyword arguments of target function with 'directory' keyword argument | ||||
|     and create the directory if necessary | ||||
|  | ||||
|     TODO: Move to separate file | ||||
|     """ | ||||
|     def wrapper(func): | ||||
|         def wrapped(**arguments): | ||||
|             d = arguments.get("directory") | ||||
|             for key, value in arguments.items(): | ||||
|                 if key.endswith("_path"): | ||||
|                     if d: | ||||
|                         value = os.path.join(d, value) | ||||
|                     value = os.path.realpath(value) | ||||
|                     parent = os.path.dirname(value) | ||||
|                     if not os.path.exists(parent): | ||||
|                         click.echo("Making directory %s for %s" % (repr(parent), repr(key))) | ||||
|                         os.makedirs(parent) | ||||
|                     elif not os.path.isdir(parent): | ||||
|                         raise Exception("Path %s is not directory!" % parent) | ||||
|                     arguments[key] = value | ||||
|             return func(**arguments) | ||||
|         return wrapped | ||||
|     return wrapper | ||||
|  | ||||
|  | ||||
| def certidude_request_certificate(url, key_path, request_path, certificate_path, authority_path, common_name, org_unit, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None, ip_address=None, dns=None): | ||||
|   | ||||
							
								
								
									
										28
									
								
								certidude/push.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								certidude/push.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
|  | ||||
| import click | ||||
| import urllib.request | ||||
| from certidude import config | ||||
|  | ||||
| def publish(event_type, event_data): | ||||
|     """ | ||||
|     Publish event on push server | ||||
|     """ | ||||
|     url = config.PUSH_PUBLISH % config.PUSH_TOKEN | ||||
|     click.echo("Posting event %s %s at %s, waiting for response..." % (repr(event_type), repr(event_data), repr(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") | ||||
|  | ||||
|     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) | ||||
|  | ||||
| @@ -1,59 +1,62 @@ | ||||
| <h1>{{authority.common_name}} management</h1> | ||||
|  | ||||
| <p>Hi {{session.username}},</p> | ||||
|  | ||||
| <p>Request submission is allowed from: {% if authority.request_subnets %}{% for i in authority.request_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}</p> | ||||
| <p>Autosign is allowed from: {% if authority.autosign_subnets %}{% for i in authority.autosign_subnets %}{{ i }} {% endfor %}{% else %}nowhere{% endif %}</p> | ||||
| <p>Authority administration is allowed from: {% if authority.admin_subnets %}{% for i in authority.admin_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %} | ||||
| <p>Authority administration allowed for: {% for i in authority.admin_users %}{{ i }} {% endfor %}</p> | ||||
| <p>Request submission is allowed from: {% if session.request_subnets %}{% for i in session.request_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}</p> | ||||
| <p>Autosign is allowed from: {% if session.autosign_subnets %}{% for i in session.autosign_subnets %}{{ i }} {% endfor %}{% else %}nowhere{% endif %}</p> | ||||
| <p>Authority administration is allowed from: {% if session.admin_subnets %}{% for i in session.admin_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %} | ||||
| <p>Authority administration allowed for: {% for i in session.admin_users %}{{ i }} {% endfor %}</p> | ||||
|  | ||||
| {% set s = authority.certificate.identity %} | ||||
| {% set s = session.certificate.identity %} | ||||
|  | ||||
|  | ||||
| <input id="search" class="icon search" type="search" placeholder="hostname, IP-address, etc"/> | ||||
|  | ||||
| <h1>Pending requests</h1> | ||||
|  | ||||
| <ul id="pending_requests"> | ||||
|     {% for request in authority.requests %} | ||||
|          {% include "request.html" %} | ||||
| 	{% endfor %} | ||||
|     <li class="notify"> | ||||
|         <p>No certificate signing requests to sign! You can  submit a certificate signing request by:</p> | ||||
|         <pre>certidude setup client {{authority.common_name}}</pre> | ||||
|     </li> | ||||
| </ul> | ||||
| <div id="requests"> | ||||
|     <h1>Pending requests</h1> | ||||
|  | ||||
| <h1>Signed certificates</h1> | ||||
|  | ||||
| <ul id="signed_certificates"> | ||||
|     {% for certificate in authority.signed | sort | reverse %} | ||||
|         {% include "signed.html" %} | ||||
| 	{% endfor %} | ||||
| </ul> | ||||
|  | ||||
| <h1>Revoked certificates</h1> | ||||
|  | ||||
| <p>To fetch certificate revocation list:</p> | ||||
| <pre> | ||||
| curl {{window.location.href}}api/revoked/ | openssl crl -text -noout | ||||
| </pre> | ||||
| <!-- | ||||
| <p>To perform online certificate status request</p> | ||||
|  | ||||
| <pre> | ||||
| curl {{request.url}}/certificate/ > authority.pem | ||||
| openssl ocsp -issuer authority.pem -CAfile authority.pem -url {{request.url}}/ocsp/ -serial 0x | ||||
| </pre> | ||||
| --> | ||||
| <ul> | ||||
|     {% for j in authority.revoked %} | ||||
|         <li id="certificate_{{ j.sha256sum }}"> | ||||
|             {{j.changed}} | ||||
|             {{j.serial_number}} <span class="monospace">{{j.identity}}</span> | ||||
|     <ul id="pending_requests"> | ||||
|         {% for request in session.requests %} | ||||
|              {% include "request.html" %} | ||||
| 	    {% endfor %} | ||||
|         <li class="notify"> | ||||
|             <p>No certificate signing requests to sign! You can  submit a certificate signing request by:</p> | ||||
|             <pre>certidude setup client {{session.common_name}}</pre> | ||||
|         </li> | ||||
|     {% else %} | ||||
|         <li>Great job! No certificate signing requests to sign.</li> | ||||
| 	{% endfor %} | ||||
| </ul> | ||||
|     </ul> | ||||
| </div> | ||||
|  | ||||
|  | ||||
| <div id="signed"> | ||||
|     <h1>Signed certificates</h1> | ||||
|     <ul id="signed_certificates"> | ||||
|         {% for certificate in session.signed | sort | reverse %} | ||||
|             {% include "signed.html" %} | ||||
| 	    {% endfor %} | ||||
|     </ul> | ||||
| </div> | ||||
|  | ||||
| <div id="revoked"> | ||||
|     <h1>Revoked certificates</h1> | ||||
|     <p>To fetch certificate revocation list:</p> | ||||
|     <pre> | ||||
|     curl {{window.location.href}}api/revoked/ | openssl crl -text -noout | ||||
|     </pre> | ||||
|     <!-- | ||||
|     <p>To perform online certificate status request</p> | ||||
|  | ||||
|     <pre> | ||||
|     curl {{request.url}}/certificate/ > session.pem | ||||
|     openssl ocsp -issuer session.pem -CAfile session.pem -url {{request.url}}/ocsp/ -serial 0x | ||||
|     </pre> | ||||
|     --> | ||||
|     <ul> | ||||
|         {% for j in session.revoked %} | ||||
|             <li id="certificate_{{ j.sha256sum }}"> | ||||
|                 {{j.changed}} | ||||
|                 {{j.serial_number}} <span class="monospace">{{j.identity}}</span> | ||||
|             </li> | ||||
|         {% else %} | ||||
|             <li>Great job! No certificate signing requests to sign.</li> | ||||
| 	    {% endfor %} | ||||
|     </ul> | ||||
| </div> | ||||
|   | ||||
| @@ -94,9 +94,7 @@ html,body { | ||||
| } | ||||
|  | ||||
| body { | ||||
|     background: #222; | ||||
|     background-image: url('../img/free_hexa_pattern_cc0_by_black_light_studio.png'); | ||||
|     background-position: center; | ||||
|     background: #fff; | ||||
| } | ||||
|  | ||||
| .comment { | ||||
| @@ -142,24 +140,31 @@ pre { | ||||
|     margin: 0 0; | ||||
| } | ||||
|  | ||||
| #container { | ||||
|     max-width: 60em; | ||||
|     margin: 1em auto; | ||||
|     background: #fff; | ||||
|     padding: 1em; | ||||
|     border-style: solid; | ||||
|     border-width: 2px; | ||||
|     border-color: #aaa; | ||||
|     border-radius: 10px; | ||||
|  | ||||
| .container { | ||||
|     max-width: 960px; | ||||
|     margin: 0 auto; | ||||
| } | ||||
|  | ||||
| li { | ||||
| #container li { | ||||
|     margin: 4px 0; | ||||
|     padding: 4px 0; | ||||
|     clear: both; | ||||
|     border-top: 1px dashed #ccc; | ||||
| } | ||||
|  | ||||
| #menu { | ||||
|     background-color: #444; | ||||
| } | ||||
|  | ||||
| #menu li { | ||||
|     color: #fff; | ||||
|     border: none; | ||||
|     display: inline; | ||||
|     margin: 1mm 5mm 1mm 0; | ||||
|     line-height: 200%; | ||||
| } | ||||
|  | ||||
| .icon{ | ||||
|     background-size: 24px; | ||||
|     padding-left: 36px; | ||||
|   | ||||
| @@ -11,7 +11,15 @@ | ||||
|     <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon"> | ||||
| </head> | ||||
| <body> | ||||
|     <div id="container"> | ||||
|     <div id="menu"> | ||||
|         <ul class="container"> | ||||
|           <li>Requests</li> | ||||
|           <li>Signed</li> | ||||
|           <li>Revoked</li> | ||||
|           <li>Log</li> | ||||
|         </ul> | ||||
|     </div> | ||||
|     <div id="container" class="container"> | ||||
|         Loading certificate authority... | ||||
|     </div> | ||||
| </body> | ||||
|   | ||||
| @@ -1,9 +1,8 @@ | ||||
| $(document).ready(function() { | ||||
|     console.info("Loading CA, to debug: curl " + window.location.href + " --negotiate -u : -H 'Accept: application/json'"); | ||||
|  | ||||
|     $.ajax({ | ||||
|         method: "GET", | ||||
|         url: "/api/session/", | ||||
|         url: "/api/", | ||||
|         dataType: "json", | ||||
|         error: function(response) { | ||||
|             if (response.responseJSON) { | ||||
| @@ -14,130 +13,116 @@ $(document).ready(function() { | ||||
|             $("#container").html(nunjucks.render('error.html', { message: msg })); | ||||
|         }, | ||||
|         success: function(session, status, xhr) { | ||||
|             console.info("Loaded CA list:", session); | ||||
|             console.info("Got:", session); | ||||
|  | ||||
|             if (!session.authorities) { | ||||
|                 alert("No certificate authorities to manage! Have you created one yet?"); | ||||
|                 return; | ||||
|             console.info("Opening EventSource from:", session.event_channel); | ||||
|  | ||||
|             var source = new EventSource(session.event_channel); | ||||
|  | ||||
|             source.onmessage = function(event) { | ||||
|                 console.log("Received server-sent event:", event); | ||||
|             } | ||||
|  | ||||
|             source.addEventListener("up-client", function(e) { | ||||
|                 console.log("Adding security association:" + e.data); | ||||
|                 var lease = JSON.parse(e.data); | ||||
|                 var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status"); | ||||
|                 $status.html(nunjucks.render('status.html', { | ||||
|                     lease: { | ||||
|                         address: lease.address, | ||||
|                         identity: lease.identity, | ||||
|                         acquired: new Date(), | ||||
|                         released: null | ||||
|                     }})); | ||||
|             }); | ||||
|  | ||||
|             source.addEventListener("down-client", function(e) { | ||||
|                 console.log("Removing security association:" + e.data); | ||||
|                 var lease = JSON.parse(e.data); | ||||
|                 var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status"); | ||||
|                 $status.html(nunjucks.render('status.html', { | ||||
|                     lease: { | ||||
|                         address: lease.address, | ||||
|                         identity: lease.identity, | ||||
|                         acquired: null, | ||||
|                         released: new Date() | ||||
|                     }})); | ||||
|             }); | ||||
|  | ||||
|             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); | ||||
|                 $.ajax({ | ||||
|                     method: "GET", | ||||
|                     url: "/api/request/" + e.data + "/", | ||||
|                     dataType: "json", | ||||
|                     success: function(request, status, xhr) { | ||||
|                         console.info(request); | ||||
|                         $("#pending_requests").prepend( | ||||
|                             nunjucks.render('request.html', { request: request })); | ||||
|                     } | ||||
|                 }); | ||||
|  | ||||
|             }); | ||||
|  | ||||
|             source.addEventListener("request_signed", function(e) { | ||||
|                 console.log("Request signed:", e.data); | ||||
|                 $("#request_" + e.data).slideUp("normal", function() { $(this).remove(); }); | ||||
|  | ||||
|                 $.ajax({ | ||||
|                     method: "GET", | ||||
|                     url: "/api/signed/" + e.data + "/", | ||||
|                     dataType: "json", | ||||
|                     success: function(certificate, status, xhr) { | ||||
|                         console.info(certificate); | ||||
|                         $("#signed_certificates").prepend( | ||||
|                             nunjucks.render('signed.html', { certificate: certificate })); | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             source.addEventListener("certificate_revoked", function(e) { | ||||
|                 console.log("Removing revoked certificate #" + e.data); | ||||
|                 $("#certificate_" + e.data).slideUp("normal", function() { $(this).remove(); }); | ||||
|             }); | ||||
|  | ||||
|             $("#container").html(nunjucks.render('authority.html', { session: session, window: window })); | ||||
|  | ||||
|             $.ajax({ | ||||
|                 method: "GET", | ||||
|                 url: "/api/", | ||||
|                 url: "/api/lease/", | ||||
|                 dataType: "json", | ||||
|                 success: function(authority, status, xhr) { | ||||
|                     console.info("Got CA:", authority); | ||||
|  | ||||
|                     console.info("Opening EventSource from:", authority.event_channel); | ||||
|  | ||||
|                     var source = new EventSource(authority.event_channel); | ||||
|  | ||||
|                     source.onmessage = function(event) { | ||||
|                         console.log("Received server-sent event:", event); | ||||
|                 success: function(leases, status, xhr) { | ||||
|                     console.info("Got leases:", leases); | ||||
|                     for (var j = 0; j < leases.length; j++) { | ||||
|                         var $status = $("#signed_certificates [data-dn='" + leases[j].identity + "'] .status"); | ||||
|                         if (!$status.length) { | ||||
|                             console.info("Detected rogue client:", leases[j]); | ||||
|                             continue; | ||||
|                         } | ||||
|                         $status.html(nunjucks.render('status.html', { | ||||
|                             lease: { | ||||
|                                 address: leases[j].address, | ||||
|                                 identity: leases[j].identity, | ||||
|                                 acquired: new Date(leases[j].acquired).toLocaleString(), | ||||
|                                 released: leases[j].released ? new Date(leases[j].released).toLocaleString() : null | ||||
|                             }})); | ||||
|                     } | ||||
|  | ||||
|                     source.addEventListener("up-client", function(e) { | ||||
|                         console.log("Adding security association:" + e.data); | ||||
|                         var lease = JSON.parse(e.data); | ||||
|                         var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status"); | ||||
|                         $status.html(nunjucks.render('status.html', { | ||||
|                             lease: { | ||||
|                                 address: lease.address, | ||||
|                                 identity: lease.identity, | ||||
|                                 acquired: new Date(), | ||||
|                                 released: null | ||||
|                             }})); | ||||
|                     }); | ||||
|  | ||||
|                     source.addEventListener("down-client", function(e) { | ||||
|                         console.log("Removing security association:" + e.data); | ||||
|                         var lease = JSON.parse(e.data); | ||||
|                         var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status"); | ||||
|                         $status.html(nunjucks.render('status.html', { | ||||
|                             lease: { | ||||
|                                 address: lease.address, | ||||
|                                 identity: lease.identity, | ||||
|                                 acquired: null, | ||||
|                                 released: new Date() | ||||
|                             }})); | ||||
|                     }); | ||||
|  | ||||
|                     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); | ||||
|                         $.ajax({ | ||||
|                             method: "GET", | ||||
|                             url: "/api/request/lauri-c720p/", | ||||
|                             dataType: "json", | ||||
|                             success: function(request, status, xhr) { | ||||
|                                 console.info(request); | ||||
|                                 $("#pending_requests").prepend( | ||||
|                                     nunjucks.render('request.html', { request: request })); | ||||
|                     /* Set up search box */ | ||||
|                     $("#search").on("keyup", function() { | ||||
|                         var q = $("#search").val().toLowerCase(); | ||||
|                         $(".filterable").each(function(i, e) { | ||||
|                             if ($(e).attr("data-dn").toLowerCase().indexOf(q) >= 0) { | ||||
|                                 $(e).show(); | ||||
|                             } else { | ||||
|                                 $(e).hide(); | ||||
|                             } | ||||
|                         }); | ||||
|  | ||||
|                     }); | ||||
|  | ||||
|                     source.addEventListener("request_signed", function(e) { | ||||
|                         console.log("Request signed:", e.data); | ||||
|                         $("#request_" + e.data).slideUp("normal", function() { $(this).remove(); }); | ||||
|  | ||||
|                         $.ajax({ | ||||
|                             method: "GET", | ||||
|                             url: "/api/signed/lauri-c720p/", | ||||
|                             dataType: "json", | ||||
|                             success: function(certificate, status, xhr) { | ||||
|                                 console.info(certificate); | ||||
|                                 $("#signed_certificates").prepend( | ||||
|                                     nunjucks.render('signed.html', { certificate: certificate })); | ||||
|                             } | ||||
|                         }); | ||||
|                     }); | ||||
|  | ||||
|                     source.addEventListener("certificate_revoked", function(e) { | ||||
|                         console.log("Removing revoked certificate #" + e.data); | ||||
|                         $("#certificate_" + e.data).slideUp("normal", function() { $(this).remove(); }); | ||||
|                     }); | ||||
|  | ||||
|                     $("#container").html(nunjucks.render('authority.html', { authority: authority, session: session, window: window })); | ||||
|  | ||||
|                     $.ajax({ | ||||
|                         method: "GET", | ||||
|                         url: "/api/lease/", | ||||
|                         dataType: "json", | ||||
|                         success: function(leases, status, xhr) { | ||||
|                             console.info("Got leases:", leases); | ||||
|                             for (var j = 0; j < leases.length; j++) { | ||||
|                                 var $status = $("#signed_certificates [data-dn='" + leases[j].identity + "'] .status"); | ||||
|                                 if (!$status.length) { | ||||
|                                     console.info("Detected rogue client:", leases[j]); | ||||
|                                     continue; | ||||
|                                 } | ||||
|                                 $status.html(nunjucks.render('status.html', { | ||||
|                                     lease: { | ||||
|                                         address: leases[j].address, | ||||
|                                         identity: leases[j].identity, | ||||
|                                         acquired: new Date(leases[j].acquired).toLocaleString(), | ||||
|                                         released: leases[j].released ? new Date(leases[j].released).toLocaleString() : null | ||||
|                                     }})); | ||||
|                             } | ||||
|  | ||||
|                             /* Set up search box */ | ||||
|                             $("#search").on("keyup", function() { | ||||
|                                 var q = $("#search").val().toLowerCase(); | ||||
|                                 $(".filterable").each(function(i, e) { | ||||
|                                     if ($(e).attr("data-dn").toLowerCase().indexOf(q) >= 0) { | ||||
|                                         $(e).show(); | ||||
|                                     } else { | ||||
|                                         $(e).hide(); | ||||
|                                     } | ||||
|                                 }); | ||||
|                             }); | ||||
|                         } | ||||
|                     }); | ||||
|                 } | ||||
|             }); | ||||
|   | ||||
							
								
								
									
										20
									
								
								certidude/templates/certidude.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								certidude/templates/certidude.conf
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| [authorization] | ||||
| admin_users = administrator | ||||
| admin_subnets = 0.0.0.0/0 | ||||
| request_subnets = 0.0.0.0/0 | ||||
| autosign_subnets = 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 | ||||
|  | ||||
| [signature] | ||||
| certificate_lifetime = 1825 | ||||
| revocation_list_lifetime = 1 | ||||
|  | ||||
| [push] | ||||
| server = | ||||
|  | ||||
| [authority] | ||||
| private_key_path = {{ ca_key }} | ||||
| certificate_path = {{ ca_crt }} | ||||
| requests_dir = {{ directory }}/requests/ | ||||
| signed_dir = {{ directory }}/signed/ | ||||
| revoked_dir = {{ directory }}/revoked/ | ||||
|  | ||||
| @@ -1,45 +0,0 @@ | ||||
| # You have to copy the settings to the system-wide | ||||
| # OpenSSL configuration (usually /etc/ssl/openssl.cnf | ||||
|  | ||||
| [CA_{{common_name}}] | ||||
| default_crl_days = {{revocation_list_lifetime}} | ||||
| default_days = {{certificate_lifetime}} | ||||
| dir = {{directory}} | ||||
| private_key = $dir/ca_key.pem | ||||
| certificate = $dir/ca_crt.pem | ||||
| new_certs_dir = $dir/requests/ | ||||
| revoked_certs_dir = $dir/revoked/ | ||||
| certs = $dir/signed/ | ||||
| crl = $dir/ca_crl.pem | ||||
| serial = $dir/serial | ||||
| {% if crl_distribution_points %} | ||||
| crlDistributionPoints = {{crl_distribution_points}} | ||||
| {% endif %} | ||||
| {% if email_address %} | ||||
| emailAddress = {{email_address}} | ||||
| {% endif %} | ||||
| x509_extensions = {{common_name}}_cert | ||||
| policy = policy_{{common_name}} | ||||
|  | ||||
| # Certidude specific stuff, TODO: move to separate section? | ||||
| request_subnets = 10.0.0.0/8 192.168.0.0/16 172.168.0.0/16 | ||||
| autosign_subnets = 127.0.0.0/8 | ||||
| admin_subnets = 127.0.0.0/8 | ||||
| admin_users = | ||||
| inbox = {{inbox}} | ||||
| outbox = {{outbox}} | ||||
| push_server = {{push_server}} | ||||
|  | ||||
| [policy_{{common_name}}] | ||||
| countryName = match | ||||
| stateOrProvinceName = match | ||||
| organizationName = match | ||||
| organizationalUnitName = optional | ||||
| commonName = supplied | ||||
| emailAddress = optional | ||||
|  | ||||
| [{{common_name}}_cert] | ||||
| basicConstraints = CA:FALSE | ||||
| keyUsage = nonRepudiation,digitalSignature,keyEncipherment | ||||
| extendedKeyUsage = clientAuth | ||||
|  | ||||
| @@ -1,60 +1,14 @@ | ||||
| import os | ||||
| import hashlib | ||||
| import logging | ||||
| import re | ||||
| import itertools | ||||
| import click | ||||
| import socket | ||||
| import io | ||||
| import urllib.request | ||||
| import ipaddress | ||||
| from configparser import RawConfigParser | ||||
| from certidude import push | ||||
| from Crypto.Util import asn1 | ||||
| from OpenSSL import crypto | ||||
| from datetime import datetime | ||||
| from jinja2 import Environment, PackageLoader, Template | ||||
| from certidude.mailer import Mailer | ||||
| from certidude.signer import raw_sign, EXTENSION_WHITELIST | ||||
|  | ||||
| env = Environment(loader=PackageLoader("certidude", "email_templates")) | ||||
|  | ||||
| # https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/ | ||||
| # https://jamielinux.com/docs/openssl-certificate-authority/ | ||||
| # http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py | ||||
|  | ||||
| def publish_certificate(func): | ||||
|     # TODO: Implement e-mail and nginx notifications using hooks | ||||
|     def wrapped(instance, csr, *args, **kwargs): | ||||
|         cert = func(instance, csr, *args, **kwargs) | ||||
|         assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert)) | ||||
|  | ||||
|         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 | ||||
|  | ||||
| #        self.mailer.send( | ||||
| #            self.certificate.email_address, | ||||
| #            (self.certificate.email_address, cert.email_address), | ||||
| #            "Certificate %s signed" % cert.distinguished_name, | ||||
| #            "certificate-signed", | ||||
| #            old_cert=old_cert, | ||||
| #            cert=cert, | ||||
| #            ca=self.certificate) | ||||
|  | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| def subject2dn(subject): | ||||
|     bits = [] | ||||
|     for j in "CN", "GN", "SN", "C", "S", "L", "O", "OU": | ||||
| @@ -62,79 +16,6 @@ def subject2dn(subject): | ||||
|             bits.append("%s=%s" % (j, getattr(subject, j))) | ||||
|     return ", ".join(bits) | ||||
|  | ||||
| class CertificateAuthorityConfig(object): | ||||
|     """ | ||||
|     Certificate Authority configuration | ||||
|  | ||||
|     :param path: Absolute path to configuration file. | ||||
|                  Defaults to /etc/ssl/openssl.cnf | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, path='/etc/ssl/openssl.cnf', *args): | ||||
|  | ||||
|         #: Path to file where current configuration is loaded from. | ||||
|         self.path = path | ||||
|  | ||||
|         self._config = RawConfigParser() | ||||
|         self._config.readfp(itertools.chain(["[global]"], open(self.path))) | ||||
|  | ||||
|     def get(self, section, key, default=""): | ||||
|         if self._config.has_option(section, key): | ||||
|             return self._config.get(section, key) | ||||
|         else: | ||||
|             return default | ||||
|  | ||||
|     def instantiate_authority(self, common_name): | ||||
|         section = "CA_" + common_name | ||||
|  | ||||
|         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", "push_server", "database", "inbox", "outbox")]) | ||||
|  | ||||
|         # Variable expansion, eg $dir | ||||
|         for key, value in dirs.items(): | ||||
|             if "$" in value: | ||||
|                 dirs[key] = re.sub(r'\$([a-z]+)', lambda m:dirs[m.groups()[0]], value) | ||||
|  | ||||
|         dirs.pop("dir") | ||||
|         dirs["email_address"] = self.get(section, "emailAddress") | ||||
|         dirs["certificate_lifetime"] = int(self.get(section, "default_days", "1825")) | ||||
|         dirs["revocation_list_lifetime"] = int(self.get(section, "default_crl_days", "1")) | ||||
|  | ||||
|         extensions_section = self.get(section, "x509_extensions") | ||||
|         if extensions_section: | ||||
|             dirs["basic_constraints"] = self.get(extensions_section, "basicConstraints") | ||||
|             dirs["key_usage"] = self.get(extensions_section, "keyUsage") | ||||
|             dirs["extended_key_usage"] = self.get(extensions_section, "extendedKeyUsage") | ||||
|         authority = CertificateAuthority(common_name, **dirs) | ||||
|         return authority | ||||
|  | ||||
|  | ||||
|     def all_authorities(self, wanted=None): | ||||
|         for ca in self.ca_list: | ||||
|             if wanted and ca not in wanted: | ||||
|                 continue | ||||
|             try: | ||||
|                 yield self.instantiate_authority(ca) | ||||
|             except FileNotFoundError: | ||||
|                 pass | ||||
|  | ||||
|  | ||||
|     @property | ||||
|     def ca_list(self): | ||||
|         """ | ||||
|         Returns sorted list of CA-s defined in the configuration file. | ||||
|         """ | ||||
|         return sorted([s[3:] for s in self._config if s.startswith("CA_")]) | ||||
|  | ||||
|     def pop_certificate_authority(self): | ||||
|         def wrapper(func): | ||||
|             def wrapped(*args, **kwargs): | ||||
|                 common_name = kwargs.pop("ca") | ||||
|                 kwargs["ca"] = self.instantiate_authority(common_name) | ||||
|                 return func(*args, **kwargs) | ||||
|             return wrapped | ||||
|         return wrapper | ||||
|  | ||||
| class CertificateBase: | ||||
|     def __repr__(self): | ||||
|         return self.buf | ||||
| @@ -351,9 +232,6 @@ class Request(CertificateBase): | ||||
|     def dump(self): | ||||
|         return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self._obj).decode("ascii") | ||||
|  | ||||
|     def __repr__(self): | ||||
|         return "Request(%s)" % repr(self.path) | ||||
|  | ||||
|     def create(self): | ||||
|         # Generate 4096-bit RSA key | ||||
|         key = crypto.PKey() | ||||
| @@ -364,6 +242,7 @@ class Request(CertificateBase): | ||||
|         req.set_pubkey(key) | ||||
|         return Request(req) | ||||
|  | ||||
|  | ||||
| class Certificate(CertificateBase): | ||||
|     def __init__(self, mixed): | ||||
|         self.buf = NotImplemented | ||||
| @@ -387,7 +266,7 @@ class Certificate(CertificateBase): | ||||
|         if isinstance(mixed, crypto.X509): | ||||
|             self._obj = mixed | ||||
|         else: | ||||
|             raise ValueError("Can't parse %s as X.509 certificate!" % mixed) | ||||
|             raise ValueError("Can't parse %s (%s) as X.509 certificate!" % (mixed, type(mixed))) | ||||
|  | ||||
|         assert not self.buf or self.buf == self.dump(), "%s is not %s" % (self.buf, self.dump()) | ||||
|  | ||||
| @@ -435,275 +314,3 @@ class Certificate(CertificateBase): | ||||
|     def __lte__(self, other): | ||||
|         return self.signed <= other.signed | ||||
|  | ||||
| class CertificateAuthority(object): | ||||
|     def __init__(self, common_name, 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, database=None): | ||||
|  | ||||
|         import hashlib | ||||
|         m = hashlib.sha512() | ||||
|         m.update(common_name.encode("ascii")) | ||||
|         m.update(b"TODO:server-secret-goes-here") | ||||
|         self.uuid = m.hexdigest() | ||||
|  | ||||
|         self.revocation_list = crl | ||||
|         self.signed_dir = certs | ||||
|         self.request_dir = new_certs_dir | ||||
|         self.revoked_dir = revoked_certs_dir | ||||
|         self.private_key = private_key | ||||
|  | ||||
|         self.admin_subnets = set([ipaddress.ip_network(j) for j in admin_subnets.split(" ") if j]) | ||||
|         self.autosign_subnets = set([ipaddress.ip_network(j) for j in autosign_subnets.split(" ") if j]) | ||||
|         self.request_subnets = set([ipaddress.ip_network(j) for j in request_subnets.split(" ") if j]).union(self.autosign_subnets) | ||||
|  | ||||
|         self.certificate = Certificate(open(certificate)) | ||||
|         self.mailer = Mailer(outbox) if outbox else None | ||||
|         self.push_server = push_server | ||||
|         self.database_url = database | ||||
|         self._database_pool = None | ||||
|  | ||||
|         self.certificate_lifetime = certificate_lifetime | ||||
|         self.revocation_list_lifetime = revocation_list_lifetime | ||||
|         self.basic_constraints = basic_constraints | ||||
|         self.key_usage = key_usage | ||||
|         self.extended_key_usage = extended_key_usage | ||||
|  | ||||
|         self.admin_emails = dict() | ||||
|         self.admin_users = set() | ||||
|         if admin_users: | ||||
|             if admin_users.startswith("/"): | ||||
|                 for user in open(admin_users): | ||||
|                     if ":" in user: | ||||
|                         user, email, first_name, last_name = user.split(":") | ||||
|                     self.admin_emails[user] = email | ||||
|                     self.admin_users.add(user) | ||||
|             else: | ||||
|                 self.admin_users = set([j for j in admin_users.split(" ") if j]) | ||||
|  | ||||
|     @property | ||||
|     def common_name(self): | ||||
|         return self.certificate.common_name | ||||
|  | ||||
|     @property | ||||
|     def database(self): | ||||
|         from urllib.parse import urlparse | ||||
|         if not self._database_pool: | ||||
|             o = urlparse(self.database_url) | ||||
|             if o.scheme == "mysql": | ||||
|                 import mysql.connector | ||||
|                 self._database_pool = mysql.connector.pooling.MySQLConnectionPool( | ||||
|                     pool_size = 3, | ||||
|                     user=o.username, | ||||
|                     password=o.password, | ||||
|                     host=o.hostname, | ||||
|                     database=o.path[1:]) | ||||
|             else: | ||||
|                 raise NotImplementedError("Unsupported database scheme %s, currently only mysql://user:pass@host/database is supported" % o.scheme) | ||||
|  | ||||
|         return self._database_pool | ||||
|  | ||||
|     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")) | ||||
|         sock.send(b"\n") | ||||
|         for bit in bits: | ||||
|             sock.send(bit.encode("ascii")) | ||||
|         sock.sendall(b"\n\n") | ||||
|         buf = sock.recv(8192) | ||||
|         if not buf: | ||||
|             raise | ||||
|         return buf | ||||
|  | ||||
|     def __repr__(self): | ||||
|         return "CertificateAuthority(common_name=%s)" % repr(self.common_name) | ||||
|  | ||||
|     def get_certificate(self, cn): | ||||
|         return open(os.path.join(self.signed_dir, cn + ".pem")).read() | ||||
|  | ||||
|     def connect_signer(self): | ||||
|         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | ||||
|         sock.connect("/run/certidude/signer/%s.sock" % self.common_name) | ||||
|         return sock | ||||
|  | ||||
|     def revoke(self, cn): | ||||
|         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): | ||||
|             for filename in files: | ||||
|                 if filename.endswith(".pem"): | ||||
|                     yield Certificate(open(os.path.join(root, filename))) | ||||
|             break | ||||
|  | ||||
|     def get_signed(self): | ||||
|         for root, dirs, files in os.walk(self.signed_dir): | ||||
|             for filename in files: | ||||
|                 if filename.endswith(".pem"): | ||||
|                     yield Certificate(open(os.path.join(root, filename))) | ||||
|             break | ||||
|  | ||||
|     def get_requests(self): | ||||
|         for root, dirs, files in os.walk(self.request_dir): | ||||
|             for filename in files: | ||||
|                 if filename.endswith(".pem"): | ||||
|                     yield Request(open(os.path.join(root, filename))) | ||||
|             break | ||||
|  | ||||
|     def get_request(self, cn): | ||||
|         return Request(open(os.path.join(self.request_dir, cn + ".pem"))) | ||||
|  | ||||
|     def store_request(self, buf, overwrite=False): | ||||
|         request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf) | ||||
|         common_name = request.get_subject().CN | ||||
|         request_path = os.path.join(self.request_dir, common_name + ".pem") | ||||
|  | ||||
|         # If there is cert, check if it's the same | ||||
|         if os.path.exists(request_path): | ||||
|             if open(request_path, "rb").read() != buf: | ||||
|                 print("Request already exists, not creating new request") | ||||
|                 raise FileExistsError("Request already exists") | ||||
|         else: | ||||
|             with open(request_path + ".part", "wb") as fh: | ||||
|                 fh.write(buf) | ||||
|             os.rename(request_path + ".part", request_path) | ||||
|  | ||||
|         return Request(open(request_path)) | ||||
|  | ||||
|     def request_exists(self, cn): | ||||
|         return os.path.exists(os.path.join(self.request_dir, cn + ".pem")) | ||||
|  | ||||
|     def delete_request(self, cn): | ||||
|         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() | ||||
|         req.country = self.certificate.country | ||||
|         req.state_or_county = self.certificate.state_or_county | ||||
|         req.city = self.certificate.city | ||||
|         req.organization = self.certificate.organization | ||||
|         req.organizational_unit = organizational_unit or self.certificate.organizational_unit | ||||
|         req.common_name = common_name | ||||
|         req.email_address = email_address | ||||
|         cert_buf = self.sign(req, overwrite) | ||||
|         return crypto.dump_privatekey(crypto.FILETYPE_PEM, key).decode("ascii"), \ | ||||
|             req_buf, cert_buf | ||||
|  | ||||
|     @publish_certificate | ||||
|     def sign(self, req, overwrite=False, delete=True): | ||||
|         """ | ||||
|         Sign certificate signing request via signer process | ||||
|         """ | ||||
|  | ||||
|         cert_path = os.path.join(self.signed_dir, req.common_name + ".pem") | ||||
|  | ||||
|         # Move existing certificate if necessary | ||||
|         if os.path.exists(cert_path): | ||||
|             old_cert = Certificate(open(cert_path)) | ||||
|             if overwrite: | ||||
|                 self.revoke(req.common_name) | ||||
|             elif req.pubkey == old_cert.pubkey: | ||||
|                 return old_cert | ||||
|             else: | ||||
|                 raise FileExistsError("Will not overwrite existing certificate") | ||||
|  | ||||
|         # Sign via signer process | ||||
|         cert_buf = self._signer_exec("sign-request", req.dump()) | ||||
|         with open(cert_path + ".part", "wb") as fh: | ||||
|             fh.write(cert_buf) | ||||
|         os.rename(cert_path + ".part", cert_path) | ||||
|  | ||||
|         return Certificate(open(cert_path)) | ||||
|  | ||||
|     @publish_certificate | ||||
|     def sign2(self, request, overwrite=False, delete=True, lifetime=None): | ||||
|         """ | ||||
|         Sign directly using private key, this is usually done by root. | ||||
|         Basic constraints and certificate lifetime are copied from openssl.cnf, | ||||
|         lifetime may be overridden on the command line, | ||||
|         other extensions are copied as is. | ||||
|         """ | ||||
|         cert = raw_sign( | ||||
|             crypto.load_privatekey(crypto.FILETYPE_PEM, open(self.private_key).read()), | ||||
|             self.certificate._obj, | ||||
|             request._obj, | ||||
|             self.basic_constraints, | ||||
|             lifetime=lifetime or self.certificate_lifetime) | ||||
|  | ||||
|         path = os.path.join(self.signed_dir, request.common_name + ".pem") | ||||
|         if os.path.exists(path): | ||||
|             if overwrite: | ||||
|                 self.revoke(request.common_name) | ||||
|             else: | ||||
|                 raise FileExistsError("File %s already exists!" % path) | ||||
|  | ||||
|         buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) | ||||
|         with open(path + ".part", "wb") as fh: | ||||
|             fh.write(buf) | ||||
|         os.rename(path + ".part", path) | ||||
|         click.echo("Wrote certificate to: %s" % path) | ||||
|         if delete: | ||||
|             os.unlink(request.path) | ||||
|             click.echo("Deleted request: %s" % request.path) | ||||
|  | ||||
|         return Certificate(open(path)) | ||||
|  | ||||
|     def export_crl(self): | ||||
|         sock = self.connect_signer() | ||||
|         sock.send(b"export-crl\n") | ||||
|         for filename in os.listdir(self.revoked_dir): | ||||
|             if not filename.endswith(".pem"): | ||||
|                 continue | ||||
|             serial_number = filename[:-4] | ||||
|             # TODO: Assert serial against regex | ||||
|             revoked_path = os.path.join(self.revoked_dir, filename) | ||||
|             # TODO: Skip expired certificates | ||||
|             s = os.stat(revoked_path) | ||||
|             sock.send(("%s:%d\n" % (serial_number, s.st_ctime)).encode("ascii")) | ||||
|         sock.sendall(b"\n") | ||||
|         return sock.recv(32*1024*1024) | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user