diff --git a/README.rst b/README.rst index e72fa09..1615f29 100644 --- a/README.rst +++ b/README.rst @@ -67,7 +67,7 @@ To install Certidude: .. code:: bash - apt-get install -y python3 python3-pip python3-dev cython3 build-essential libffi-dev libssl-dev libkrb5-dev + apt-get install -y python3 python3-pip python3-dev python3-mysql.connector cython3 build-essential libffi-dev libssl-dev libkrb5-dev pip3 install certidude Make sure you're running PyOpenSSL 0.15+ and netifaces 0.10.4+ from PyPI, @@ -87,6 +87,8 @@ First make sure the machine used for CA has fully qualified domain name set up properly. You can check it with: +.. code:: bash + hostname -f The command should return ca.example.co diff --git a/certidude/api.py b/certidude/api.py deleted file mode 100644 index f1c6cbb..0000000 --- a/certidude/api.py +++ /dev/null @@ -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 -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 diff --git a/certidude/api/__init__.py b/certidude/api/__init__.py new file mode 100644 index 0000000..02697fa --- /dev/null +++ b/certidude/api/__init__.py @@ -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 -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 diff --git a/certidude/api/lease.py b/certidude/api/lease.py new file mode 100644 index 0000000..c678fe5 --- /dev/null +++ b/certidude/api/lease.py @@ -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)) + } + diff --git a/certidude/api/request.py b/certidude/api/request.py new file mode 100644 index 0000000..9013f53 --- /dev/null +++ b/certidude/api/request.py @@ -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() diff --git a/certidude/api/revoked.py b/certidude/api/revoked.py new file mode 100644 index 0000000..0f85d94 --- /dev/null +++ b/certidude/api/revoked.py @@ -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() + diff --git a/certidude/api/signed.py b/certidude/api/signed.py new file mode 100644 index 0000000..14f0aff --- /dev/null +++ b/certidude/api/signed.py @@ -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) + diff --git a/certidude/api/whois.py b/certidude/api/whois.py new file mode 100644 index 0000000..1ebeee1 --- /dev/null +++ b/certidude/api/whois.py @@ -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"] diff --git a/certidude/auth.py b/certidude/auth.py index 670535a..416ac9e 100644 --- a/certidude/auth.py +++ b/certidude/auth.py @@ -1,6 +1,7 @@ import click import falcon +import ipaddress import kerberos import os import re @@ -70,3 +71,28 @@ def login_required(func): raise falcon.HTTPForbidden("Forbidden", "Tried GSSAPI") return wrapped + + +def authorize_admin(func): + def wrapped(self, req, resp, *args, **kwargs): + from certidude import config + # Parse remote IPv4/IPv6 address + remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"]) + + # Check for administration subnet whitelist + print("Comparing:", config.ADMIN_SUBNETS, "To:", remote_addr) + for subnet in config.ADMIN_SUBNETS: + if subnet.overlaps(remote_addr): + break + else: + raise falcon.HTTPForbidden("Forbidden", "Remote address %s not whitelisted" % remote_addr) + + # Check for username whitelist + kerberos_username, kerberos_realm = req.context.get("user") + if kerberos_username not in config.ADMIN_USERS: + raise falcon.HTTPForbidden("Forbidden", "User %s not whitelisted" % kerberos_username) + + # Retain username, TODO: Better abstraction with username, e-mail, sn, gn? + + return func(self, req, resp, *args, **kwargs) + return wrapped diff --git a/certidude/authority.py b/certidude/authority.py new file mode 100644 index 0000000..16135e8 --- /dev/null +++ b/certidude/authority.py @@ -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)) + diff --git a/certidude/cli.py b/certidude/cli.py index 5c5e6f0..f888446 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -13,10 +13,9 @@ import signal import socket import subprocess import sys -from certidude.helpers import expand_paths, \ - certidude_request_certificate +from certidude import authority from certidude.signer import SignServer -from certidude.wrappers import CertificateAuthorityConfig, subject2dn +from certidude.common import expand_paths from datetime import datetime from humanize import naturaltime from ipaddress import ip_network, ip_address @@ -62,20 +61,15 @@ if os.getuid() >= 1000: FIRST_NAME = gecos -def load_config(): - path = os.getenv('CERTIDUDE_CONF') - if path and os.path.isfile(path): - return CertificateAuthorityConfig(path) - return CertificateAuthorityConfig() - - -@click.command("spawn", help="Run privilege isolated signer processes") -@click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instances") +@click.command("spawn", help="Run privilege isolated signer process") +@click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instance") @click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys") def certidude_spawn(kill, no_interaction): """ Spawn processes for signers """ + from certidude import config + # Check whether we have privileges os.umask(0o027) uid = os.getuid() @@ -84,13 +78,12 @@ def certidude_spawn(kill, no_interaction): # Process directories run_dir = "/run/certidude" - signer_dir = os.path.join(run_dir, "signer") - chroot_dir = os.path.join(signer_dir, "jail") + chroot_dir = os.path.join(run_dir, "jail") # Prepare signer PID-s directory - if not os.path.exists(signer_dir): - click.echo("Creating: %s" % signer_dir) - os.makedirs(signer_dir) + if not os.path.exists(run_dir): + click.echo("Creating: %s" % run_dir) + os.makedirs(run_dir) # Preload charmap encoding for byte_string() function of pyOpenSSL # in order to enable chrooting @@ -104,54 +97,49 @@ def certidude_spawn(kill, no_interaction): os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom")) ca_loaded = False - config = load_config() - for ca in config.all_authorities(): - socket_path = os.path.join(signer_dir, ca.common_name + ".sock") - pidfile_path = os.path.join(signer_dir, ca.common_name + ".pid") - try: - with open(pidfile_path) as fh: - pid = int(fh.readline()) - os.kill(pid, 0) - click.echo("Found process with PID %d for %s" % (pid, ca.common_name)) - except (ValueError, ProcessLookupError, FileNotFoundError): - pid = 0 - if pid > 0: - if kill: - try: - click.echo("Killing %d" % pid) - os.kill(pid, signal.SIGTERM) - sleep(1) - os.kill(pid, signal.SIGKILL) - sleep(1) - except ProcessLookupError: - pass - ca_loaded = True - else: - ca_loaded = True - continue + try: + with open(config.SIGNER_PID_PATH) as fh: + pid = int(fh.readline()) + os.kill(pid, 0) + click.echo("Found process with PID %d" % pid) + except (ValueError, ProcessLookupError, FileNotFoundError): + pid = 0 - child_pid = os.fork() + if pid > 0: + if kill: + try: + click.echo("Killing %d" % pid) + os.kill(pid, signal.SIGTERM) + sleep(1) + os.kill(pid, signal.SIGKILL) + sleep(1) + except ProcessLookupError: + pass - if child_pid == 0: - with open(pidfile_path, "w") as fh: - fh.write("%d\n" % os.getpid()) + child_pid = os.fork() - setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name)) - logging.basicConfig( - filename="/var/log/certidude-%s.log" % ca.common_name, - level=logging.INFO) - server = SignServer(socket_path, ca.private_key, ca.certificate.path, - ca.certificate_lifetime, ca.basic_constraints, ca.key_usage, - ca.extended_key_usage, ca.revocation_list_lifetime) - asyncore.loop() - else: - click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, socket_path)) - ca_loaded = True + if child_pid == 0: + with open(config.SIGNER_PID_PATH, "w") as fh: + fh.write("%d\n" % os.getpid()) - if not ca_loaded: - raise click.ClickException("No CA sections defined in configuration: {}".format(config.path)) +# setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name)) + logging.basicConfig( + filename="/var/log/signer.log", + level=logging.INFO) + server = SignServer( + config.SIGNER_SOCKET_PATH, + config.AUTHORITY_PRIVATE_KEY_PATH, + config.AUTHORITY_CERTIFICATE_PATH, + config.CERTIFICATE_LIFETIME, + config.CERTIFICATE_BASIC_CONSTRAINTS, + config.CERTIFICATE_KEY_USAGE_FLAGS, + config.CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS, + config.REVOCATION_LIST_LIFETIME) + asyncore.loop() + else: + click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH)) @click.command("client", help="Setup X.509 certificates for application") @@ -171,6 +159,7 @@ def certidude_spawn(kill, no_interaction): @click.option("--certificate-path", "-c", default=HOSTNAME + ".crt", help="Certificate path, %s.crt by default" % HOSTNAME) @click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt by default") def certidude_setup_client(quiet, **kwargs): + from certidude.helpers import certidude_request_certificate return certidude_request_certificate(**kwargs) @@ -197,6 +186,7 @@ def certidude_setup_client(quiet, **kwargs): @expand_paths() def certidude_setup_openvpn_server(url, config, subnet, route, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, dhparam_path, local, proto, port): # TODO: Intelligent way of getting last IP address in the subnet + from certidude.helpers import certidude_request_certificate subnet_first = None subnet_last = None subnet_second = None @@ -213,7 +203,6 @@ def certidude_setup_openvpn_server(url, config, subnet, route, email_address, co click.echo("use following command to sign on Certidude server instead of web interface:") click.echo() click.echo(" certidude sign %s" % common_name) - retval = certidude_request_certificate( url, key_path, @@ -536,19 +525,14 @@ def certidude_setup_production(username, hostname, push_server, nginx_config, uw @click.option("--pkcs11", default=False, is_flag=True, help="Use PKCS#11 token instead of files") @click.option("--crl-distribution-url", default=None, help="CRL distribution URL") @click.option("--ocsp-responder-url", default=None, help="OCSP responder URL") -@click.option("--email-address", default=EMAIL, help="CA e-mail address") -@click.option("--inbox", default="imap://user:pass@host:port/INBOX", help="Inbound e-mail server") -@click.option("--outbox", default="smtp://localhost", help="Outbound e-mail server") @click.option("--push-server", default="", help="Streaming nginx push server") -@click.option("--directory", default=None, help="Directory for authority files, /var/lib/certidude// by default") -def certidude_setup_authority(parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, pkcs11, crl_distribution_url, ocsp_responder_url, email_address, inbox, outbox, push_server): - - if not directory: - directory = os.path.join("/var/lib/certidude", common_name) +@click.option("--email-address", default="certidude@" + FQDN, help="E-mail address of the CA") +@click.option("--directory", default=os.path.join("/var/lib/certidude", FQDN), help="Directory for authority files, /var/lib/certidude/ by default") +def certidude_setup_authority(parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, pkcs11, crl_distribution_url, ocsp_responder_url, push_server, email_address): # Make sure common_name is valid - if not re.match(r"^[\._a-zA-Z0-9]+$", common_name): - raise click.ClickException("CA name can contain only alphanumeric and '_' characters") + if not re.match(r"^[\.\-_a-zA-Z0-9]+$", common_name): + raise click.ClickException("CA name can contain only alphanumeric, '_' and '-' characters") if os.path.lexists(directory): raise click.ClickException("Output directory {} already exists.".format(directory)) @@ -612,14 +596,13 @@ def certidude_setup_authority(parent, country, state, locality, organization, or crl_distribution_points.encode("ascii")) ]) - if email_address: - subject_alt_name = "email:%s" % email_address - ca.add_extensions([ - crypto.X509Extension( - b"subjectAltName", - False, - subject_alt_name.encode("ascii")) - ]) + subject_alt_name = "email:%s" % email_address + ca.add_extensions([ + crypto.X509Extension( + b"subjectAltName", + False, + subject_alt_name.encode("ascii")) + ]) if ocsp_responder_url: raise NotImplementedError() @@ -635,7 +618,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or ]) """ - click.echo("Signing %s..." % subject2dn(ca.get_subject())) + click.echo("Signing %s..." % ca.get_subject()) # openssl x509 -in ca_crt.pem -outform DER | sha256sum # openssl x509 -fingerprint -in ca_crt.pem @@ -665,13 +648,9 @@ def certidude_setup_authority(parent, country, state, locality, organization, or with open(ca_key, "wb") as fh: fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) - ssl_cnf_example = os.path.join(directory, "openssl.cnf.example") - with open(ssl_cnf_example, "w") as fh: - fh.write(env.get_template("openssl.cnf").render(locals())) - - click.echo("You need to copy the contents of the '%s'" % ssl_cnf_example) - click.echo("to system-wide OpenSSL configuration file, usually located") - click.echo("at /etc/ssl/openssl.cnf") + certidude_conf = os.path.join("/etc/certidude.conf") + with open(certidude_conf, "w") as fh: + fh.write(env.get_template("certidude.conf").render(locals())) click.echo() click.echo("Use following commands to inspect the newly created files:") @@ -691,7 +670,6 @@ def certidude_setup_authority(parent, country, state, locality, organization, or @click.command("list", help="List certificates") -@click.argument("ca", nargs=-1) @click.option("--verbose", "-v", default=False, is_flag=True, help="Verbose output") @click.option("--show-key-type", "-k", default=False, is_flag=True, help="Show key type and length") @click.option("--show-path", "-p", default=False, is_flag=True, help="Show filesystem paths") @@ -699,7 +677,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or @click.option("--hide-requests", "-h", default=False, is_flag=True, help="Hide signing requests") @click.option("--show-signed", "-s", default=False, is_flag=True, help="Show signed certificates") @click.option("--show-revoked", "-r", default=False, is_flag=True, help="Show revoked certificates") -def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_signed, show_revoked, hide_requests): +def certidude_list(verbose, show_key_type, show_extensions, show_path, show_signed, show_revoked, hide_requests): # Statuses: # s - submitted # v - valid @@ -738,147 +716,99 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_ if j.fqdn: click.echo("Associated hostname: " + j.fqdn) - config = load_config() - wanted_list = None - if ca: - missing = list(set(ca) - set(config.ca_list)) - if missing: - raise click.NoSuchOption(option_name='', message="Unable to find certificate authority.", possibilities=config.ca_list) - wanted_list = ca + if not hide_requests: + for j in authority.list_requests(): - for ca in config.all_authorities(wanted_list): - if not hide_requests: - for j in ca.get_requests(): - if not verbose: - click.echo("s " + j.path + " " + j.identity) - continue - click.echo(click.style(j.common_name, fg="blue")) - click.echo("=" * len(j.common_name)) - click.echo("State: ? " + click.style("submitted", fg="yellow") + " " + naturaltime(j.created) + click.style(", %s" %j.created, fg="white")) + if not verbose: + click.echo("s " + j.path + " " + j.identity) + continue + click.echo(click.style(j.common_name, fg="blue")) + click.echo("=" * len(j.common_name)) + click.echo("State: ? " + click.style("submitted", fg="yellow") + " " + naturaltime(j.created) + click.style(", %s" %j.created, fg="white")) - dump_common(j) + dump_common(j) - # Calculate checksums for cross-checking - import hashlib - md5sum = hashlib.md5() - sha1sum = hashlib.sha1() - sha256sum = hashlib.sha256() - with open(j.path, "rb") as fh: - buf = fh.read() - md5sum.update(buf) - sha1sum.update(buf) - sha256sum.update(buf) - click.echo("MD5 checksum: %s" % md5sum.hexdigest()) - click.echo("SHA-1 checksum: %s" % sha1sum.hexdigest()) - click.echo("SHA-256 checksum: %s" % sha256sum.hexdigest()) + # Calculate checksums for cross-checking + import hashlib + md5sum = hashlib.md5() + sha1sum = hashlib.sha1() + sha256sum = hashlib.sha256() + with open(j.path, "rb") as fh: + buf = fh.read() + md5sum.update(buf) + sha1sum.update(buf) + sha256sum.update(buf) + click.echo("MD5 checksum: %s" % md5sum.hexdigest()) + click.echo("SHA-1 checksum: %s" % sha1sum.hexdigest()) + click.echo("SHA-256 checksum: %s" % sha256sum.hexdigest()) - if show_path: - click.echo("Details: openssl req -in %s -text -noout" % j.path) - click.echo("Sign: certidude sign %s" % j.path) - click.echo() - - if show_signed: - for j in ca.get_signed(): - if not verbose: - if j.signed < NOW and j.expires > NOW: - click.echo("v " + j.path + " " + j.identity) - elif NOW > j.expires: - click.echo("e " + j.path + " " + j.identity) - else: - click.echo("y " + j.path + " " + j.identity) - continue - - click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white")) - click.echo("="*(len(j.common_name)+60)) + if show_path: + click.echo("Details: openssl req -in %s -text -noout" % j.path) + click.echo("Sign: certidude sign %s" % j.path) + click.echo() + if show_signed: + for j in authority.list_signed(): + if not verbose: if j.signed < NOW and j.expires > NOW: - click.echo("Status: \u2713 " + click.style("valid", fg="green") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white")) + click.echo("v " + j.path + " " + j.identity) elif NOW > j.expires: - click.echo("Status: \u2717 " + click.style("expired", fg="red") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white")) + click.echo("e " + j.path + " " + j.identity) else: - click.echo("Status: \u2717 " + click.style("not valid yet", fg="red") + click.style(", %s" %j.expires, fg="white")) - dump_common(j) + click.echo("y " + j.path + " " + j.identity) + continue - if show_path: - click.echo("Details: openssl x509 -in %s -text -noout" % j.path) - click.echo("Revoke: certidude revoke %s" % j.path) - click.echo() + click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white")) + click.echo("="*(len(j.common_name)+60)) - if show_revoked: - for j in ca.get_revoked(): - if not verbose: - click.echo("r " + j.path + " " + j.identity) - continue - click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white")) - click.echo("="*(len(j.common_name)+60)) - click.echo("Status: \u2717 " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-j.changed), click.style(", %s" % j.changed, fg="white"))) - dump_common(j) - if show_path: - click.echo("Details: openssl x509 -in %s -text -noout" % j.path) - click.echo() + if j.signed < NOW and j.expires > NOW: + click.echo("Status: \u2713 " + click.style("valid", fg="green") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white")) + elif NOW > j.expires: + click.echo("Status: \u2717 " + click.style("expired", fg="red") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white")) + else: + click.echo("Status: \u2717 " + click.style("not valid yet", fg="red") + click.style(", %s" %j.expires, fg="white")) + dump_common(j) - click.echo() + if show_path: + click.echo("Details: openssl x509 -in %s -text -noout" % j.path) + click.echo("Revoke: certidude revoke %s" % j.path) + click.echo() -@click.command("list", help="List Certificate Authorities") -@click.argument("ca") -#@config.pop_certificate_authority() -def cert_list(ca): + if show_revoked: + for j in authority.list_revoked(): + if not verbose: + click.echo("r " + j.path + " " + j.identity) + continue + click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white")) + click.echo("="*(len(j.common_name)+60)) + click.echo("Status: \u2717 " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-j.changed), click.style(", %s" % j.changed, fg="white"))) + dump_common(j) + if show_path: + click.echo("Details: openssl x509 -in %s -text -noout" % j.path) + click.echo() - mapping = {} + click.echo() - config = load_config() - - click.echo("Listing certificates for: %s" % ca.certificate.subject.CN) - - for serial, reason, timestamp in ca.get_revoked(): - mapping[serial] = None, reason - - for certificate in ca.get_signed(): - mapping[certificate.serial] = certificate, None - - for serial, (certificate, reason) in sorted(mapping.items(), key=lambda j:j[0]): - if not reason: - click.echo(" %03d. %s %s" % (serial, certificate.subject.CN, (certificate.not_after-NOW))) - else: - click.echo(" %03d. Revoked due to: %s" % (serial, reason)) - - for request in ca.get_requests(): - click.echo(" ⌛ %s" % request.subject.CN) @click.command("sign", help="Sign certificates") @click.argument("common_name") @click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN") @click.option("--lifetime", "-l", help="Lifetime") def certidude_sign(common_name, overwrite, lifetime): - config = load_config() - def iterate(): - for ca in config.all_authorities(): - for request in ca.get_requests(): - if request.common_name != common_name: - continue - print(request.fingerprint(), request.common_name, request.path, request.key_usage) - yield ca, request + request = authority.get_request(common_name) + if request.signable: + # Sign via signer process + cert = authority.sign(request) + else: + # Sign directly using private key + cert = authority.sign2(request, overwrite, True, lifetime) - results = tuple(iterate()) + click.echo("Signed %s" % cert.identity) + for key, value, data in cert.extensions: + click.echo("Added extension %s: %s" % (key, value)) click.echo() - click.echo("Press Ctrl-C to cancel singing these requests...") - sys.stdin.readline() - - for ca, request in results: - if request.signable: - # Sign via signer process - cert = ca.sign(request) - else: - # Sign directly using private key - cert = ca.sign2(request, overwrite, True, lifetime) - - click.echo("Signed %s" % cert.identity) - for key, value, data in cert.extensions: - click.echo("Added extension %s: %s" % (key, value)) - click.echo() - @click.command("serve", help="Run built-in HTTP server") @click.option("-u", "--user", default="certidude", help="Run as user") @click.option("-p", "--port", default=80, help="Listen port") diff --git a/certidude/common.py b/certidude/common.py new file mode 100644 index 0000000..c325a2c --- /dev/null +++ b/certidude/common.py @@ -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 + diff --git a/certidude/config.py b/certidude/config.py new file mode 100644 index 0000000..309a4b1 --- /dev/null +++ b/certidude/config.py @@ -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) + diff --git a/certidude/decorators.py b/certidude/decorators.py new file mode 100644 index 0000000..8ddce56 --- /dev/null +++ b/certidude/decorators.py @@ -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 + diff --git a/certidude/helpers.py b/certidude/helpers.py index 700f945..7e65c61 100644 --- a/certidude/helpers.py +++ b/certidude/helpers.py @@ -2,34 +2,9 @@ import click import os import urllib.request -from certidude.wrappers import Certificate, Request +from certidude import config from OpenSSL import crypto -def expand_paths(): - """ - Prefix '..._path' keyword arguments of target function with 'directory' keyword argument - and create the directory if necessary - - TODO: Move to separate file - """ - def wrapper(func): - def wrapped(**arguments): - d = arguments.get("directory") - for key, value in arguments.items(): - if key.endswith("_path"): - if d: - value = os.path.join(d, value) - value = os.path.realpath(value) - parent = os.path.dirname(value) - if not os.path.exists(parent): - click.echo("Making directory %s for %s" % (repr(parent), repr(key))) - os.makedirs(parent) - elif not os.path.isdir(parent): - raise Exception("Path %s is not directory!" % parent) - arguments[key] = value - return func(**arguments) - return wrapped - return wrapper def certidude_request_certificate(url, key_path, request_path, certificate_path, authority_path, common_name, org_unit, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None, ip_address=None, dns=None): diff --git a/certidude/push.py b/certidude/push.py new file mode 100644 index 0000000..18ad726 --- /dev/null +++ b/certidude/push.py @@ -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) + diff --git a/certidude/static/authority.html b/certidude/static/authority.html index 1f671f2..2c159c8 100644 --- a/certidude/static/authority.html +++ b/certidude/static/authority.html @@ -1,59 +1,62 @@ -

{{authority.common_name}} management

Hi {{session.username}},

-

Request submission is allowed from: {% if authority.request_subnets %}{% for i in authority.request_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}

-

Autosign is allowed from: {% if authority.autosign_subnets %}{% for i in authority.autosign_subnets %}{{ i }} {% endfor %}{% else %}nowhere{% endif %}

-

Authority administration is allowed from: {% if authority.admin_subnets %}{% for i in authority.admin_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %} -

Authority administration allowed for: {% for i in authority.admin_users %}{{ i }} {% endfor %}

+

Request submission is allowed from: {% if session.request_subnets %}{% for i in session.request_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}

+

Autosign is allowed from: {% if session.autosign_subnets %}{% for i in session.autosign_subnets %}{{ i }} {% endfor %}{% else %}nowhere{% endif %}

+

Authority administration is allowed from: {% if session.admin_subnets %}{% for i in session.admin_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %} +

Authority administration allowed for: {% for i in session.admin_users %}{{ i }} {% endfor %}

-{% set s = authority.certificate.identity %} +{% set s = session.certificate.identity %} - -

Pending requests

-
    - {% for request in authority.requests %} - {% include "request.html" %} - {% endfor %} -
  • -

    No certificate signing requests to sign! You can submit a certificate signing request by:

    -
    certidude setup client {{authority.common_name}}
    -
  • -
+
+

Pending requests

-

Signed certificates

- -
    - {% for certificate in authority.signed | sort | reverse %} - {% include "signed.html" %} - {% endfor %} -
- -

Revoked certificates

- -

To fetch certificate revocation list:

-
-curl {{window.location.href}}api/revoked/ | openssl crl -text -noout
-
- -
    - {% for j in authority.revoked %} -
  • - {{j.changed}} - {{j.serial_number}} {{j.identity}} +
      + {% for request in session.requests %} + {% include "request.html" %} + {% endfor %} +
    • +

      No certificate signing requests to sign! You can submit a certificate signing request by:

      +
      certidude setup client {{session.common_name}}
    • - {% else %} -
    • Great job! No certificate signing requests to sign.
    • - {% endfor %} -
    +
+
+ +
+

Signed certificates

+
    + {% for certificate in session.signed | sort | reverse %} + {% include "signed.html" %} + {% endfor %} +
+
+ +
+

Revoked certificates

+

To fetch certificate revocation list:

+
+    curl {{window.location.href}}api/revoked/ | openssl crl -text -noout
+    
+ +
    + {% for j in session.revoked %} +
  • + {{j.changed}} + {{j.serial_number}} {{j.identity}} +
  • + {% else %} +
  • Great job! No certificate signing requests to sign.
  • + {% endfor %} +
+
diff --git a/certidude/static/css/style.css b/certidude/static/css/style.css index 91dd18a..0f44908 100644 --- a/certidude/static/css/style.css +++ b/certidude/static/css/style.css @@ -94,9 +94,7 @@ html,body { } body { - background: #222; - background-image: url('../img/free_hexa_pattern_cc0_by_black_light_studio.png'); - background-position: center; + background: #fff; } .comment { @@ -142,24 +140,31 @@ pre { margin: 0 0; } -#container { - max-width: 60em; - margin: 1em auto; - background: #fff; - padding: 1em; - border-style: solid; - border-width: 2px; - border-color: #aaa; - border-radius: 10px; + +.container { + max-width: 960px; + margin: 0 auto; } -li { +#container li { margin: 4px 0; padding: 4px 0; clear: both; border-top: 1px dashed #ccc; } +#menu { + background-color: #444; +} + +#menu li { + color: #fff; + border: none; + display: inline; + margin: 1mm 5mm 1mm 0; + line-height: 200%; +} + .icon{ background-size: 24px; padding-left: 36px; diff --git a/certidude/static/index.html b/certidude/static/index.html index afb0ab9..d5a70b9 100644 --- a/certidude/static/index.html +++ b/certidude/static/index.html @@ -11,7 +11,15 @@ -
+ +
Loading certificate authority...
diff --git a/certidude/static/js/certidude.js b/certidude/static/js/certidude.js index ae3cda5..e6b9cd2 100644 --- a/certidude/static/js/certidude.js +++ b/certidude/static/js/certidude.js @@ -1,9 +1,8 @@ $(document).ready(function() { console.info("Loading CA, to debug: curl " + window.location.href + " --negotiate -u : -H 'Accept: application/json'"); - $.ajax({ method: "GET", - url: "/api/session/", + url: "/api/", dataType: "json", error: function(response) { if (response.responseJSON) { @@ -14,130 +13,116 @@ $(document).ready(function() { $("#container").html(nunjucks.render('error.html', { message: msg })); }, success: function(session, status, xhr) { - console.info("Loaded CA list:", session); + console.info("Got:", session); - if (!session.authorities) { - alert("No certificate authorities to manage! Have you created one yet?"); - return; + console.info("Opening EventSource from:", session.event_channel); + + var source = new EventSource(session.event_channel); + + source.onmessage = function(event) { + console.log("Received server-sent event:", event); } + source.addEventListener("up-client", function(e) { + console.log("Adding security association:" + e.data); + var lease = JSON.parse(e.data); + var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status"); + $status.html(nunjucks.render('status.html', { + lease: { + address: lease.address, + identity: lease.identity, + acquired: new Date(), + released: null + }})); + }); + + source.addEventListener("down-client", function(e) { + console.log("Removing security association:" + e.data); + var lease = JSON.parse(e.data); + var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status"); + $status.html(nunjucks.render('status.html', { + lease: { + address: lease.address, + identity: lease.identity, + acquired: null, + released: new Date() + }})); + }); + + source.addEventListener("request_deleted", function(e) { + console.log("Removing deleted request #" + e.data); + $("#request_" + e.data).remove(); + }); + + source.addEventListener("request_submitted", function(e) { + console.log("Request submitted:", e.data); + $.ajax({ + method: "GET", + url: "/api/request/" + e.data + "/", + dataType: "json", + success: function(request, status, xhr) { + console.info(request); + $("#pending_requests").prepend( + nunjucks.render('request.html', { request: request })); + } + }); + + }); + + source.addEventListener("request_signed", function(e) { + console.log("Request signed:", e.data); + $("#request_" + e.data).slideUp("normal", function() { $(this).remove(); }); + + $.ajax({ + method: "GET", + url: "/api/signed/" + e.data + "/", + dataType: "json", + success: function(certificate, status, xhr) { + console.info(certificate); + $("#signed_certificates").prepend( + nunjucks.render('signed.html', { certificate: certificate })); + } + }); + }); + + source.addEventListener("certificate_revoked", function(e) { + console.log("Removing revoked certificate #" + e.data); + $("#certificate_" + e.data).slideUp("normal", function() { $(this).remove(); }); + }); + + $("#container").html(nunjucks.render('authority.html', { session: session, window: window })); + $.ajax({ method: "GET", - url: "/api/", + url: "/api/lease/", dataType: "json", - success: function(authority, status, xhr) { - console.info("Got CA:", authority); - - console.info("Opening EventSource from:", authority.event_channel); - - var source = new EventSource(authority.event_channel); - - source.onmessage = function(event) { - console.log("Received server-sent event:", event); + success: function(leases, status, xhr) { + console.info("Got leases:", leases); + for (var j = 0; j < leases.length; j++) { + var $status = $("#signed_certificates [data-dn='" + leases[j].identity + "'] .status"); + if (!$status.length) { + console.info("Detected rogue client:", leases[j]); + continue; + } + $status.html(nunjucks.render('status.html', { + lease: { + address: leases[j].address, + identity: leases[j].identity, + acquired: new Date(leases[j].acquired).toLocaleString(), + released: leases[j].released ? new Date(leases[j].released).toLocaleString() : null + }})); } - source.addEventListener("up-client", function(e) { - console.log("Adding security association:" + e.data); - var lease = JSON.parse(e.data); - var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status"); - $status.html(nunjucks.render('status.html', { - lease: { - address: lease.address, - identity: lease.identity, - acquired: new Date(), - released: null - }})); - }); - - source.addEventListener("down-client", function(e) { - console.log("Removing security association:" + e.data); - var lease = JSON.parse(e.data); - var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status"); - $status.html(nunjucks.render('status.html', { - lease: { - address: lease.address, - identity: lease.identity, - acquired: null, - released: new Date() - }})); - }); - - source.addEventListener("request_deleted", function(e) { - console.log("Removing deleted request #" + e.data); - $("#request_" + e.data).remove(); - }); - - source.addEventListener("request_submitted", function(e) { - console.log("Request submitted:", e.data); - $.ajax({ - method: "GET", - url: "/api/request/lauri-c720p/", - dataType: "json", - success: function(request, status, xhr) { - console.info(request); - $("#pending_requests").prepend( - nunjucks.render('request.html', { request: request })); + /* Set up search box */ + $("#search").on("keyup", function() { + var q = $("#search").val().toLowerCase(); + $(".filterable").each(function(i, e) { + if ($(e).attr("data-dn").toLowerCase().indexOf(q) >= 0) { + $(e).show(); + } else { + $(e).hide(); } }); - - }); - - source.addEventListener("request_signed", function(e) { - console.log("Request signed:", e.data); - $("#request_" + e.data).slideUp("normal", function() { $(this).remove(); }); - - $.ajax({ - method: "GET", - url: "/api/signed/lauri-c720p/", - dataType: "json", - success: function(certificate, status, xhr) { - console.info(certificate); - $("#signed_certificates").prepend( - nunjucks.render('signed.html', { certificate: certificate })); - } - }); - }); - - source.addEventListener("certificate_revoked", function(e) { - console.log("Removing revoked certificate #" + e.data); - $("#certificate_" + e.data).slideUp("normal", function() { $(this).remove(); }); - }); - - $("#container").html(nunjucks.render('authority.html', { authority: authority, session: session, window: window })); - - $.ajax({ - method: "GET", - url: "/api/lease/", - dataType: "json", - success: function(leases, status, xhr) { - console.info("Got leases:", leases); - for (var j = 0; j < leases.length; j++) { - var $status = $("#signed_certificates [data-dn='" + leases[j].identity + "'] .status"); - if (!$status.length) { - console.info("Detected rogue client:", leases[j]); - continue; - } - $status.html(nunjucks.render('status.html', { - lease: { - address: leases[j].address, - identity: leases[j].identity, - acquired: new Date(leases[j].acquired).toLocaleString(), - released: leases[j].released ? new Date(leases[j].released).toLocaleString() : null - }})); - } - - /* Set up search box */ - $("#search").on("keyup", function() { - var q = $("#search").val().toLowerCase(); - $(".filterable").each(function(i, e) { - if ($(e).attr("data-dn").toLowerCase().indexOf(q) >= 0) { - $(e).show(); - } else { - $(e).hide(); - } - }); - }); - } }); } }); diff --git a/certidude/templates/certidude.conf b/certidude/templates/certidude.conf new file mode 100644 index 0000000..c9dbbf0 --- /dev/null +++ b/certidude/templates/certidude.conf @@ -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/ + diff --git a/certidude/templates/openssl.cnf b/certidude/templates/openssl.cnf deleted file mode 100644 index d0839bd..0000000 --- a/certidude/templates/openssl.cnf +++ /dev/null @@ -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 - diff --git a/certidude/wrappers.py b/certidude/wrappers.py index ea8403d..1bc8bf7 100644 --- a/certidude/wrappers.py +++ b/certidude/wrappers.py @@ -1,60 +1,14 @@ import os import hashlib -import logging import re -import itertools import click -import socket import io -import urllib.request -import ipaddress -from configparser import RawConfigParser +from certidude import push from Crypto.Util import asn1 from OpenSSL import crypto from datetime import datetime -from jinja2 import Environment, PackageLoader, Template -from certidude.mailer import Mailer from certidude.signer import raw_sign, EXTENSION_WHITELIST -env = Environment(loader=PackageLoader("certidude", "email_templates")) - -# https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/ -# https://jamielinux.com/docs/openssl-certificate-authority/ -# http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py - -def publish_certificate(func): - # TODO: Implement e-mail and nginx notifications using hooks - def wrapped(instance, csr, *args, **kwargs): - cert = func(instance, csr, *args, **kwargs) - assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert)) - - if instance.push_server: - url = instance.push_server + "/pub/?id=" + csr.fingerprint() - notification = urllib.request.Request(url, cert.dump().encode("ascii")) - notification.add_header("User-Agent", "Certidude API") - notification.add_header("Content-Type", "application/x-x509-user-cert") - click.echo("Publishing certificate at %s, waiting for response..." % url) - response = urllib.request.urlopen(notification) - response.read() - - instance.event_publish("request_signed", csr.fingerprint()) - - return cert - -# TODO: Implement e-mailing - -# self.mailer.send( -# self.certificate.email_address, -# (self.certificate.email_address, cert.email_address), -# "Certificate %s signed" % cert.distinguished_name, -# "certificate-signed", -# old_cert=old_cert, -# cert=cert, -# ca=self.certificate) - - return wrapped - - def subject2dn(subject): bits = [] for j in "CN", "GN", "SN", "C", "S", "L", "O", "OU": @@ -62,79 +16,6 @@ def subject2dn(subject): bits.append("%s=%s" % (j, getattr(subject, j))) return ", ".join(bits) -class CertificateAuthorityConfig(object): - """ - Certificate Authority configuration - - :param path: Absolute path to configuration file. - Defaults to /etc/ssl/openssl.cnf - """ - - def __init__(self, path='/etc/ssl/openssl.cnf', *args): - - #: Path to file where current configuration is loaded from. - self.path = path - - self._config = RawConfigParser() - self._config.readfp(itertools.chain(["[global]"], open(self.path))) - - def get(self, section, key, default=""): - if self._config.has_option(section, key): - return self._config.get(section, key) - else: - return default - - def instantiate_authority(self, common_name): - section = "CA_" + common_name - - dirs = dict([(key, self.get(section, key)) - for key in ("dir", "certificate", "crl", "certs", "new_certs_dir", "private_key", "revoked_certs_dir", "request_subnets", "autosign_subnets", "admin_subnets", "admin_users", "push_server", "database", "inbox", "outbox")]) - - # Variable expansion, eg $dir - for key, value in dirs.items(): - if "$" in value: - dirs[key] = re.sub(r'\$([a-z]+)', lambda m:dirs[m.groups()[0]], value) - - dirs.pop("dir") - dirs["email_address"] = self.get(section, "emailAddress") - dirs["certificate_lifetime"] = int(self.get(section, "default_days", "1825")) - dirs["revocation_list_lifetime"] = int(self.get(section, "default_crl_days", "1")) - - extensions_section = self.get(section, "x509_extensions") - if extensions_section: - dirs["basic_constraints"] = self.get(extensions_section, "basicConstraints") - dirs["key_usage"] = self.get(extensions_section, "keyUsage") - dirs["extended_key_usage"] = self.get(extensions_section, "extendedKeyUsage") - authority = CertificateAuthority(common_name, **dirs) - return authority - - - def all_authorities(self, wanted=None): - for ca in self.ca_list: - if wanted and ca not in wanted: - continue - try: - yield self.instantiate_authority(ca) - except FileNotFoundError: - pass - - - @property - def ca_list(self): - """ - Returns sorted list of CA-s defined in the configuration file. - """ - return sorted([s[3:] for s in self._config if s.startswith("CA_")]) - - def pop_certificate_authority(self): - def wrapper(func): - def wrapped(*args, **kwargs): - common_name = kwargs.pop("ca") - kwargs["ca"] = self.instantiate_authority(common_name) - return func(*args, **kwargs) - return wrapped - return wrapper - class CertificateBase: def __repr__(self): return self.buf @@ -351,9 +232,6 @@ class Request(CertificateBase): def dump(self): return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self._obj).decode("ascii") - def __repr__(self): - return "Request(%s)" % repr(self.path) - def create(self): # Generate 4096-bit RSA key key = crypto.PKey() @@ -364,6 +242,7 @@ class Request(CertificateBase): req.set_pubkey(key) return Request(req) + class Certificate(CertificateBase): def __init__(self, mixed): self.buf = NotImplemented @@ -387,7 +266,7 @@ class Certificate(CertificateBase): if isinstance(mixed, crypto.X509): self._obj = mixed else: - raise ValueError("Can't parse %s as X.509 certificate!" % mixed) + raise ValueError("Can't parse %s (%s) as X.509 certificate!" % (mixed, type(mixed))) assert not self.buf or self.buf == self.dump(), "%s is not %s" % (self.buf, self.dump()) @@ -435,275 +314,3 @@ class Certificate(CertificateBase): def __lte__(self, other): return self.signed <= other.signed -class CertificateAuthority(object): - def __init__(self, common_name, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, private_key=None, autosign_subnets=None, request_subnets=None, admin_subnets=None, admin_users=None, email_address=None, inbox=None, outbox=None, basic_constraints="CA:FALSE", key_usage="digitalSignature,keyEncipherment", extended_key_usage="clientAuth", certificate_lifetime=5*365, revocation_list_lifetime=1, push_server=None, database=None): - - import hashlib - m = hashlib.sha512() - m.update(common_name.encode("ascii")) - m.update(b"TODO:server-secret-goes-here") - self.uuid = m.hexdigest() - - self.revocation_list = crl - self.signed_dir = certs - self.request_dir = new_certs_dir - self.revoked_dir = revoked_certs_dir - self.private_key = private_key - - self.admin_subnets = set([ipaddress.ip_network(j) for j in admin_subnets.split(" ") if j]) - self.autosign_subnets = set([ipaddress.ip_network(j) for j in autosign_subnets.split(" ") if j]) - self.request_subnets = set([ipaddress.ip_network(j) for j in request_subnets.split(" ") if j]).union(self.autosign_subnets) - - self.certificate = Certificate(open(certificate)) - self.mailer = Mailer(outbox) if outbox else None - self.push_server = push_server - self.database_url = database - self._database_pool = None - - self.certificate_lifetime = certificate_lifetime - self.revocation_list_lifetime = revocation_list_lifetime - self.basic_constraints = basic_constraints - self.key_usage = key_usage - self.extended_key_usage = extended_key_usage - - self.admin_emails = dict() - self.admin_users = set() - if admin_users: - if admin_users.startswith("/"): - for user in open(admin_users): - if ":" in user: - user, email, first_name, last_name = user.split(":") - self.admin_emails[user] = email - self.admin_users.add(user) - else: - self.admin_users = set([j for j in admin_users.split(" ") if j]) - - @property - def common_name(self): - return self.certificate.common_name - - @property - def database(self): - from urllib.parse import urlparse - if not self._database_pool: - o = urlparse(self.database_url) - if o.scheme == "mysql": - import mysql.connector - self._database_pool = mysql.connector.pooling.MySQLConnectionPool( - pool_size = 3, - user=o.username, - password=o.password, - host=o.hostname, - database=o.path[1:]) - else: - raise NotImplementedError("Unsupported database scheme %s, currently only mysql://user:pass@host/database is supported" % o.scheme) - - return self._database_pool - - def event_publish(self, event_type, event_data): - """ - Publish event on push server - """ - url = self.push_server + "/pub?id=" + self.uuid # Derive CA's push channel URL - - notification = urllib.request.Request( - url, - event_data.encode("utf-8"), - {"Event-ID": b"TODO", "Event-Type":event_type.encode("ascii")}) - notification.add_header("User-Agent", "Certidude API") - click.echo("Posting event %s %s at %s, waiting for response..." % (repr(event_type), repr(event_data), repr(url))) - try: - response = urllib.request.urlopen(notification) - body = response.read() - except urllib.error.HTTPError as err: - if err.code == 404: - print("No subscribers on the channel") - else: - raise - else: - print("Push server returned:", response.code, body) - - def _signer_exec(self, cmd, *bits): - sock = self.connect_signer() - sock.send(cmd.encode("ascii")) - sock.send(b"\n") - for bit in bits: - sock.send(bit.encode("ascii")) - sock.sendall(b"\n\n") - buf = sock.recv(8192) - if not buf: - raise - return buf - - def __repr__(self): - return "CertificateAuthority(common_name=%s)" % repr(self.common_name) - - def get_certificate(self, cn): - return open(os.path.join(self.signed_dir, cn + ".pem")).read() - - def connect_signer(self): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect("/run/certidude/signer/%s.sock" % self.common_name) - return sock - - def revoke(self, cn): - cert = Certificate(open(os.path.join(self.signed_dir, cn + ".pem"))) - revoked_filename = os.path.join(self.revoked_dir, "%s.pem" % cert.serial_number) - os.rename(cert.path, revoked_filename) - self.event_publish("certificate_revoked", cert.fingerprint()) - - def get_revoked(self): - for root, dirs, files in os.walk(self.revoked_dir): - for filename in files: - if filename.endswith(".pem"): - yield Certificate(open(os.path.join(root, filename))) - break - - def get_signed(self): - for root, dirs, files in os.walk(self.signed_dir): - for filename in files: - if filename.endswith(".pem"): - yield Certificate(open(os.path.join(root, filename))) - break - - def get_requests(self): - for root, dirs, files in os.walk(self.request_dir): - for filename in files: - if filename.endswith(".pem"): - yield Request(open(os.path.join(root, filename))) - break - - def get_request(self, cn): - return Request(open(os.path.join(self.request_dir, cn + ".pem"))) - - def store_request(self, buf, overwrite=False): - request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf) - common_name = request.get_subject().CN - request_path = os.path.join(self.request_dir, common_name + ".pem") - - # If there is cert, check if it's the same - if os.path.exists(request_path): - if open(request_path, "rb").read() != buf: - print("Request already exists, not creating new request") - raise FileExistsError("Request already exists") - else: - with open(request_path + ".part", "wb") as fh: - fh.write(buf) - os.rename(request_path + ".part", request_path) - - return Request(open(request_path)) - - def request_exists(self, cn): - return os.path.exists(os.path.join(self.request_dir, cn + ".pem")) - - def delete_request(self, cn): - path = os.path.join(self.request_dir, cn + ".pem") - request_sha1sum = Request(open(path)).fingerprint() - os.unlink(path) - - # Publish event at CA channel - self.event_publish("request_deleted", request_sha1sum) - - # Write empty certificate to long-polling URL - url = self.push_server + "/pub/?id=" + request_sha1sum - publisher = urllib.request.Request(url, b"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n") - publisher.add_header("User-Agent", "Certidude API") - click.echo("POST-ing empty certificate at %s, waiting for response..." % url) - try: - response = urllib.request.urlopen(publisher) - body = response.read() - except urllib.error.HTTPError as err: - if err.code == 404: - print("No subscribers on the channel") - else: - raise - else: - print("Push server returned:", response.code, body) - - def create_bundle(self, common_name, organizational_unit=None, email_address=None, overwrite=True): - req = Request.create() - req.country = self.certificate.country - req.state_or_county = self.certificate.state_or_county - req.city = self.certificate.city - req.organization = self.certificate.organization - req.organizational_unit = organizational_unit or self.certificate.organizational_unit - req.common_name = common_name - req.email_address = email_address - cert_buf = self.sign(req, overwrite) - return crypto.dump_privatekey(crypto.FILETYPE_PEM, key).decode("ascii"), \ - req_buf, cert_buf - - @publish_certificate - def sign(self, req, overwrite=False, delete=True): - """ - Sign certificate signing request via signer process - """ - - cert_path = os.path.join(self.signed_dir, req.common_name + ".pem") - - # Move existing certificate if necessary - if os.path.exists(cert_path): - old_cert = Certificate(open(cert_path)) - if overwrite: - self.revoke(req.common_name) - elif req.pubkey == old_cert.pubkey: - return old_cert - else: - raise FileExistsError("Will not overwrite existing certificate") - - # Sign via signer process - cert_buf = self._signer_exec("sign-request", req.dump()) - with open(cert_path + ".part", "wb") as fh: - fh.write(cert_buf) - os.rename(cert_path + ".part", cert_path) - - return Certificate(open(cert_path)) - - @publish_certificate - def sign2(self, request, overwrite=False, delete=True, lifetime=None): - """ - Sign directly using private key, this is usually done by root. - Basic constraints and certificate lifetime are copied from openssl.cnf, - lifetime may be overridden on the command line, - other extensions are copied as is. - """ - cert = raw_sign( - crypto.load_privatekey(crypto.FILETYPE_PEM, open(self.private_key).read()), - self.certificate._obj, - request._obj, - self.basic_constraints, - lifetime=lifetime or self.certificate_lifetime) - - path = os.path.join(self.signed_dir, request.common_name + ".pem") - if os.path.exists(path): - if overwrite: - self.revoke(request.common_name) - else: - raise FileExistsError("File %s already exists!" % path) - - buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) - with open(path + ".part", "wb") as fh: - fh.write(buf) - os.rename(path + ".part", path) - click.echo("Wrote certificate to: %s" % path) - if delete: - os.unlink(request.path) - click.echo("Deleted request: %s" % request.path) - - return Certificate(open(path)) - - def export_crl(self): - sock = self.connect_signer() - sock.send(b"export-crl\n") - for filename in os.listdir(self.revoked_dir): - if not filename.endswith(".pem"): - continue - serial_number = filename[:-4] - # TODO: Assert serial against regex - revoked_path = os.path.join(self.revoked_dir, filename) - # TODO: Skip expired certificates - s = os.stat(revoked_path) - sock.send(("%s:%d\n" % (serial_number, s.st_ctime)).encode("ascii")) - sock.sendall(b"\n") - return sock.recv(32*1024*1024) -