mirror of
				https://github.com/laurivosandi/certidude
				synced 2025-10-31 17:39:12 +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 | .. 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 |     pip3 install certidude | ||||||
|  |  | ||||||
| Make sure you're running PyOpenSSL 0.15+ and netifaces 0.10.4+ from PyPI, | 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. | domain name set up properly. | ||||||
| You can check it with: | You can check it with: | ||||||
|  |  | ||||||
|  | .. code:: bash | ||||||
|  |  | ||||||
|   hostname -f |   hostname -f | ||||||
|  |  | ||||||
| The command should return ca.example.co | 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 click | ||||||
| import falcon | import falcon | ||||||
|  | import ipaddress | ||||||
| import kerberos | import kerberos | ||||||
| import os | import os | ||||||
| import re | import re | ||||||
| @@ -70,3 +71,28 @@ def login_required(func): | |||||||
|             raise falcon.HTTPForbidden("Forbidden", "Tried GSSAPI") |             raise falcon.HTTPForbidden("Forbidden", "Tried GSSAPI") | ||||||
|  |  | ||||||
|     return wrapped |     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)) | ||||||
|  |  | ||||||
							
								
								
									
										160
									
								
								certidude/cli.py
									
									
									
									
									
								
							
							
						
						
									
										160
									
								
								certidude/cli.py
									
									
									
									
									
								
							| @@ -13,10 +13,9 @@ import signal | |||||||
| import socket | import socket | ||||||
| import subprocess | import subprocess | ||||||
| import sys | import sys | ||||||
| from certidude.helpers import expand_paths, \ | from certidude import authority | ||||||
|     certidude_request_certificate |  | ||||||
| from certidude.signer import SignServer | from certidude.signer import SignServer | ||||||
| from certidude.wrappers import CertificateAuthorityConfig, subject2dn | from certidude.common import expand_paths | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from humanize import naturaltime | from humanize import naturaltime | ||||||
| from ipaddress import ip_network, ip_address | from ipaddress import ip_network, ip_address | ||||||
| @@ -62,20 +61,15 @@ if os.getuid() >= 1000: | |||||||
|         FIRST_NAME = gecos |         FIRST_NAME = gecos | ||||||
|  |  | ||||||
|  |  | ||||||
| def load_config(): | @click.command("spawn", help="Run privilege isolated signer process") | ||||||
|     path = os.getenv('CERTIDUDE_CONF') | @click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instance") | ||||||
|     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.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys") | @click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys") | ||||||
| def certidude_spawn(kill, no_interaction): | def certidude_spawn(kill, no_interaction): | ||||||
|     """ |     """ | ||||||
|     Spawn processes for signers |     Spawn processes for signers | ||||||
|     """ |     """ | ||||||
|  |     from certidude import config | ||||||
|  |  | ||||||
|     # Check whether we have privileges |     # Check whether we have privileges | ||||||
|     os.umask(0o027) |     os.umask(0o027) | ||||||
|     uid = os.getuid() |     uid = os.getuid() | ||||||
| @@ -84,13 +78,12 @@ def certidude_spawn(kill, no_interaction): | |||||||
|  |  | ||||||
|     # Process directories |     # Process directories | ||||||
|     run_dir = "/run/certidude" |     run_dir = "/run/certidude" | ||||||
|     signer_dir = os.path.join(run_dir, "signer") |     chroot_dir = os.path.join(run_dir, "jail") | ||||||
|     chroot_dir = os.path.join(signer_dir, "jail") |  | ||||||
|  |  | ||||||
|     # Prepare signer PID-s directory |     # Prepare signer PID-s directory | ||||||
|     if not os.path.exists(signer_dir): |     if not os.path.exists(run_dir): | ||||||
|         click.echo("Creating: %s" % signer_dir) |         click.echo("Creating: %s" % run_dir) | ||||||
|         os.makedirs(signer_dir) |         os.makedirs(run_dir) | ||||||
|  |  | ||||||
|     # Preload charmap encoding for byte_string() function of pyOpenSSL |     # Preload charmap encoding for byte_string() function of pyOpenSSL | ||||||
|     # in order to enable chrooting |     # in order to enable chrooting | ||||||
| @@ -104,16 +97,13 @@ def certidude_spawn(kill, no_interaction): | |||||||
|         os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom")) |         os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom")) | ||||||
|  |  | ||||||
|     ca_loaded = False |     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: |     try: | ||||||
|             with open(pidfile_path) as fh: |         with open(config.SIGNER_PID_PATH) as fh: | ||||||
|             pid = int(fh.readline()) |             pid = int(fh.readline()) | ||||||
|             os.kill(pid, 0) |             os.kill(pid, 0) | ||||||
|                 click.echo("Found process with PID %d for %s" % (pid, ca.common_name)) |             click.echo("Found process with PID %d" % pid) | ||||||
|     except (ValueError, ProcessLookupError, FileNotFoundError): |     except (ValueError, ProcessLookupError, FileNotFoundError): | ||||||
|         pid = 0 |         pid = 0 | ||||||
|  |  | ||||||
| @@ -127,31 +117,29 @@ def certidude_spawn(kill, no_interaction): | |||||||
|                 sleep(1) |                 sleep(1) | ||||||
|             except ProcessLookupError: |             except ProcessLookupError: | ||||||
|                 pass |                 pass | ||||||
|                 ca_loaded = True |  | ||||||
|             else: |  | ||||||
|                 ca_loaded = True |  | ||||||
|                 continue |  | ||||||
|  |  | ||||||
|     child_pid = os.fork() |     child_pid = os.fork() | ||||||
|  |  | ||||||
|     if child_pid == 0: |     if child_pid == 0: | ||||||
|             with open(pidfile_path, "w") as fh: |         with open(config.SIGNER_PID_PATH, "w") as fh: | ||||||
|             fh.write("%d\n" % os.getpid()) |             fh.write("%d\n" % os.getpid()) | ||||||
|  |  | ||||||
|             setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name)) | #        setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name)) | ||||||
|         logging.basicConfig( |         logging.basicConfig( | ||||||
|                 filename="/var/log/certidude-%s.log" % ca.common_name, |             filename="/var/log/signer.log", | ||||||
|             level=logging.INFO) |             level=logging.INFO) | ||||||
|             server = SignServer(socket_path, ca.private_key, ca.certificate.path, |         server = SignServer( | ||||||
|                 ca.certificate_lifetime, ca.basic_constraints, ca.key_usage, |             config.SIGNER_SOCKET_PATH, | ||||||
|                 ca.extended_key_usage, ca.revocation_list_lifetime) |             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() |         asyncore.loop() | ||||||
|     else: |     else: | ||||||
|             click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, socket_path)) |         click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH)) | ||||||
|         ca_loaded = True |  | ||||||
|  |  | ||||||
|     if not ca_loaded: |  | ||||||
|         raise click.ClickException("No CA sections defined in configuration: {}".format(config.path)) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @click.command("client", help="Setup X.509 certificates for application") | @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("--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") | @click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt by default") | ||||||
| def certidude_setup_client(quiet, **kwargs): | def certidude_setup_client(quiet, **kwargs): | ||||||
|  |     from certidude.helpers import certidude_request_certificate | ||||||
|     return certidude_request_certificate(**kwargs) |     return certidude_request_certificate(**kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -197,6 +186,7 @@ def certidude_setup_client(quiet, **kwargs): | |||||||
| @expand_paths() | @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): | 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 |     # TODO: Intelligent way of getting last IP address in the subnet | ||||||
|  |     from certidude.helpers import certidude_request_certificate | ||||||
|     subnet_first = None |     subnet_first = None | ||||||
|     subnet_last = None |     subnet_last = None | ||||||
|     subnet_second = 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("use following command to sign on Certidude server instead of web interface:") | ||||||
|         click.echo() |         click.echo() | ||||||
|         click.echo("  certidude sign %s" % common_name) |         click.echo("  certidude sign %s" % common_name) | ||||||
|  |  | ||||||
|     retval = certidude_request_certificate( |     retval = certidude_request_certificate( | ||||||
|         url, |         url, | ||||||
|         key_path, |         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("--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("--crl-distribution-url", default=None, help="CRL distribution URL") | ||||||
| @click.option("--ocsp-responder-url", default=None, help="OCSP responder 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("--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") | @click.option("--email-address", default="certidude@" + FQDN, help="E-mail address of the CA") | ||||||
| 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): | @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): | ||||||
|     if not directory: |  | ||||||
|         directory = os.path.join("/var/lib/certidude", common_name) |  | ||||||
|  |  | ||||||
|     # Make sure common_name is valid |     # Make sure common_name is valid | ||||||
|     if not re.match(r"^[\._a-zA-Z0-9]+$", common_name): |     if not re.match(r"^[\.\-_a-zA-Z0-9]+$", common_name): | ||||||
|         raise click.ClickException("CA name can contain only alphanumeric and '_' characters") |         raise click.ClickException("CA name can contain only alphanumeric, '_' and '-' characters") | ||||||
|  |  | ||||||
|     if os.path.lexists(directory): |     if os.path.lexists(directory): | ||||||
|         raise click.ClickException("Output directory {} already exists.".format(directory)) |         raise click.ClickException("Output directory {} already exists.".format(directory)) | ||||||
| @@ -612,7 +596,6 @@ def certidude_setup_authority(parent, country, state, locality, organization, or | |||||||
|             crl_distribution_points.encode("ascii")) |             crl_distribution_points.encode("ascii")) | ||||||
|     ]) |     ]) | ||||||
|  |  | ||||||
|     if email_address: |  | ||||||
|     subject_alt_name = "email:%s" % email_address |     subject_alt_name = "email:%s" % email_address | ||||||
|     ca.add_extensions([ |     ca.add_extensions([ | ||||||
|         crypto.X509Extension( |         crypto.X509Extension( | ||||||
| @@ -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 -in ca_crt.pem -outform DER | sha256sum | ||||||
|     # openssl x509 -fingerprint -in ca_crt.pem |     # 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: |     with open(ca_key, "wb") as fh: | ||||||
|         fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) |         fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) | ||||||
|  |  | ||||||
|     ssl_cnf_example = os.path.join(directory, "openssl.cnf.example") |     certidude_conf = os.path.join("/etc/certidude.conf") | ||||||
|     with open(ssl_cnf_example, "w") as fh: |     with open(certidude_conf, "w") as fh: | ||||||
|         fh.write(env.get_template("openssl.cnf").render(locals())) |         fh.write(env.get_template("certidude.conf").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") |  | ||||||
|  |  | ||||||
|     click.echo() |     click.echo() | ||||||
|     click.echo("Use following commands to inspect the newly created files:") |     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.command("list", help="List certificates") | ||||||
| @click.argument("ca", nargs=-1) |  | ||||||
| @click.option("--verbose", "-v", default=False, is_flag=True, help="Verbose output") | @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-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") | @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("--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-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") | @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: |     # Statuses: | ||||||
|     #   s - submitted |     #   s - submitted | ||||||
|     #   v - valid |     #   v - valid | ||||||
| @@ -738,18 +716,10 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_ | |||||||
|             if j.fqdn: |             if j.fqdn: | ||||||
|                 click.echo("Associated hostname: " + 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 |  | ||||||
|  |  | ||||||
|     for ca in config.all_authorities(wanted_list): |  | ||||||
|     if not hide_requests: |     if not hide_requests: | ||||||
|             for j in ca.get_requests(): |         for j in authority.list_requests(): | ||||||
|  |  | ||||||
|             if not verbose: |             if not verbose: | ||||||
|                 click.echo("s " + j.path + " " + j.identity) |                 click.echo("s " + j.path + " " + j.identity) | ||||||
|                 continue |                 continue | ||||||
| @@ -779,7 +749,7 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_ | |||||||
|             click.echo() |             click.echo() | ||||||
|  |  | ||||||
|     if show_signed: |     if show_signed: | ||||||
|             for j in ca.get_signed(): |         for j in authority.list_signed(): | ||||||
|             if not verbose: |             if not verbose: | ||||||
|                 if j.signed < NOW and j.expires > NOW: |                 if j.signed < NOW and j.expires > NOW: | ||||||
|                     click.echo("v " + j.path + " " + j.identity) |                     click.echo("v " + j.path + " " + j.identity) | ||||||
| @@ -806,7 +776,7 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_ | |||||||
|             click.echo() |             click.echo() | ||||||
|  |  | ||||||
|     if show_revoked: |     if show_revoked: | ||||||
|             for j in ca.get_revoked(): |         for j in authority.list_revoked(): | ||||||
|             if not verbose: |             if not verbose: | ||||||
|                 click.echo("r " + j.path + " " + j.identity) |                 click.echo("r " + j.path + " " + j.identity) | ||||||
|                 continue |                 continue | ||||||
| @@ -820,59 +790,19 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_ | |||||||
|  |  | ||||||
|     click.echo() |     click.echo() | ||||||
|  |  | ||||||
| @click.command("list", help="List Certificate Authorities") |  | ||||||
| @click.argument("ca") |  | ||||||
| #@config.pop_certificate_authority() |  | ||||||
| def cert_list(ca): |  | ||||||
|  |  | ||||||
|     mapping = {} |  | ||||||
|  |  | ||||||
|     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.command("sign", help="Sign certificates") | ||||||
| @click.argument("common_name") | @click.argument("common_name") | ||||||
| @click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN") | @click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN") | ||||||
| @click.option("--lifetime", "-l", help="Lifetime") | @click.option("--lifetime", "-l", help="Lifetime") | ||||||
| def certidude_sign(common_name, overwrite, lifetime): | def certidude_sign(common_name, overwrite, lifetime): | ||||||
|     config = load_config() |     request = authority.get_request(common_name) | ||||||
|     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 |  | ||||||
|  |  | ||||||
|     results = tuple(iterate()) |  | ||||||
|     click.echo() |  | ||||||
|  |  | ||||||
|     click.echo("Press Ctrl-C to cancel singing these requests...") |  | ||||||
|     sys.stdin.readline() |  | ||||||
|  |  | ||||||
|     for ca, request in results: |  | ||||||
|     if request.signable: |     if request.signable: | ||||||
|         # Sign via signer process |         # Sign via signer process | ||||||
|             cert = ca.sign(request) |         cert = authority.sign(request) | ||||||
|     else: |     else: | ||||||
|         # Sign directly using private key |         # Sign directly using private key | ||||||
|             cert = ca.sign2(request, overwrite, True, lifetime) |         cert = authority.sign2(request, overwrite, True, lifetime) | ||||||
|  |  | ||||||
|     click.echo("Signed %s" % cert.identity) |     click.echo("Signed %s" % cert.identity) | ||||||
|     for key, value, data in cert.extensions: |     for key, value, data in cert.extensions: | ||||||
|   | |||||||
							
								
								
									
										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 click | ||||||
| import os | import os | ||||||
| import urllib.request | import urllib.request | ||||||
| from certidude.wrappers import Certificate, Request | from certidude import config | ||||||
| from OpenSSL import crypto | 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): | 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,53 +1,56 @@ | |||||||
| <h1>{{authority.common_name}} management</h1> |  | ||||||
|  |  | ||||||
| <p>Hi {{session.username}},</p> | <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>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 authority.autosign_subnets %}{% for i in authority.autosign_subnets %}{{ i }} {% endfor %}{% else %}nowhere{% 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 authority.admin_subnets %}{% for i in authority.admin_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %} | <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 authority.admin_users %}{{ i }} {% endfor %}</p> | <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"> | <div id="requests"> | ||||||
|     {% for request in authority.requests %} |     <h1>Pending requests</h1> | ||||||
|  |  | ||||||
|  |     <ul id="pending_requests"> | ||||||
|  |         {% for request in session.requests %} | ||||||
|              {% include "request.html" %} |              {% include "request.html" %} | ||||||
| 	    {% endfor %} | 	    {% endfor %} | ||||||
|         <li class="notify"> |         <li class="notify"> | ||||||
|             <p>No certificate signing requests to sign! You can  submit a certificate signing request by:</p> |             <p>No certificate signing requests to sign! You can  submit a certificate signing request by:</p> | ||||||
|         <pre>certidude setup client {{authority.common_name}}</pre> |             <pre>certidude setup client {{session.common_name}}</pre> | ||||||
|         </li> |         </li> | ||||||
| </ul> |     </ul> | ||||||
|  | </div> | ||||||
|  |  | ||||||
| <h1>Signed certificates</h1> |  | ||||||
|  |  | ||||||
| <ul id="signed_certificates"> | <div id="signed"> | ||||||
|     {% for certificate in authority.signed | sort | reverse %} |     <h1>Signed certificates</h1> | ||||||
|  |     <ul id="signed_certificates"> | ||||||
|  |         {% for certificate in session.signed | sort | reverse %} | ||||||
|             {% include "signed.html" %} |             {% include "signed.html" %} | ||||||
| 	    {% endfor %} | 	    {% endfor %} | ||||||
| </ul> |     </ul> | ||||||
|  | </div> | ||||||
|  |  | ||||||
| <h1>Revoked certificates</h1> | <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> | ||||||
|  |  | ||||||
| <p>To fetch certificate revocation list:</p> |     <pre> | ||||||
| <pre> |     curl {{request.url}}/certificate/ > session.pem | ||||||
| curl {{window.location.href}}api/revoked/ | openssl crl -text -noout |     openssl ocsp -issuer session.pem -CAfile session.pem -url {{request.url}}/ocsp/ -serial 0x | ||||||
| </pre> |     </pre> | ||||||
| <!-- |     --> | ||||||
| <p>To perform online certificate status request</p> |     <ul> | ||||||
|  |         {% for j in session.revoked %} | ||||||
| <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 }}"> |             <li id="certificate_{{ j.sha256sum }}"> | ||||||
|                 {{j.changed}} |                 {{j.changed}} | ||||||
|                 {{j.serial_number}} <span class="monospace">{{j.identity}}</span> |                 {{j.serial_number}} <span class="monospace">{{j.identity}}</span> | ||||||
| @@ -55,5 +58,5 @@ openssl ocsp -issuer authority.pem -CAfile authority.pem -url {{request.url}}/oc | |||||||
|         {% else %} |         {% else %} | ||||||
|             <li>Great job! No certificate signing requests to sign.</li> |             <li>Great job! No certificate signing requests to sign.</li> | ||||||
| 	    {% endfor %} | 	    {% endfor %} | ||||||
| </ul> |     </ul> | ||||||
|  | </div> | ||||||
|   | |||||||
| @@ -94,9 +94,7 @@ html,body { | |||||||
| } | } | ||||||
|  |  | ||||||
| body { | body { | ||||||
|     background: #222; |     background: #fff; | ||||||
|     background-image: url('../img/free_hexa_pattern_cc0_by_black_light_studio.png'); |  | ||||||
|     background-position: center; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| .comment { | .comment { | ||||||
| @@ -142,24 +140,31 @@ pre { | |||||||
|     margin: 0 0; |     margin: 0 0; | ||||||
| } | } | ||||||
|  |  | ||||||
| #container { |  | ||||||
|     max-width: 60em; | .container { | ||||||
|     margin: 1em auto; |     max-width: 960px; | ||||||
|     background: #fff; |     margin: 0 auto; | ||||||
|     padding: 1em; |  | ||||||
|     border-style: solid; |  | ||||||
|     border-width: 2px; |  | ||||||
|     border-color: #aaa; |  | ||||||
|     border-radius: 10px; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| li { | #container li { | ||||||
|     margin: 4px 0; |     margin: 4px 0; | ||||||
|     padding: 4px 0; |     padding: 4px 0; | ||||||
|     clear: both; |     clear: both; | ||||||
|     border-top: 1px dashed #ccc; |     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{ | .icon{ | ||||||
|     background-size: 24px; |     background-size: 24px; | ||||||
|     padding-left: 36px; |     padding-left: 36px; | ||||||
|   | |||||||
| @@ -11,7 +11,15 @@ | |||||||
|     <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon"> |     <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon"> | ||||||
| </head> | </head> | ||||||
| <body> | <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... |         Loading certificate authority... | ||||||
|     </div> |     </div> | ||||||
| </body> | </body> | ||||||
|   | |||||||
| @@ -1,9 +1,8 @@ | |||||||
| $(document).ready(function() { | $(document).ready(function() { | ||||||
|     console.info("Loading CA, to debug: curl " + window.location.href + " --negotiate -u : -H 'Accept: application/json'"); |     console.info("Loading CA, to debug: curl " + window.location.href + " --negotiate -u : -H 'Accept: application/json'"); | ||||||
|  |  | ||||||
|     $.ajax({ |     $.ajax({ | ||||||
|         method: "GET", |         method: "GET", | ||||||
|         url: "/api/session/", |         url: "/api/", | ||||||
|         dataType: "json", |         dataType: "json", | ||||||
|         error: function(response) { |         error: function(response) { | ||||||
|             if (response.responseJSON) { |             if (response.responseJSON) { | ||||||
| @@ -14,23 +13,11 @@ $(document).ready(function() { | |||||||
|             $("#container").html(nunjucks.render('error.html', { message: msg })); |             $("#container").html(nunjucks.render('error.html', { message: msg })); | ||||||
|         }, |         }, | ||||||
|         success: function(session, status, xhr) { |         success: function(session, status, xhr) { | ||||||
|             console.info("Loaded CA list:", session); |             console.info("Got:", session); | ||||||
|  |  | ||||||
|             if (!session.authorities) { |             console.info("Opening EventSource from:", session.event_channel); | ||||||
|                 alert("No certificate authorities to manage! Have you created one yet?"); |  | ||||||
|                 return; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             $.ajax({ |             var source = new EventSource(session.event_channel); | ||||||
|                 method: "GET", |  | ||||||
|                 url: "/api/", |  | ||||||
|                 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) { |             source.onmessage = function(event) { | ||||||
|                 console.log("Received server-sent event:", event); |                 console.log("Received server-sent event:", event); | ||||||
| @@ -71,7 +58,7 @@ $(document).ready(function() { | |||||||
|                 console.log("Request submitted:", e.data); |                 console.log("Request submitted:", e.data); | ||||||
|                 $.ajax({ |                 $.ajax({ | ||||||
|                     method: "GET", |                     method: "GET", | ||||||
|                             url: "/api/request/lauri-c720p/", |                     url: "/api/request/" + e.data + "/", | ||||||
|                     dataType: "json", |                     dataType: "json", | ||||||
|                     success: function(request, status, xhr) { |                     success: function(request, status, xhr) { | ||||||
|                         console.info(request); |                         console.info(request); | ||||||
| @@ -88,7 +75,7 @@ $(document).ready(function() { | |||||||
|  |  | ||||||
|                 $.ajax({ |                 $.ajax({ | ||||||
|                     method: "GET", |                     method: "GET", | ||||||
|                             url: "/api/signed/lauri-c720p/", |                     url: "/api/signed/" + e.data + "/", | ||||||
|                     dataType: "json", |                     dataType: "json", | ||||||
|                     success: function(certificate, status, xhr) { |                     success: function(certificate, status, xhr) { | ||||||
|                         console.info(certificate); |                         console.info(certificate); | ||||||
| @@ -103,7 +90,7 @@ $(document).ready(function() { | |||||||
|                 $("#certificate_" + e.data).slideUp("normal", function() { $(this).remove(); }); |                 $("#certificate_" + e.data).slideUp("normal", function() { $(this).remove(); }); | ||||||
|             }); |             }); | ||||||
|  |  | ||||||
|                     $("#container").html(nunjucks.render('authority.html', { authority: authority, session: session, window: window })); |             $("#container").html(nunjucks.render('authority.html', { session: session, window: window })); | ||||||
|  |  | ||||||
|             $.ajax({ |             $.ajax({ | ||||||
|                 method: "GET", |                 method: "GET", | ||||||
| @@ -141,6 +128,4 @@ $(document).ready(function() { | |||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|         } |  | ||||||
|     }); |  | ||||||
| }); | }); | ||||||
|   | |||||||
							
								
								
									
										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 os | ||||||
| import hashlib | import hashlib | ||||||
| import logging |  | ||||||
| import re | import re | ||||||
| import itertools |  | ||||||
| import click | import click | ||||||
| import socket |  | ||||||
| import io | import io | ||||||
| import urllib.request | from certidude import push | ||||||
| import ipaddress |  | ||||||
| from configparser import RawConfigParser |  | ||||||
| from Crypto.Util import asn1 | from Crypto.Util import asn1 | ||||||
| from OpenSSL import crypto | from OpenSSL import crypto | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from jinja2 import Environment, PackageLoader, Template |  | ||||||
| from certidude.mailer import Mailer |  | ||||||
| from certidude.signer import raw_sign, EXTENSION_WHITELIST | 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): | def subject2dn(subject): | ||||||
|     bits = [] |     bits = [] | ||||||
|     for j in "CN", "GN", "SN", "C", "S", "L", "O", "OU": |     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))) |             bits.append("%s=%s" % (j, getattr(subject, j))) | ||||||
|     return ", ".join(bits) |     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: | class CertificateBase: | ||||||
|     def __repr__(self): |     def __repr__(self): | ||||||
|         return self.buf |         return self.buf | ||||||
| @@ -351,9 +232,6 @@ class Request(CertificateBase): | |||||||
|     def dump(self): |     def dump(self): | ||||||
|         return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self._obj).decode("ascii") |         return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self._obj).decode("ascii") | ||||||
|  |  | ||||||
|     def __repr__(self): |  | ||||||
|         return "Request(%s)" % repr(self.path) |  | ||||||
|  |  | ||||||
|     def create(self): |     def create(self): | ||||||
|         # Generate 4096-bit RSA key |         # Generate 4096-bit RSA key | ||||||
|         key = crypto.PKey() |         key = crypto.PKey() | ||||||
| @@ -364,6 +242,7 @@ class Request(CertificateBase): | |||||||
|         req.set_pubkey(key) |         req.set_pubkey(key) | ||||||
|         return Request(req) |         return Request(req) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Certificate(CertificateBase): | class Certificate(CertificateBase): | ||||||
|     def __init__(self, mixed): |     def __init__(self, mixed): | ||||||
|         self.buf = NotImplemented |         self.buf = NotImplemented | ||||||
| @@ -387,7 +266,7 @@ class Certificate(CertificateBase): | |||||||
|         if isinstance(mixed, crypto.X509): |         if isinstance(mixed, crypto.X509): | ||||||
|             self._obj = mixed |             self._obj = mixed | ||||||
|         else: |         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()) |         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): |     def __lte__(self, other): | ||||||
|         return self.signed <= other.signed |         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