Separated codebase from development repo
This commit is contained in:
		
							
								
								
									
										6
									
								
								.flake8
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.flake8
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| [flake8] | ||||
| inline-quotes = " | ||||
| multiline-quotes = """ | ||||
| indent-size = 4 | ||||
| max-line-length = 160 | ||||
| ignore = Q003 E128 E704 | ||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| *.pyc | ||||
| *.swp | ||||
| @@ -3,7 +3,7 @@ repos: | ||||
|     rev: 3.9.2 | ||||
|     hooks: | ||||
|     -   id: flake8 | ||||
|         additional_dependencies: [flake8-typing-imports==1.10.0] | ||||
|         additional_dependencies: [flake8-typing-imports==1.10.0,flake8-quotes==3.2.0] | ||||
|  | ||||
| -   repo: https://github.com/jorisroovers/gitlint | ||||
|     rev: v0.15.1 | ||||
|   | ||||
							
								
								
									
										28
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| FROM ubuntu:20.04 as base | ||||
| ENV container docker | ||||
| ENV PYTHONUNBUFFERED=1 | ||||
| ENV LC_ALL C.UTF-8 | ||||
| ENV DEBIAN_FRONTEND noninteractive | ||||
| RUN echo force-unsafe-io > /etc/dpkg/dpkg.cfg.d/docker-apt-speedup \ | ||||
|  && echo "Dpkg::Use-Pty=0;" > /etc/apt/apt.conf.d/99quieter \ | ||||
|  && apt-get update -qq \ | ||||
|  && apt-get install -y -qq \ | ||||
|     bash build-essential python3-dev cython3 libffi-dev libssl-dev \ | ||||
|     libkrb5-dev ldap-utils libsasl2-modules-gssapi-mit libsasl2-dev libldap2-dev \ | ||||
|     python python3-pip python3-cffi iptables ipset \ | ||||
|     strongswan libstrongswan-extra-plugins libcharon-extra-plugins \ | ||||
|     openvpn  libncurses5-dev gawk wget unzip git rsync \ | ||||
|  && apt-get clean \ | ||||
|  && rm /etc/dpkg/dpkg.cfg.d/docker-apt-speedup | ||||
|  | ||||
| WORKDIR /src | ||||
| COPY requirements.txt /src/ | ||||
| RUN pip3 install --no-cache-dir -r requirements.txt | ||||
| COPY config/strongswan.conf /etc/strongswan.conf | ||||
| COPY pinecrypt/. /src/pinecrypt/ | ||||
| COPY helpers /helpers/ | ||||
| COPY MANIFEST.in setup.py README.md /src/ | ||||
| COPY misc/. /src/misc/ | ||||
| RUN python3 -m compileall . | ||||
| RUN pip3 install --no-cache-dir . | ||||
| RUN rm -Rfv /src | ||||
							
								
								
									
										6
									
								
								MANIFEST.in
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								MANIFEST.in
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| include README.md | ||||
| include pinecrypt/server/templates/mail/*.md | ||||
| include pinecrypt/server/builder/overlay/usr/bin/pinecrypt.server-* | ||||
| include pinecrypt/server/builder/overlay/etc/uci-defaults/* | ||||
| include pinecrypt/server/builder/overlay/etc/profile | ||||
| include pinecrypt/server/builder/*.sh | ||||
							
								
								
									
										45
									
								
								config/strongswan.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								config/strongswan.conf
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| charon { | ||||
|   load_modular = yes | ||||
|   plugins { | ||||
|     include /etc/strongswan.d/charon/ccm.conf | ||||
|     include /etc/strongswan.d/charon/nonce.conf | ||||
|     include /etc/strongswan.d/charon/updown.conf | ||||
|     include /etc/strongswan.d/charon/pkcs11.conf | ||||
|     include /etc/strongswan.d/charon/connmark.conf | ||||
|     include /etc/strongswan.d/charon/pkcs1.conf | ||||
|     include /etc/strongswan.d/charon/constraints.conf | ||||
|     include /etc/strongswan.d/charon/revocation.conf | ||||
|     include /etc/strongswan.d/charon/openssl.conf | ||||
|     include /etc/strongswan.d/charon/aes.conf | ||||
|     include /etc/strongswan.d/charon/resolve.conf | ||||
|     include /etc/strongswan.d/charon/curve25519.conf | ||||
|     include /etc/strongswan.d/charon/gcm.conf | ||||
|     include /etc/strongswan.d/charon/random.conf | ||||
|     include /etc/strongswan.d/charon/pkcs7.conf | ||||
|     include /etc/strongswan.d/charon/af-alg.conf | ||||
|     include /etc/strongswan.d/charon/x509.conf | ||||
|     include /etc/strongswan.d/charon/rdrand.conf | ||||
|     include /etc/strongswan.d/charon/hmac.conf | ||||
|     include /etc/strongswan.d/charon/gmp.conf | ||||
|     include /etc/strongswan.d/charon/pubkey.conf | ||||
|     include /etc/strongswan.d/charon/ctr.conf | ||||
|     include /etc/strongswan.d/charon/certexpire.conf | ||||
|     include /etc/strongswan.d/charon/socket-default.conf | ||||
|     include /etc/strongswan.d/charon/lookip.conf | ||||
|     include /etc/strongswan.d/charon/mgf1.conf | ||||
|     include /etc/strongswan.d/charon/unity.conf | ||||
|     include /etc/strongswan.d/charon/sha2.conf | ||||
|     include /etc/strongswan.d/charon/stroke.conf | ||||
|     include /etc/strongswan.d/charon/aesni.conf | ||||
|     include /etc/strongswan.d/charon/agent.conf | ||||
|     include /etc/strongswan.d/charon/kernel-libipsec.conf | ||||
|     include /etc/strongswan.d/charon/curl.conf | ||||
|     include /etc/strongswan.d/charon/pkcs8.conf | ||||
|     include /etc/strongswan.d/charon/cmac.conf | ||||
|     include /etc/strongswan.d/charon/attr.conf | ||||
|     include /etc/strongswan.d/charon/error-notify.conf | ||||
|     include /etc/strongswan.d/charon/pem.conf | ||||
|     include /etc/strongswan.d/charon/pkcs12.conf | ||||
|     include /etc/strongswan.d/charon/kernel-netlink.conf | ||||
|   } | ||||
| } | ||||
							
								
								
									
										15
									
								
								helpers/openvpn-client-connect.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										15
									
								
								helpers/openvpn-client-connect.py
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| #!/usr/bin/python3 | ||||
| import os | ||||
| import sys | ||||
| from pinecrypt.server import db | ||||
|  | ||||
| # This implements OCSP like functionality | ||||
|  | ||||
| obj = db.certificates.find_one({ | ||||
|     # TODO: use digest instead | ||||
|     "serial_number": "%x" % int(os.environ["tls_serial_0"]), | ||||
|     "status":"signed", | ||||
| }) | ||||
|  | ||||
| if not obj: | ||||
|     sys.exit(1) | ||||
							
								
								
									
										28
									
								
								helpers/openvpn-learn-address.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										28
									
								
								helpers/openvpn-learn-address.py
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| #!/usr/bin/python3 | ||||
| import os | ||||
| import sys | ||||
| from pinecrypt.server import db | ||||
| from datetime import datetime | ||||
|  | ||||
| operation, addr = sys.argv[1:3] | ||||
| if operation == "delete": | ||||
|     pass | ||||
| else: | ||||
|     common_name = sys.argv[3] | ||||
|     db.certificates.update_one({ | ||||
|         # TODO: use digest instead | ||||
|         "serial_number": "%x" % int(os.environ["tls_serial_0"]), | ||||
|         "status":"signed", | ||||
|     }, { | ||||
|         "$set": { | ||||
|             "last_seen": datetime.utcnow(), | ||||
|             "instance": os.environ["instance"], | ||||
|             "remote": { | ||||
|                 "port": int(os.environ["untrusted_port"]), | ||||
|                 "addr": os.environ["untrusted_ip"], | ||||
|             } | ||||
|         }, | ||||
|         "$addToSet": { | ||||
|             "ip": addr | ||||
|         } | ||||
|     }) | ||||
							
								
								
									
										31
									
								
								helpers/updown.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										31
									
								
								helpers/updown.py
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| #!/usr/bin/python3 | ||||
| import sys | ||||
| import os | ||||
| from pinecrypt.server import db | ||||
| from datetime import datetime | ||||
|  | ||||
| addrs = set() | ||||
| for key, value in os.environ.items(): | ||||
|     if key.startswith("PLUTO_PEER_SOURCEIP"): | ||||
|         addrs.add(value) | ||||
|  | ||||
| with open("/instance") as fh: | ||||
|     instance = fh.read().strip() | ||||
|  | ||||
| db.certificates.update_one({ | ||||
|     "distinguished_name": os.environ["PLUTO_PEER_ID"], | ||||
|     "status":"signed", | ||||
| }, { | ||||
|     "$set": { | ||||
|         "last_seen": datetime.utcnow(), | ||||
|         "instance": instance, | ||||
|         "remote": { | ||||
|             "addr": os.environ["PLUTO_PEER"] | ||||
|         } | ||||
|     }, | ||||
|     "$addToSet": { | ||||
|         "ip": { | ||||
|             "$each": list(addrs) | ||||
|         } | ||||
|     } | ||||
| }) | ||||
							
								
								
									
										6
									
								
								misc/pinecone
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								misc/pinecone
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| #!/usr/bin/env python | ||||
|  | ||||
| from pinecrypt.server.cli import entry_point | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     entry_point() | ||||
							
								
								
									
										0
									
								
								pinecrypt/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								pinecrypt/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								pinecrypt/server/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								pinecrypt/server/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								pinecrypt/server/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								pinecrypt/server/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										44
									
								
								pinecrypt/server/api/bootstrap.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								pinecrypt/server/api/bootstrap.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| import hashlib | ||||
| import logging | ||||
| from pinecrypt.server import authority, const, config | ||||
| from pinecrypt.server.common import cert_to_dn | ||||
| from pinecrypt.server.decorators import serialize | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
| class BootstrapResource(object): | ||||
|     @serialize | ||||
|     def on_get(self, req, resp): | ||||
|         """ | ||||
|         Return publicly accessible info unlike /api/session | ||||
|         """ | ||||
|         return dict( | ||||
|             hostname=const.FQDN, | ||||
|             namespace=const.AUTHORITY_NAMESPACE, | ||||
|             replicas=[doc["common_name"] for doc in authority.list_replicas()], | ||||
|             globals=list(config.get_all("Globals")), | ||||
|             openvpn=dict( | ||||
|                 tls_version_min=config.get("Globals", "OPENVPN_TLS_VERSION_MIN")["value"], | ||||
|                 tls_ciphersuites=config.get("Globals", "OPENVPN_TLS_CIPHERSUITES")["value"], | ||||
|                 tls_cipher=config.get("Globals", "OPENVPN_TLS_CIPHER")["value"], | ||||
|                 cipher=config.get("Globals", "OPENVPN_CIPHER")["value"], | ||||
|                 auth=config.get("Globals", "OPENVPN_AUTH")["value"] | ||||
|             ), | ||||
|             strongswan=dict( | ||||
|                 dhgroup=config.get("Globals", "STRONGSWAN_DHGROUP")["value"], | ||||
|                 ike=config.get("Globals", "STRONGSWAN_IKE")["value"], | ||||
|                 esp=config.get("Globals", "STRONGSWAN_ESP")["value"], | ||||
|             ), | ||||
|             certificate=dict( | ||||
|                 algorithm=authority.public_key.algorithm, | ||||
|                 common_name=authority.certificate.subject.native["common_name"], | ||||
|                 distinguished_name=cert_to_dn(authority.certificate), | ||||
|                 md5sum=hashlib.md5(authority.certificate_buf).hexdigest(), | ||||
|                 blob=authority.certificate_buf.decode("ascii"), | ||||
|                 organization=authority.certificate["tbs_certificate"]["subject"].native.get("organization_name"), | ||||
|                 signed=authority.certificate["tbs_certificate"]["validity"]["not_before"].native.replace(tzinfo=None), | ||||
|                 expires=authority.certificate["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None) | ||||
|             ), | ||||
|             user_enrollment_allowed=const.USER_ENROLLMENT_ALLOWED, | ||||
|             user_multiple_certificates=const.USER_MULTIPLE_CERTIFICATES, | ||||
|         ) | ||||
							
								
								
									
										55
									
								
								pinecrypt/server/api/builder.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								pinecrypt/server/api/builder.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
|  | ||||
| import click | ||||
| import os | ||||
| import asyncio | ||||
| from sanic import Sanic | ||||
| from sanic.exceptions import ServerError | ||||
| from sanic.response import file_stream | ||||
| from pinecrypt.server import const | ||||
|  | ||||
| app = Sanic("builder") | ||||
| app.config.RESPONSE_TIMEOUT = 300 | ||||
|  | ||||
| @app.route("/api/build/") | ||||
| async def view_build(request): | ||||
|     build_script_path = "/builder/script/mfp.sh" | ||||
|     suffix = "-glinet_gl-ar150-squashfs-sysupgrade.bin" | ||||
|     suggested_filename = "mfp%s" % suffix | ||||
|     build = "/builder/src" | ||||
|     log_path = build + "/build.log" | ||||
|  | ||||
|     proc = await asyncio.create_subprocess_exec( | ||||
|         build_script_path, | ||||
|         stdout=open(log_path, "w"), | ||||
|         close_fds=True, | ||||
|         shell=False, | ||||
|         cwd=os.path.dirname(os.path.realpath(build_script_path)), | ||||
|         env={ | ||||
|             "PROFILE": "glinet_gl-ar150", | ||||
|             "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", | ||||
|             "AUTHORITY_NAMESPACE": const.AUTHORITY_NAMESPACE, | ||||
|             "BUILD": build, | ||||
|             "OVERLAY": build + "/overlay/" | ||||
|         }, | ||||
|         startupinfo=None, | ||||
|         creationflags=0, | ||||
|     ) | ||||
|  | ||||
|     stdout, stderr = await proc.communicate() | ||||
|  | ||||
|     if proc.returncode: | ||||
|         raise ServerError("Build script finished with non-zero exitcode, see %s for more information" % log_path) | ||||
|  | ||||
|     for root, dirs, files in os.walk("/builder/src/bin/targets"): | ||||
|         for filename in files: | ||||
|             if filename.endswith(suffix): | ||||
|                 path = os.path.join(root, filename) | ||||
|                 click.echo("Serving: %s" % path) | ||||
|                 return await file_stream( | ||||
|                         path, | ||||
|                         headers={ | ||||
|                            "Content-Disposition": "attachment; filename=%s" % suggested_filename | ||||
|                         } | ||||
|                     ) | ||||
|     raise ServerError("Failed to find image builder directory in %s" % build) | ||||
|  | ||||
							
								
								
									
										121
									
								
								pinecrypt/server/api/events.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								pinecrypt/server/api/events.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | ||||
| from datetime import datetime | ||||
| from functools import wraps | ||||
| from oscrypto import asymmetric | ||||
| from json import dumps | ||||
| from motor.motor_asyncio import AsyncIOMotorClient | ||||
| from pinecrypt.server import const | ||||
| from prometheus_client import Counter | ||||
| from sanic import Sanic | ||||
| from sanic.response import stream | ||||
| from sanic_prometheus import monitor | ||||
| from bson.objectid import ObjectId | ||||
|  | ||||
|  | ||||
| streams_opened = Counter("pinecrypt_events_stream_opened", | ||||
|     "Event stream opened count") | ||||
| events_emitted = Counter("pinecrypt_events_emitted", | ||||
|     "Events emitted count") | ||||
|  | ||||
|  | ||||
| app = Sanic("events") | ||||
| monitor(app).expose_endpoint() | ||||
| app.config.RESPONSE_TIMEOUT = 999 | ||||
|  | ||||
| def cookie_login(func): | ||||
|     @wraps(func) | ||||
|     async def wrapped(request, *args, **kwargs): | ||||
|         if request.method != "GET": | ||||
|             raise ValueError("For now stick with read-only operations for cookie auth") | ||||
|         value = request.cookies.get(const.SESSION_COOKIE) | ||||
|         now = datetime.utcnow() | ||||
|         await app.db.certidude_sessions.update_one({ | ||||
|             "secret": value, | ||||
|             "started": { | ||||
|                 "$lte": now | ||||
|             }, | ||||
|             "expires": { | ||||
|                 "$gte": now | ||||
|             }, | ||||
|         }, { | ||||
|             "$set": { | ||||
|                 "last_seen": now, | ||||
|            } | ||||
|         }) | ||||
|         return await func(request, *args, **kwargs) | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| @app.listener("before_server_start") | ||||
| async def setup_db(app, loop): | ||||
|     # TODO: find cleaner way to do this, for more see | ||||
|     # https://github.com/sanic-org/sanic/issues/919 | ||||
|     app.db = AsyncIOMotorClient(const.MONGO_URI).get_default_database() | ||||
|  | ||||
|  | ||||
| # TODO: Change to /api/event/log and simplify nginx config to /api/event | ||||
| @app.route("/api/event/") | ||||
| @cookie_login | ||||
| async def view_event(request): | ||||
|     async def g(resp): | ||||
|         await resp.write("data: response-generator-started\n\n") | ||||
|         streams_opened.inc() | ||||
|  | ||||
|         async with app.db.watch(full_document="updateLookup") as stream: | ||||
|             await resp.write("data: watch-stream-opened\n\n") | ||||
|             async for event in stream: | ||||
|  | ||||
|                 if event.get("ns").get("coll") == "certidude_certificates": | ||||
|                     if event.get("operationType") == "insert" and event["fullDocument"].get("status") == "csr": | ||||
|                         await resp.write("event: request-submitted\ndata: %s\n\n" % str(event["documentKey"].get("_id"))) | ||||
|                         events_emitted.inc() | ||||
|  | ||||
|                     if event.get("operationType") == "update" and event["updateDescription"].get("updatedFields").get("status") == "signed": | ||||
|                         await resp.write("event: request-signed\ndata: %s\n\n" % str(event["documentKey"].get("_id"))) | ||||
|                         events_emitted.inc() | ||||
|  | ||||
|                     if event.get("operationType") == "insert" and event["fullDocument"].get("status") == "signed": | ||||
|                         await resp.write("event: request-signed\ndata: %s\n\n" % event["fullDocument"].get("common_name")) | ||||
|                         events_emitted.inc() | ||||
|  | ||||
|                     if event.get("operationType") == "update" and event["fullDocument"].get("status") == "revoked": | ||||
|                         await resp.write("event: certificate-revoked\ndata: %s\n\n" % str(event["documentKey"].get("_id"))) | ||||
|                         events_emitted.inc() | ||||
|  | ||||
|                     if event.get("operationType") == "delete": | ||||
|                         await resp.write("event: request-deleted\ndata: %s\n\n" % str(event["documentKey"].get("_id"))) | ||||
|                         events_emitted.inc() | ||||
|  | ||||
|                     if event.get("operationType") == "update" and "tags" in event.get("updateDescription").get("updatedFields"): | ||||
|                         await resp.write("event: tag-update\ndata: %s\n\n" % event["fullDocument"].get("common_name")) | ||||
|                         events_emitted.inc() | ||||
|  | ||||
|                     if event.get("operationType") == "update" and "attributes" in event.get("updateDescription").get("updatedFields"): | ||||
|                         await resp.write("event: attribute-update\ndata: %s\n\n" % str(event["documentKey"].get("_id"))) | ||||
|                         events_emitted.inc() | ||||
|  | ||||
|  | ||||
|                 if event.get("ns").get("coll") == "certidude_logs": | ||||
|  | ||||
|                     from pinecrypt.server.decorators import MyEncoder | ||||
|  | ||||
|                     obj=dict( | ||||
|                         created=event["fullDocument"].get("created"), | ||||
|                         message=event["fullDocument"].get("message"), | ||||
|                         severity=event["fullDocument"].get("severity") | ||||
|                     ) | ||||
|  | ||||
|                     await resp.write("event: log-entry\ndata: %s\n\n" % dumps(obj, cls=MyEncoder)) | ||||
|                     events_emitted.inc() | ||||
|     return stream(g, content_type="text/event-stream") | ||||
|  | ||||
|  | ||||
| @app.route("/api/event/request-signed/<id>") | ||||
| async def publish(request, id): | ||||
|     pipeline = [{"$match": { "operationType": "update", "fullDocument.status": "signed", "documentKey._id": ObjectId(id)}}] | ||||
|     resp = await request.respond(content_type="application/x-x509-user-cert") | ||||
|     async with app.db["certidude_certificates"].watch(pipeline, full_document="updateLookup") as stream: | ||||
|         async for event in stream: | ||||
|             cert_der = event["fullDocument"].get("cert_buf") | ||||
|             cert_pem = asymmetric.dump_certificate(asymmetric.load_certificate(cert_der)) | ||||
|             await resp.send(cert_pem, True) | ||||
|     return resp | ||||
							
								
								
									
										13
									
								
								pinecrypt/server/api/log.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								pinecrypt/server/api/log.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| from pinecrypt.server.decorators import serialize | ||||
| from pinecrypt.server import db | ||||
| from .utils.firewall import cookie_login | ||||
|  | ||||
| class LogResource(object): | ||||
|     @serialize | ||||
|     @cookie_login | ||||
|     def on_get(self, req, resp): | ||||
|         def g(): | ||||
|             for log in db.eventlog.find({}).limit(req.get_param_as_int("limit", required=True)).sort("created", -1): | ||||
|                 log.pop("_id") | ||||
|                 yield log | ||||
|         return tuple(g()) | ||||
							
								
								
									
										127
									
								
								pinecrypt/server/api/ocsp.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								pinecrypt/server/api/ocsp.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | ||||
| import pytz | ||||
| from asn1crypto.util import timezone | ||||
| from asn1crypto import ocsp | ||||
| from pinecrypt.server import const, authority | ||||
| from datetime import datetime, timedelta | ||||
| from math import inf | ||||
| from motor.motor_asyncio import AsyncIOMotorClient | ||||
| from oscrypto import asymmetric | ||||
| from prometheus_client import Counter, Histogram | ||||
| from sanic import Sanic, response | ||||
| from sanic_prometheus import monitor | ||||
|  | ||||
| ocsp_request_valid = Counter("pinecrypt_ocsp_request_valid", | ||||
|     "Valid OCSP requests") | ||||
| ocsp_request_list_size = Histogram("pinecrypt_ocsp_request_list_size", | ||||
|     "Histogram of OCSP request list size", | ||||
|     buckets=(1, 2, 3, inf)) | ||||
| ocsp_request_size_bytes = Histogram("pinecrypt_ocsp_request_size_bytes", | ||||
|     "Histogram of OCSP request size in bytes", | ||||
|     buckets=(100, 200, 500, 1000, 2000, 5000, 10000, inf)) | ||||
| ocsp_request_nonces = Histogram("pinecrypt_ocsp_request_nonces", | ||||
|     "Histogram of nonce count per request", | ||||
|     buckets=(1, 2, 3, inf)) | ||||
| ocsp_response_status = Counter("pinecrypt_ocsp_response_status", | ||||
|     "Status responses", ["status"]) | ||||
|  | ||||
| app = Sanic("events") | ||||
| monitor(app).expose_endpoint() | ||||
|  | ||||
|  | ||||
| @app.listener("before_server_start") | ||||
| async def setup_db(app, loop): | ||||
|     # TODO: find cleaner way to do this, for more see | ||||
|     # https://github.com/sanic-org/sanic/issues/919 | ||||
|     app.ctx.db = AsyncIOMotorClient(const.MONGO_URI).get_default_database() | ||||
|  | ||||
|  | ||||
| @app.route("/api/ocsp/", methods=["POST"]) | ||||
| async def view_ocsp_responder(request): | ||||
|     ocsp_request_size_bytes.observe(len(request.body)) | ||||
|     ocsp_req = ocsp.OCSPRequest.load(request.body) | ||||
|  | ||||
|     server_certificate = authority.get_ca_cert() | ||||
|  | ||||
|     now = datetime.now(timezone.utc).replace(microsecond=0) | ||||
|     response_extensions = [] | ||||
|  | ||||
|     nonces = 0 | ||||
|     for ext in ocsp_req["tbs_request"]["request_extensions"]: | ||||
|         if ext["extn_id"].native == "nonce": | ||||
|             nonces += 1 | ||||
|             response_extensions.append( | ||||
|                 ocsp.ResponseDataExtension({ | ||||
|                     "extn_id": "nonce", | ||||
|                     "critical": False, | ||||
|                     "extn_value": ext["extn_value"] | ||||
|                 }) | ||||
|             ) | ||||
|  | ||||
|     ocsp_request_nonces.observe(nonces) | ||||
|     ocsp_request_valid.inc() | ||||
|  | ||||
|     responses = [] | ||||
|  | ||||
|     ocsp_request_list_size.observe(len(ocsp_req["tbs_request"]["request_list"])) | ||||
|     for item in ocsp_req["tbs_request"]["request_list"]: | ||||
|         serial = item["req_cert"]["serial_number"].native | ||||
|         assert serial > 0, "Serial number correctness check failed" | ||||
|  | ||||
|         doc = await app.ctx.db.certidude_certificates.find_one({"serial_number": "%x" % serial}) | ||||
|         if doc: | ||||
|             if doc["status"] == "signed": | ||||
|                 status = ocsp.CertStatus(name="good", value=None) | ||||
|                 ocsp_response_status.labels("good").inc() | ||||
|             elif doc["status"] == "revoked": | ||||
|                 status = ocsp.CertStatus( | ||||
|                     name="revoked", | ||||
|                     value={ | ||||
|                         "revocation_time": doc["revoked"].replace(tzinfo=pytz.UTC), | ||||
|                         "revocation_reason": doc["revocation_reason"], | ||||
|                     }) | ||||
|                 ocsp_response_status.labels("revoked").inc() | ||||
|             else: | ||||
|                 # This should not happen, if it does database is mangled | ||||
|                 raise ValueError("Invalid/unknown certificate status '%s'" % doc["status"]) | ||||
|         else: | ||||
|             status = ocsp.CertStatus(name="unknown", value=None) | ||||
|             ocsp_response_status.labels("unknown").inc() | ||||
|  | ||||
|         responses.append({ | ||||
|             "cert_id": { | ||||
|                 "hash_algorithm": { | ||||
|                     "algorithm": "sha1" | ||||
|                 }, | ||||
|                 "issuer_name_hash": server_certificate.asn1.subject.sha1, | ||||
|                 "issuer_key_hash": server_certificate.public_key.asn1.sha1, | ||||
|                 "serial_number": serial, | ||||
|             }, | ||||
|             "cert_status": status, | ||||
|             "this_update": now - const.CLOCK_SKEW_TOLERANCE, | ||||
|             "next_update": now + timedelta(minutes=15) + const.CLOCK_SKEW_TOLERANCE, | ||||
|             "single_extensions": [] | ||||
|         }) | ||||
|  | ||||
|     response_data = ocsp.ResponseData({ | ||||
|         "responder_id": ocsp.ResponderId(name="by_key", value=server_certificate.public_key.asn1.sha1), | ||||
|         "produced_at": now, | ||||
|         "responses": responses, | ||||
|         "response_extensions": response_extensions | ||||
|     }) | ||||
|  | ||||
|     return response.raw(ocsp.OCSPResponse({ | ||||
|         "response_status": "successful", | ||||
|         "response_bytes": { | ||||
|             "response_type": "basic_ocsp_response", | ||||
|             "response": { | ||||
|                 "tbs_response_data": response_data, | ||||
|                 "certs": [server_certificate.asn1], | ||||
|                 "signature_algorithm": {"algorithm": "sha1_ecdsa" if authority.public_key.algorithm == "ec" else "sha1_rsa" }, | ||||
|                 "signature": (asymmetric.ecdsa_sign if authority.public_key.algorithm == "ec" else asymmetric.rsa_pkcs1v15_sign)( | ||||
|                     authority.private_key, | ||||
|                     response_data.dump(), | ||||
|                     "sha1" | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     }).dump(), headers={"Content-Type": "application/ocsp-response"}) | ||||
							
								
								
									
										268
									
								
								pinecrypt/server/api/request.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										268
									
								
								pinecrypt/server/api/request.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,268 @@ | ||||
| import click | ||||
| import falcon | ||||
| import logging | ||||
| import json | ||||
| import hashlib | ||||
| from asn1crypto import pem | ||||
| from asn1crypto.csr import CertificationRequest | ||||
| from pinecrypt.server import const, errors, authority | ||||
| from pinecrypt.server.decorators import csrf_protection, MyEncoder | ||||
| from pinecrypt.server.user import DirectoryConnection | ||||
| from oscrypto import asymmetric | ||||
| from .utils.firewall import whitelist_subnets, whitelist_content_types, \ | ||||
|     login_required, login_optional, authorize_admin, validate_clock_skew | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
| """ | ||||
| openssl genrsa -out test.key 1024 | ||||
| openssl req -new -sha256 -key test.key -out test.csr -subj "/CN=test" | ||||
| curl -f -L -H "Content-type: application/pkcs10" --data-binary @test.csr \ | ||||
|   http://ca.example.lan/api/request/?wait=yes | ||||
| """ | ||||
|  | ||||
| class RequestListResource(object): | ||||
|     @login_optional | ||||
|     @whitelist_subnets(const.REQUEST_SUBNETS) | ||||
|     @whitelist_content_types("application/pkcs10") | ||||
|     @validate_clock_skew | ||||
|     def on_post(self, req, resp): | ||||
|         """ | ||||
|         Validate and parse certificate signing request, the RESTful way | ||||
|         Endpoint urls | ||||
|         /request/?wait=yes | ||||
|         /request/autosign=1 | ||||
|         /request | ||||
|         """ | ||||
|         reasons = [] | ||||
|         body = req.stream.read(req.content_length) | ||||
|  | ||||
|         try: | ||||
|             header, _, der_bytes = pem.unarmor(body) | ||||
|             csr = CertificationRequest.load(der_bytes) | ||||
|         except ValueError: | ||||
|             logger.info("Malformed certificate signing request submission from %s blocked", req.context["remote"]["addr"]) | ||||
|             raise falcon.HTTPBadRequest( | ||||
|                 "Bad request", | ||||
|                 "Malformed certificate signing request") | ||||
|         else: | ||||
|             req_public_key = asymmetric.load_public_key(csr["certification_request_info"]["subject_pk_info"]) | ||||
|             if authority.public_key.algorithm != req_public_key.algorithm: | ||||
|                 logger.info("Attempt to submit %s based request from %s blocked, only %s allowed" % ( | ||||
|                     req_public_key.algorithm.upper(), | ||||
|                     req.context["remote"]["addr"], | ||||
|                     authority.public_key.algorithm.upper())) | ||||
|                 raise falcon.HTTPBadRequest( | ||||
|                     "Bad request", | ||||
|                     "Unsupported key algorithm %s, expected %s" % (req_public_key.algorithm, authority.public_key.algorithm)) | ||||
|  | ||||
|         try: | ||||
|             common_name = csr["certification_request_info"]["subject"].native["common_name"] | ||||
|         except KeyError: | ||||
|             logger.info("Malformed certificate signing request without common name submitted from %s" % req.context["remote"]["addr"]) | ||||
|             raise falcon.HTTPBadRequest(title="Bad request",description="Common name missing from certificate signing request") | ||||
|  | ||||
|         """ | ||||
|         Determine whether autosign is allowed to overwrite already issued | ||||
|         certificates automatically | ||||
|         """ | ||||
|  | ||||
|         overwrite_allowed = False | ||||
|         for subnet in const.OVERWRITE_SUBNETS: | ||||
|             if req.context["remote"]["addr"] in subnet: | ||||
|                 overwrite_allowed = True | ||||
|                 break | ||||
|  | ||||
|  | ||||
|         """ | ||||
|         Handle domain computer automatic enrollment | ||||
|         """ | ||||
|         machine = req.context.get("machine") | ||||
|         if machine: | ||||
|             reasons.append("machine enrollment not allowed from %s" % req.context["remote"]["addr"]) | ||||
|             for subnet in const.MACHINE_ENROLLMENT_SUBNETS: | ||||
|                 if req.context["remote"]["addr"] in subnet: | ||||
|                     if common_name != machine: | ||||
|                         raise falcon.HTTPBadRequest( | ||||
|                             "Bad request", | ||||
|                             "Common name %s differs from Kerberos credential %s!" % (common_name, machine)) | ||||
|  | ||||
|                     hit = False | ||||
|                     with DirectoryConnection() as conn: | ||||
|                         ft = const.LDAP_COMPUTER_FILTER % ("%s$" % machine) | ||||
|                         attribs = "cn", | ||||
|                         r = conn.search_s(const.LDAP_BASE, 2, ft, attribs) | ||||
|                         for dn, entry in r: | ||||
|                             if not dn: | ||||
|                                 continue | ||||
|                             else: | ||||
|                                 hit = True | ||||
|                                 break | ||||
|  | ||||
|                     if hit: | ||||
|                         # Automatic enroll with Kerberos machine cerdentials | ||||
|                         resp.set_header("Content-Type", "application/x-pem-file") | ||||
|                         try: | ||||
|                             mongo_doc = authority.store_request(body,address=str(req.context["remote"]["addr"])) | ||||
|                             cert, resp.text = authority.sign(mongo_id=str(mongo_doc["_id"]), | ||||
|                                 profile="Roadwarrior", overwrite=overwrite_allowed) # TODO: handle thrown exception | ||||
|                             logger.info("Automatically enrolled Kerberos authenticated machine %s (%s) from %s", | ||||
|                                 machine, dn, req.context["remote"]["addr"]) | ||||
|                             return | ||||
|                         except errors.RequestExists: | ||||
|                             reasons.append("same request already uploaded exists") | ||||
|                             # We should still redirect client to long poll URL below | ||||
|                         except errors.DuplicateCommonNameError: | ||||
|                             logger.warning("rejected signing request with overlapping common name from %s", | ||||
|                                 req.context["remote"]["addr"]) | ||||
|                             raise falcon.HTTPConflict( | ||||
|                                 "CSR with such CN already exists", | ||||
|                                  "Will not overwrite existing certificate signing request, explicitly delete CSR and try again") | ||||
|  | ||||
|                     else: | ||||
|                         logger.error("Kerberos authenticated machine %s didn't fit the 'ldap computer filter' criteria %s" % (machine, ft)) | ||||
|  | ||||
|  | ||||
|         """ | ||||
|         Process automatic signing if the IP address is whitelisted, | ||||
|         autosigning was requested and certificate can be automatically signed | ||||
|         """ | ||||
|  | ||||
|         if req.get_param_as_bool("autosign"): | ||||
|             for subnet in const.AUTOSIGN_SUBNETS: | ||||
|                 if req.context["remote"]["addr"] in subnet: | ||||
|                     try: | ||||
|                         resp.set_header("Content-Type", "application/x-pem-file") | ||||
|                         mongo_doc = authority.store_request(body,address=str(req.context["remote"]["addr"])) | ||||
|                         _, resp.text = authority.sign(mongo_id=str(mongo_doc["_id"]), | ||||
|                             overwrite=overwrite_allowed, profile="Roadwarrior") | ||||
|  | ||||
|                         logger.info("Signed %s as %s is whitelisted for autosign", common_name, req.context["remote"]["addr"]) | ||||
|                         return | ||||
|                     except EnvironmentError: | ||||
|                         logger.info("Autosign for %s from %s failed, signed certificate already exists", | ||||
|                             common_name, req.context["remote"]["addr"]) | ||||
|                         reasons.append("autosign failed, signed certificate already exists") | ||||
|                     break | ||||
|             else: | ||||
|                 reasons.append("IP address not whitelisted for autosign") | ||||
|         else: | ||||
|             reasons.append("autosign not requested") | ||||
|  | ||||
|         # Attempt to save the request otherwise | ||||
|         try: | ||||
|             mongo_doc = authority.store_request(body, | ||||
|                 address=str(req.context["remote"]["addr"])) | ||||
|         except errors.RequestExists: | ||||
|             reasons.append("same request already uploaded exists") | ||||
|             # We should still redirect client to long poll URL below | ||||
|         except errors.DuplicateCommonNameError: | ||||
|             logger.warning("rejected signing request with overlapping common name from %s", | ||||
|                 req.context["remote"]["addr"]) | ||||
|             raise falcon.HTTPConflict( | ||||
|                 "CSR with such CN already exists", | ||||
|                 "Will not overwrite existing certificate signing request, explicitly delete CSR and try again") | ||||
|  | ||||
|         # Wait the certificate to be signed if waiting is requested | ||||
|         logger.info("Signing request %s from %s put on hold,  %s", common_name, req.context["remote"]["addr"], ", ".join(reasons)) | ||||
|  | ||||
|         if req.get_param("wait"): | ||||
|             header, _, der_bytes = pem.unarmor(body) | ||||
|             url = "https://%s/api/event/request-signed/%s" % (const.AUTHORITY_NAMESPACE, str(mongo_doc["_id"])) | ||||
|             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 | ||||
|             resp.text = ". ".join(reasons) | ||||
|  | ||||
|             if req.client_accepts("application/json"): | ||||
|                 resp.text = json.dumps({"title":"Accepted", "description":resp.text, "id":str(mongo_doc["_id"])}, | ||||
|                     cls=MyEncoder) | ||||
|  | ||||
|  | ||||
| class RequestDetailResource(object): | ||||
|     def on_get(self, req, resp, id): | ||||
|         """ | ||||
|         Fetch certificate signing request as PEM | ||||
|         """ | ||||
|  | ||||
|         try: | ||||
|             csr, csr_doc, buf = authority.get_request(id) | ||||
|         except errors.RequestDoesNotExist: | ||||
|             logger.warning("Failed to serve non-existant request %s to %s", | ||||
|                 id, req.context["remote"]["addr"]) | ||||
|             raise falcon.HTTPNotFound() | ||||
|  | ||||
|         resp.set_header("Content-Type", "application/pkcs10") | ||||
|         logger.debug("Signing request %s was downloaded by %s", | ||||
|             csr_doc["common_name"], req.context["remote"]["addr"]) | ||||
|  | ||||
|         preferred_type = req.client_prefers(("application/json", "application/x-pem-file")) | ||||
|  | ||||
|         if preferred_type == "application/x-pem-file": | ||||
|             # For certidude client, curl scripts etc | ||||
|             resp.set_header("Content-Type", "application/x-pem-file") | ||||
|             resp.set_header("Content-Disposition", ("attachment; filename=%s.pem" % csr_doc["common_name"])) | ||||
|             resp.text = buf | ||||
|         elif preferred_type == "application/json": | ||||
|             # For web interface events | ||||
|             resp.set_header("Content-Type", "application/json") | ||||
|             resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % csr_doc["common_name"])) | ||||
|             resp.text = json.dumps(dict( | ||||
|                 submitted=csr_doc["submitted"], | ||||
|                 common_name=csr_doc["common_name"], | ||||
|                 id=str(csr_doc["_id"]), | ||||
|                 address=csr_doc["user"]["request_addresss"], | ||||
|                 md5sum=hashlib.md5(buf).hexdigest(), | ||||
|                 sha1sum=hashlib.sha1(buf).hexdigest(), | ||||
|                 sha256sum=hashlib.sha256(buf).hexdigest(), | ||||
|                 sha512sum=hashlib.sha512(buf).hexdigest()), cls=MyEncoder) | ||||
|         else: | ||||
|             raise falcon.HTTPUnsupportedMediaType( | ||||
|                 "Client did not accept application/json or application/x-pem-file") | ||||
|  | ||||
|  | ||||
|     @csrf_protection | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_post(self, req, resp, id): | ||||
|         """ | ||||
|         Sign a certificate signing request | ||||
|         """ | ||||
|         try: | ||||
|             cert, buf = authority.sign(mongo_id=id, | ||||
|                 profile=req.get_param("profile", default="Roadwarrior"), | ||||
|                 overwrite=True, | ||||
|                 signer=req.context.get("user").name)  #if user is cached in browser then there is no name | ||||
|             # Mailing and long poll publishing implemented in the function above | ||||
|         except EnvironmentError: # no such CSR | ||||
|             raise falcon.HTTPNotFound(title="Not found",description="CSR not found with id %s" %  id) | ||||
|  | ||||
|         resp.text = "Certificate successfully signed" | ||||
|         resp.status = falcon.HTTP_201 | ||||
|         resp.location = req.forwarded_uri.replace("request","sign") | ||||
|  | ||||
|         cn = cert.subject.native.get("common_name") | ||||
|         logger.info("Signing request %s signed by %s from %s", cn, | ||||
|             req.context.get("user"), req.context["remote"]["addr"]) | ||||
|  | ||||
|  | ||||
|     @csrf_protection | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_delete(self, req, resp, id): | ||||
|         try: | ||||
|             authority.delete_request(id, user=req.context.get("user")) | ||||
|             # Logging implemented in the function above | ||||
|         except errors.RequestDoesNotExist as e: | ||||
|             resp.text = "No certificate signing request for with id %s not found" % id | ||||
|             logger.warning("User %s failed to delete signing request %s from %s, reason: %s", | ||||
|                 req.context["user"], id, req.context["remote"]["addr"], e) | ||||
|             raise falcon.HTTPNotFound() | ||||
|         except ValueError as e: | ||||
|             resp.text = "No ID specified %s" % id | ||||
|             logger.warning("User %s wanted to delete invalid signing request %s from %s, reason: %s", | ||||
|                 req.context["user"], id, req.context["remote"]["addr"], e) | ||||
|             raise falcon.HTTPBadRequest() | ||||
							
								
								
									
										46
									
								
								pinecrypt/server/api/revoked.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								pinecrypt/server/api/revoked.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| import falcon | ||||
| import logging | ||||
| from pinecrypt.server import authority, const, errors | ||||
| from .utils.firewall import whitelist_subnets | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
| class RevocationListResource(object): | ||||
|     @whitelist_subnets(const.CRL_SUBNETS) | ||||
|     def on_get(self, req, resp): | ||||
|         # Primarily offer DER encoded CRL as per RFC5280 | ||||
|         # This is also what StrongSwan expects | ||||
|         if req.client_accepts("application/x-pkcs7-crl"): | ||||
|             resp.set_header("Content-Type", "application/x-pkcs7-crl") | ||||
|             resp.append_header( | ||||
|                 "Content-Disposition", | ||||
|                 ("attachment; filename=%s.crl" % const.HOSTNAME)) | ||||
|             # Convert PEM to DER | ||||
|             logger.debug("Serving revocation list (DER) to %s", req.context["remote"]["addr"]) | ||||
|             resp.text = authority.export_crl(pem=False) | ||||
|         elif req.client_accepts("application/x-pem-file"): | ||||
|             resp.set_header("Content-Type", "application/x-pem-file") | ||||
|             resp.append_header( | ||||
|                 "Content-Disposition", | ||||
|                 ("attachment; filename=%s-crl.pem" % const.HOSTNAME)) | ||||
|             logger.debug("Serving revocation list (PEM) to %s", req.context["remote"]["addr"]) | ||||
|             resp.text = authority.export_crl() | ||||
|         else: | ||||
|             logger.debug("Client %s asked revocation list in unsupported format" % req.context["remote"]["addr"]) | ||||
|             raise falcon.HTTPUnsupportedMediaType( | ||||
|                 "Client did not accept application/x-pkcs7-crl or application/x-pem-file") | ||||
|  | ||||
|  | ||||
| class RevokedCertificateDetailResource(object): | ||||
|     def on_get(self, req, resp, serial_number): | ||||
|         try: | ||||
|             cert_doc, buf = authority.get_revoked(serial_number) | ||||
|         except errors.CertificateDoesNotExist: | ||||
|             logger.warning("Failed to serve non-existant revoked certificate with serial %s to %s", | ||||
|                 serial_number, req.context["remote"]["addr"]) | ||||
|             raise falcon.HTTPNotFound() | ||||
|         resp.set_header("Content-Type", "application/x-pem-file") | ||||
|         resp.set_header("Content-Disposition", ("attachment; filename=%s.pem" % cert_doc["serial_number"])) | ||||
|         resp.text = buf | ||||
|         logger.debug("Served revoked certificate with serial %s to %s", | ||||
|             cert_doc["serial_number"], req.context["remote"]["addr"]) | ||||
							
								
								
									
										29
									
								
								pinecrypt/server/api/script.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								pinecrypt/server/api/script.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| import logging | ||||
| import os | ||||
| from pinecrypt.server import authority, const | ||||
| from jinja2 import Environment, FileSystemLoader | ||||
| from .utils.firewall import whitelist_subject | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
| env = Environment(loader=FileSystemLoader(const.SCRIPT_DIR), trim_blocks=True) | ||||
|  | ||||
| class ScriptResource(object): | ||||
|     @whitelist_subject | ||||
|     def on_get(self, req, resp, id): | ||||
|         path, buf, cert, attribs = authority.get_attributes(id) | ||||
|         # TODO: are keys unique? | ||||
|         named_tags = {} | ||||
|         other_tags = [] | ||||
|         cn = cert["common_name"] | ||||
|  | ||||
|         script = named_tags.get("script", "default.sh") | ||||
|         assert script in os.listdir(const.SCRIPT_DIR) | ||||
|         resp.set_header("Content-Type", "text/x-shellscript") | ||||
|         resp.body = env.get_template(os.path.join(script)).render( | ||||
|             authority_name=const.FQDN, | ||||
|             common_name=cn, | ||||
|             other_tags=other_tags, | ||||
|             named_tags=named_tags, | ||||
|             attributes=attribs.get("user").get("machine")) | ||||
|         logger.info("Served script %s for %s at %s" % (script, cn, req.context["remote"]["addr"])) | ||||
|         # TODO: Assert time is within reasonable range | ||||
							
								
								
									
										185
									
								
								pinecrypt/server/api/session.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								pinecrypt/server/api/session.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,185 @@ | ||||
| import hashlib | ||||
| import logging | ||||
| from pinecrypt.server import authority, const, config | ||||
| from pinecrypt.server.decorators import serialize, csrf_protection | ||||
| from pinecrypt.server.user import User | ||||
| from .utils.firewall import login_required, authorize_admin, register_session | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
|  | ||||
| class CertificateAuthorityResource(object): | ||||
|     def on_get(self, req, resp): | ||||
|         logger.info("Served CA certificate to %s", req.context["remote"]["addr"]) | ||||
|         resp.stream = open(const.AUTHORITY_CERTIFICATE_PATH, "rb") | ||||
|         resp.append_header("Content-Type", "application/x-x509-ca-cert") | ||||
|         resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % | ||||
|             const.HOSTNAME) | ||||
|  | ||||
|  | ||||
| class SessionResource(object): | ||||
|  | ||||
|     def __init__(self, manager): | ||||
|         self.token_manager = manager | ||||
|  | ||||
|     @csrf_protection | ||||
|     @serialize | ||||
|     @login_required | ||||
|     @register_session | ||||
|     @authorize_admin | ||||
|     def on_get(self, req, resp): | ||||
|         def serialize_requests(g): | ||||
|             for csr, request, server in g(): | ||||
|                 try: | ||||
|                     submission_address = request["user"]["request_address"] | ||||
|                 except KeyError: | ||||
|                     submission_address = None | ||||
|                 try: | ||||
|                     submission_hostname = request["user"]["request_hostname"] | ||||
|                 except KeyError: | ||||
|                     submission_hostname = None | ||||
|                 yield dict( | ||||
|                     id=str(request["_id"]), | ||||
|                     submitted=request["submitted"], | ||||
|                     common_name=request["common_name"], | ||||
|                     address=submission_address, | ||||
|                     hostname=submission_hostname if submission_hostname != submission_address else None, | ||||
|                     md5sum=hashlib.md5(request["request_buf"]).hexdigest(), | ||||
|                     sha1sum=hashlib.sha1(request["request_buf"]).hexdigest(), | ||||
|                     sha256sum=hashlib.sha256(request["request_buf"]).hexdigest(), | ||||
|                     sha512sum=hashlib.sha512(request["request_buf"]).hexdigest() | ||||
|                 ) | ||||
|  | ||||
|         def serialize_revoked(g): | ||||
|             for cert_obj, cert in g(limit=5): | ||||
|                 yield dict( | ||||
|                     id=str(cert_obj["_id"]), | ||||
|                     serial="%x" % cert.serial_number, | ||||
|                     common_name=cert_obj["common_name"], | ||||
|                     # TODO: key type, key length, key exponent, key modulo | ||||
|                     signed=cert_obj["signed"], | ||||
|                     expired=cert_obj["expires"], | ||||
|                     revoked=cert_obj["revoked"], | ||||
|                     reason=cert_obj["revocation_reason"], | ||||
|                     sha256sum=hashlib.sha256(cert_obj["cert_buf"]).hexdigest()) | ||||
|  | ||||
|         def serialize_certificates(g): | ||||
|             for cert_doc, cert in g(): | ||||
|                 try: | ||||
|                     tags = cert_doc["tags"] | ||||
|                 except KeyError:  # No tags | ||||
|                     tags = None | ||||
|  | ||||
|  | ||||
|                 # TODO: Load attributes from databse | ||||
|                 attributes = {} | ||||
|  | ||||
|                 try: | ||||
|                     lease = dict( | ||||
|                         inner_address=cert_doc["ip"], | ||||
|                         outer_address=cert_doc["remote"]["addr"], | ||||
|                         last_seen=cert_doc["last_seen"], | ||||
|                     ) | ||||
|                 except KeyError: # No such attribute(s) | ||||
|                     lease = None | ||||
|  | ||||
|                 try: | ||||
|                     #signer_username = getxattr(path, "user.signature.username").decode("ascii") | ||||
|                     signer_username = cert_doc["user"]["signature"]["username"] | ||||
|                 except KeyError: | ||||
|                     signer_username = None | ||||
|  | ||||
|                 # TODO: dedup | ||||
|                 serialized = dict( | ||||
|                     id=str(cert_doc["_id"]), | ||||
|                     serial="%x" % cert.serial_number, | ||||
|                     organizational_unit=cert.subject.native.get("organizational_unit_name"), | ||||
|                     common_name=cert_doc["common_name"], | ||||
|                     # TODO: key type, key length, key exponent, key modulo | ||||
|                     signed=cert_doc["signed"], | ||||
|                     expires=cert_doc["expires"], | ||||
|                     sha256sum=hashlib.sha256(cert_doc["cert_buf"]).hexdigest(), | ||||
|                     signer=signer_username, | ||||
|                     lease=lease, | ||||
|                     tags=tags, | ||||
|                     attributes=attributes or None, | ||||
|                     responder_url=None | ||||
|                 ) | ||||
|  | ||||
|                 for e in cert["tbs_certificate"]["extensions"].native: | ||||
|                     if e["extn_id"] == "key_usage": | ||||
|                         serialized["key_usage"] = e["extn_value"] | ||||
|                     elif e["extn_id"] == "extended_key_usage": | ||||
|                         serialized["extended_key_usage"] = e["extn_value"] | ||||
|                     elif e["extn_id"] == "basic_constraints": | ||||
|                         serialized["basic_constraints"] = e["extn_value"] | ||||
|                     elif e["extn_id"] == "crl_distribution_points": | ||||
|                         for c in e["extn_value"]: | ||||
|                             serialized["revoked_url"] = c["distribution_point"] | ||||
|                             break | ||||
|                         serialized["extended_key_usage"] = e["extn_value"] | ||||
|                     elif e["extn_id"] == "authority_information_access": | ||||
|                         for a in e["extn_value"]: | ||||
|                             if a["access_method"] == "ocsp": | ||||
|                                 serialized["responder_url"] = a["access_location"] | ||||
|                             else: | ||||
|                                 raise NotImplementedError("Don't know how to handle AIA access method %s" % a["access_method"]) | ||||
|                     elif e["extn_id"] == "authority_key_identifier": | ||||
|                         pass | ||||
|                     elif e["extn_id"] == "key_identifier": | ||||
|                         pass | ||||
|                     elif e["extn_id"] == "subject_alt_name": | ||||
|                         serialized["subject_alt_name"] = e["extn_value"][0] | ||||
|                     else: | ||||
|                         raise NotImplementedError("Don't know how to handle extension %s" % e["extn_id"]) | ||||
|                 yield serialized | ||||
|  | ||||
|         logger.info("Logged in authority administrator %s from %s with %s" % ( | ||||
|             req.context.get("user"), req.context["remote"]["addr"], req.context["remote"]["user_agent"])) | ||||
|         return dict( | ||||
|             user=dict( | ||||
|                 name=req.context.get("user").name, | ||||
|                 gn=req.context.get("user").given_name, | ||||
|                 sn=req.context.get("user").surname, | ||||
|                 mail=req.context.get("user").mail | ||||
|             ), | ||||
|             request_submission_allowed=const.REQUEST_SUBMISSION_ALLOWED, | ||||
|             service=dict( | ||||
|                 protocols=const.SERVICE_PROTOCOLS, | ||||
|             ), | ||||
|             builder=dict( | ||||
|                 profiles=const.IMAGE_BUILDER_PROFILES or None | ||||
|             ), | ||||
|             tokens=self.token_manager.list() if self.token_manager else None, | ||||
|             tagging=[dict(name=t[0], type=t[1], title=t[0]) for t in const.TAG_TYPES], | ||||
|  | ||||
|             mailer=dict( | ||||
|                name=const.SMTP_SENDER_NAME, | ||||
|                address=const.SMTP_SENDER_ADDR | ||||
|             ) if const.SMTP_SENDER_ADDR else None, | ||||
|             events="/api/event/", | ||||
|             requests=serialize_requests(authority.list_requests), | ||||
|             signed=serialize_certificates(authority.list_signed), | ||||
|             revoked=serialize_revoked(authority.list_revoked), | ||||
|             signature=dict( | ||||
|                 revocation_list_lifetime=const.REVOCATION_LIST_LIFETIME, | ||||
|                 profiles=config.options("SignatureProfile"), | ||||
|             ), | ||||
|             authorization=dict( | ||||
|                 admin_users=User.objects.filter_admins(), | ||||
|  | ||||
|                 user_subnets=const.USER_SUBNETS or None, | ||||
|                 autosign_subnets=const.AUTOSIGN_SUBNETS or None, | ||||
|                 request_subnets=const.REQUEST_SUBNETS or None, | ||||
|                 machine_enrollment_subnets=const.MACHINE_ENROLLMENT_SUBNETS or None, | ||||
|                 admin_subnets=const.ADMIN_SUBNETS or None, | ||||
|  | ||||
|                 ocsp_subnets=const.OCSP_SUBNETS or None, | ||||
|                 crl_subnets=const.CRL_SUBNETS or None, | ||||
|             ), | ||||
|             features=dict( | ||||
|                 token=True, | ||||
|                 tagging=True, | ||||
|                 leases=True, | ||||
|                 logging=True) | ||||
|         ) | ||||
							
								
								
									
										84
									
								
								pinecrypt/server/api/signed.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								pinecrypt/server/api/signed.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
|  | ||||
| import falcon | ||||
| import logging | ||||
| import json | ||||
| import hashlib | ||||
| from pinecrypt.server import authority, errors | ||||
| from pinecrypt.server.decorators import csrf_protection | ||||
| from .utils.firewall import login_required, authorize_admin | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
| class SignedCertificateDetailResource(object): | ||||
|     def on_get_cn(self, req, resp, cn): | ||||
|         try: | ||||
|             id = authority.get_common_name_id(cn) | ||||
|         except ValueError: | ||||
|             raise falcon.HTTPNotFound("Unknown Common name", | ||||
|             "Object not found with common name %s" % cn) | ||||
|  | ||||
|         id = authority.get_common_name_id(cn) | ||||
|         url = req.forwarded_uri.replace(cn,"id/%s" % id) | ||||
|  | ||||
|         resp.status = falcon.HTTP_307 | ||||
|         resp.location = url | ||||
|  | ||||
|  | ||||
|     def on_get(self, req, resp, id): | ||||
|         preferred_type = req.client_prefers(("application/json", "application/x-pem-file")) | ||||
|         try: | ||||
|             cert, cert_doc, pem_buf = authority.get_signed(mongo_id=id) | ||||
|         except errors.CertificateDoesNotExist: | ||||
|             logger.warning("Failed to serve non-existant certificate %s to %s", | ||||
|                 id, req.context["remote"]["addr"]) | ||||
|             raise falcon.HTTPNotFound() | ||||
|  | ||||
|         cn = cert_doc["common_name"] | ||||
|  | ||||
|         if preferred_type == "application/x-pem-file": | ||||
|             resp.set_header("Content-Type", "application/x-pem-file") | ||||
|             resp.set_header("Content-Disposition", ("attachment; filename=%s.pem" % cn)) | ||||
|             resp.text = pem_buf | ||||
|             logger.debug("Served certificate %s to %s as application/x-pem-file", | ||||
|                 cn, req.context["remote"]["addr"]) | ||||
|         elif preferred_type == "application/json": | ||||
|             resp.set_header("Content-Type", "application/json") | ||||
|             resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % cn)) | ||||
|             try: | ||||
|                 signer_username = cert_doc["user"]["signature"]["username"] | ||||
|             except KeyError: | ||||
|                 signer_username = None | ||||
|  | ||||
|             resp.text = json.dumps(dict( | ||||
|                 common_name=cn, | ||||
|                 id=str(cert_doc["_id"]), | ||||
|                 signer=signer_username, | ||||
|                 serial="%040x" % cert.serial_number, | ||||
|                 organizational_unit=cert.subject.native.get("organizational_unit_name"), | ||||
|                 signed=cert["tbs_certificate"]["validity"]["not_before"].native.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", | ||||
|                 expires=cert["tbs_certificate"]["validity"]["not_after"].native.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", | ||||
|                 sha256sum=hashlib.sha256(pem_buf).hexdigest(), | ||||
|                 attributes=None, | ||||
|                 lease=None, | ||||
|                 extensions=dict([ | ||||
|                     (e["extn_id"].native, e["extn_value"].native) | ||||
|                     for e in cert["tbs_certificate"]["extensions"] | ||||
|                     if e["extn_id"].native in ("extended_key_usage",)]) | ||||
|  | ||||
|             )) | ||||
|             logger.debug("Served certificate %s to %s as application/json", | ||||
|                 cn, req.context["remote"]["addr"]) | ||||
|         else: | ||||
|             logger.debug("Client did not accept application/json or application/x-pem-file") | ||||
|             raise falcon.HTTPUnsupportedMediaType( | ||||
|                 "Client did not accept application/json or application/x-pem-file") | ||||
|  | ||||
|     @csrf_protection | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_delete(self, req, resp, id): | ||||
|         authority.revoke(id, | ||||
|             reason=req.get_param("reason", default="key_compromise"), | ||||
|             user=req.context.get("user") | ||||
|         ) | ||||
|  | ||||
							
								
								
									
										64
									
								
								pinecrypt/server/api/tag.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								pinecrypt/server/api/tag.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| from pinecrypt.server import db | ||||
| from pinecrypt.server.decorators import serialize, csrf_protection | ||||
| from .utils.firewall import login_required, authorize_admin | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
| class TagResource(object): | ||||
|     @serialize | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_get(self, req, resp, id): | ||||
|         tags = db.certificates.find_one({"_id": db.ObjectId(id), "status": "signed"}).get("tags") | ||||
|         return tags | ||||
|  | ||||
|     @csrf_protection | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_post(self, req, resp, id): | ||||
|         # TODO: Sanitize input | ||||
|         key, value = req.get_param("key", required=True), req.get_param("value", required=True) | ||||
|         db.certificates.update_one({ | ||||
|             "_id": db.ObjectId(id), | ||||
|             "status": "signed" | ||||
|         }, { | ||||
|             "$addToSet": {"tags": "%s=%s" % (key, value)} | ||||
|         }) | ||||
|  | ||||
|  | ||||
| class TagDetailResource(object): | ||||
|     @csrf_protection | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_put(self, req, resp, id, tag): | ||||
|         key = tag | ||||
|         if "=" in tag: | ||||
|             key, prev_value = tag.split("=") | ||||
|  | ||||
|         value = req.get_param("value", required=True) | ||||
|         # TODO: Make atomic https://docs.mongodb.com/manual/reference/operator/update-array/ | ||||
|         db.certificates.find_one_and_update({ | ||||
|             "_id": db.ObjectId(id), | ||||
|             "status": "signed" | ||||
|         }, { | ||||
|             "$pull": {"tags": tag} | ||||
|         }) | ||||
|  | ||||
|         db.certificates.find_one_and_update({ | ||||
|             "_id": db.ObjectId(id), | ||||
|             "status": "signed" | ||||
|         }, { | ||||
|             "$addToSet": {"tags": "%s=%s" % (key, value)} | ||||
|         }) | ||||
|  | ||||
|  | ||||
|     @csrf_protection | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_delete(self, req, resp, id, tag): | ||||
|         db.certificates.find_one_and_update({ | ||||
|             "_id": db.ObjectId(id), | ||||
|             "status": "signed" | ||||
|         }, { | ||||
|             "$pull": {"tags": tag} | ||||
|         }) | ||||
							
								
								
									
										66
									
								
								pinecrypt/server/api/token.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								pinecrypt/server/api/token.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | ||||
| import falcon | ||||
| import logging | ||||
| import re | ||||
| from asn1crypto import pem | ||||
| from asn1crypto.csr import CertificationRequest | ||||
| from pinecrypt.server import const, errors, authority | ||||
| from pinecrypt.server.decorators import serialize | ||||
| from pinecrypt.server.user import User | ||||
| from .utils.firewall import login_required, authorize_admin | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
| class TokenResource(object): | ||||
|     def __init__(self, manager): | ||||
|         self.manager = manager | ||||
|  | ||||
|     def on_put(self, req, resp): | ||||
|         try: | ||||
|             username, mail, created, expires, profile = self.manager.consume(req.get_param("token", required=True)) | ||||
|         except errors.TokenDoesNotExist: | ||||
|             raise falcon.HTTPForbidden("Forbidden", "No such token or token expired") | ||||
|         body = req.stream.read(req.content_length) | ||||
|         header, _, der_bytes = pem.unarmor(body) | ||||
|         csr = CertificationRequest.load(der_bytes) | ||||
|  | ||||
|         try: | ||||
|             common_name = csr["certification_request_info"]["subject"].native["common_name"] | ||||
|         except KeyError: | ||||
|             logger.info("Malformed certificate signing request without common name token submitted from %s" % req.context["remote"]["addr"]) | ||||
|             raise falcon.HTTPBadRequest(title="Bad request",description="Common name missing from certificate signing request token") | ||||
|  | ||||
|         if not re.match(const.RE_COMMON_NAME, common_name): | ||||
|             raise falcon.HTTPBadRequest("Bad request", "Invalid common name %s" % common_name) | ||||
|  | ||||
|         try: | ||||
|             mongo_doc = authority.store_request(body, overwrite=const.TOKEN_OVERWRITE_PERMITTED, | ||||
|                namespace="%s.%s" % (username, const.USER_NAMESPACE), address=str(req.context["remote"]["addr"])) | ||||
|             _, resp.text = authority.sign(mongo_id=str(mongo_doc["_id"]), profile=profile, | ||||
|                 overwrite=const.TOKEN_OVERWRITE_PERMITTED, | ||||
|                 namespace="%s.%s" % (username, const.USER_NAMESPACE)) | ||||
|             resp.set_header("Content-Type", "application/x-pem-file") | ||||
|             logger.info("Autosigned %s as proven by token ownership", common_name) | ||||
|         except errors.DuplicateCommonNameError: | ||||
|             logger.info("Another request with same common name already exists", common_name) | ||||
|             raise falcon.HTTPConflict( | ||||
|                 title="CSR with such common name (CN) already exists", | ||||
|                 description="Will not overwrite existing certificate signing request, explicitly delete existing one and try again") | ||||
|         except FileExistsError: | ||||
|             logger.info("Won't autosign duplicate %s", common_name) | ||||
|             raise falcon.HTTPConflict( | ||||
|                 "Certificate with such common name (CN) already exists", | ||||
|                 "Will not overwrite existing certificate signing request, explicitly delete existing one and try again") | ||||
|  | ||||
|  | ||||
|     @serialize | ||||
|     @login_required | ||||
|     @authorize_admin | ||||
|     def on_post(self, req, resp): | ||||
|         username = req.get_param("username", required=True) | ||||
|         if not re.match(const.RE_USERNAME, username): | ||||
|             raise falcon.HTTPBadRequest("Bad request", "Invalid username") | ||||
|         # TODO: validate e-mail | ||||
|         self.manager.issue( | ||||
|             issuer=req.context.get("user"), | ||||
|             subject=User.objects.get(username), | ||||
|             subject_mail=req.get_param("mail")) | ||||
							
								
								
									
										307
									
								
								pinecrypt/server/api/utils/firewall.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										307
									
								
								pinecrypt/server/api/utils/firewall.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,307 @@ | ||||
|  | ||||
| import falcon | ||||
| import logging | ||||
| import binascii | ||||
| import click | ||||
| import gssapi | ||||
| import ldap | ||||
| import os | ||||
| import random | ||||
| import string | ||||
| from asn1crypto import pem, x509 | ||||
| from base64 import b64decode | ||||
| from falcon.util import http_date_to_dt | ||||
| from datetime import datetime, timedelta | ||||
| from pinecrypt.server.user import User | ||||
| from pinecrypt.server import const, errors, db | ||||
| from prometheus_client import Counter, Histogram | ||||
|  | ||||
| clock_skew = Histogram("pinecrypt_authority_clock_skew", | ||||
|     "Histogram of client-server clock skew", ["method", "path", "passed"], | ||||
|     buckets=(0.1, 0.5, 1.0, 5.0, 10.0, 50.0, 100.0, 500.0, 1000.0, 5000.0)) | ||||
| whitelist_blocked_requests = Counter("pinecrypt_authority_whitelist_blocked_requests", | ||||
|     "Requests blocked by whitelists", ["method", "path"]) | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
|  | ||||
| def whitelist_subnets(subnets): | ||||
|     """ | ||||
|     Validate source IP address of API call against subnet list | ||||
|     """ | ||||
|     def wrapper(func): | ||||
|         def wrapped(self, req, resp, *args, **kwargs): | ||||
|             # Check for administration subnet whitelist | ||||
|             for subnet in subnets: | ||||
|                 if req.context["remote"]["addr"] in subnet: | ||||
|                     break | ||||
|             else: | ||||
|                 logger.info("Rejected access to administrative call %s by %s from %s, source address not whitelisted", | ||||
|                     req.env["PATH_INFO"], | ||||
|                     req.context.get("user", "unauthenticated user"), | ||||
|                     req.context["remote"]["addr"]) | ||||
|                 whitelist_blocked_requests.labels(method=req.method, path=req.path).inc() | ||||
|                 raise falcon.HTTPForbidden("Forbidden", "Remote address %s not whitelisted" % req.context["remote"]["addr"]) | ||||
|  | ||||
|             return func(self, req, resp, *args, **kwargs) | ||||
|         return wrapped | ||||
|     return wrapper | ||||
|  | ||||
|  | ||||
| def whitelist_content_types(*content_types): | ||||
|     def wrapper(func): | ||||
|         def wrapped(self, req, resp, *args, **kwargs): | ||||
|             for content_type in content_types: | ||||
|                 if req.get_header("Content-Type") == content_type: | ||||
|                     return func(self, req, resp, *args, **kwargs) | ||||
|             raise falcon.HTTPUnsupportedMediaType( | ||||
|                 "This API call accepts only %s content type" % ", ".join(content_types)) | ||||
|         return wrapped | ||||
|     return wrapper | ||||
|  | ||||
|  | ||||
| def whitelist_subject(func): | ||||
|     def wrapped(self, req, resp, id, *args, **kwargs): | ||||
|         from pinecrypt.server import authority | ||||
|         try: | ||||
|             cert, cert_doc, pem_buf = authority.get_signed(id) | ||||
|         except errors.CertificateDoesNotExist: | ||||
|             raise falcon.HTTPNotFound() | ||||
|         else: | ||||
|             buf = req.get_header("X-SSL-CERT") | ||||
|             if buf: | ||||
|                 header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii")) | ||||
|                 origin_cert = x509.Certificate.load(der_bytes) | ||||
|                 if origin_cert.native == cert.native: | ||||
|                     logger.debug("Subject authenticated using certificates") | ||||
|                     return func(self, req, resp, id, *args, **kwargs) | ||||
|             raise falcon.HTTPForbidden("Forbidden", "Remote address %s not whitelisted" % req.context["remote"]["addr"]) | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| def authenticate(optional=False): | ||||
|     def wrapper(func): | ||||
|         def wrapped(resource, req, resp, *args, **kwargs): | ||||
|             kerberized = False | ||||
|  | ||||
|             if "kerberos" in const.AUTHENTICATION_BACKENDS: | ||||
|                 for subnet in const.KERBEROS_SUBNETS: | ||||
|                     if req.context["remote"]["addr"] in subnet: | ||||
|                         kerberized = True | ||||
|  | ||||
|             if not req.auth: # no credentials provided | ||||
|                 if optional: # optional allowed | ||||
|                     req.context["user"] = None | ||||
|                     return func(resource, req, resp, *args, **kwargs) | ||||
|  | ||||
|                 if kerberized: | ||||
|                     logger.debug("No Kerberos ticket offered while attempting to access %s from %s", | ||||
|                         req.env["PATH_INFO"], req.context["remote"]["addr"]) | ||||
|                     raise falcon.HTTPUnauthorized("Unauthorized", | ||||
|                         "No Kerberos ticket offered, are you sure you've logged in with domain user account?", | ||||
|                         ["Negotiate"]) | ||||
|                 else: | ||||
|                     logger.debug("No credentials offered while attempting to access %s from %s", | ||||
|                         req.env["PATH_INFO"], req.context["remote"]["addr"]) | ||||
|                     #falcon 3.0 login fix | ||||
|                     raise falcon.HTTPUnauthorized(title="Unauthorized", description="Please authenticate", challenges=("Basic",)) | ||||
|  | ||||
|             if kerberized: | ||||
|                 if not req.auth.startswith("Negotiate "): | ||||
|                     raise falcon.HTTPUnauthorized("Unauthorized", | ||||
|                         "Bad header, expected Negotiate", ["Negotiate"]) | ||||
|  | ||||
|                 os.environ["KRB5_KTNAME"] = const.KERBEROS_KEYTAB | ||||
|  | ||||
|                 try: | ||||
|                     server_creds = gssapi.creds.Credentials( | ||||
|                         usage="accept", | ||||
|                         name=gssapi.names.Name("HTTP/%s" % const.FQDN)) | ||||
|                 except gssapi.raw.exceptions.BadNameError: | ||||
|                     logger.error("Failed initialize HTTP service principal, possibly bad permissions for %s or /etc/krb5.conf" % | ||||
|                         const.KERBEROS_KEYTAB) | ||||
|                     raise | ||||
|  | ||||
|                 context = gssapi.sec_contexts.SecurityContext(creds=server_creds) | ||||
|  | ||||
|                 token = "".join(req.auth.split()[1:]) | ||||
|  | ||||
|                 try: | ||||
|                     context.step(b64decode(token)) | ||||
|                 except binascii.Error: | ||||
|                     # base64 errors | ||||
|                     raise falcon.HTTPBadRequest(title="Bad request", description="Malformed token") | ||||
|                 except gssapi.raw.exceptions.BadMechanismError: | ||||
|                     raise falcon.HTTPBadRequest(title="Bad request", description=""" | ||||
|                         Unsupported authentication mechanism (NTLM?) was offered. | ||||
|                         Please make sure you've logged into the computer with domain user account. | ||||
|                         The web interface should not prompt for username or password.""") | ||||
|  | ||||
|                 try: | ||||
|                     username, realm = str(context.initiator_name).split("@") | ||||
|                 except AttributeError: | ||||
|                     # TODO: Better exception handling | ||||
|                     raise falcon.HTTPForbidden("Failed to determine username, are you trying to log in with correct domain account?") | ||||
|  | ||||
|                 assert const.KERBEROS_REALM, "KERBEROS_REALM not configured" | ||||
|                 if realm != const.KERBEROS_REALM: | ||||
|                     raise falcon.HTTPForbidden("Forbidden", | ||||
|                         "Cross-realm trust not supported") | ||||
|  | ||||
|                 if username.endswith("$") and optional: | ||||
|                     # Extract machine hostname | ||||
|                     # TODO: Assert LDAP group membership | ||||
|                     req.context["machine"] = username[:-1].lower() | ||||
|                     req.context["user"] = None | ||||
|                 else: | ||||
|                     # Attempt to look up real user | ||||
|                     req.context["user"] = User.objects.get(username) | ||||
|  | ||||
|                 logger.debug("Succesfully authenticated user %s for %s from %s", | ||||
|                     req.context["user"], req.env["PATH_INFO"], req.context["remote"]["addr"]) | ||||
|                 return func(resource, req, resp, *args, **kwargs) | ||||
|  | ||||
|             else: | ||||
|                 if not req.auth.startswith("Basic "): | ||||
|                     raise falcon.HTTPUnauthorized("Forbidden", "Bad header, expected Basic", ("Basic",)) | ||||
|  | ||||
|                 basic, token = req.auth.split(" ", 1) | ||||
|                 user, passwd = b64decode(token).decode("utf-8").split(":", 1) | ||||
|  | ||||
|             if "ldap" in const.AUTHENTICATION_BACKENDS: | ||||
|                 upn = "%s@%s" % (user, const.KERBEROS_REALM) | ||||
|                 click.echo("Connecting to %s as %s" % (const.LDAP_AUTHENTICATION_URI, upn)) | ||||
|                 conn = ldap.initialize(const.LDAP_AUTHENTICATION_URI, bytes_mode=False) | ||||
|                 conn.set_option(ldap.OPT_REFERRALS, 0) | ||||
|  | ||||
|                 try: | ||||
|                     conn.simple_bind_s(upn, passwd) | ||||
|                 except ldap.STRONG_AUTH_REQUIRED: | ||||
|                     logger.critical("LDAP server demands encryption, use ldaps:// instead of ldap://") | ||||
|                     raise | ||||
|                 except ldap.SERVER_DOWN: | ||||
|                     logger.critical("Failed to connect LDAP server at %s, are you sure LDAP server's CA certificate has been copied to this machine?", | ||||
|                         const.LDAP_AUTHENTICATION_URI) | ||||
|                     raise | ||||
|                 except ldap.INVALID_CREDENTIALS: | ||||
|                     logger.critical("LDAP bind authentication failed for user %s from  %s", | ||||
|                         repr(user), req.context["remote"]["addr"]) | ||||
|                     raise falcon.HTTPUnauthorized( | ||||
|                         description="Please authenticate with %s domain account username" % const.DOMAIN, | ||||
|                         challenges=["Basic"]) | ||||
|  | ||||
|                 req.context["ldap_conn"] = conn | ||||
|             else: | ||||
|                 raise NotImplementedError("No suitable authentication method configured") | ||||
|  | ||||
|             try: | ||||
|                 req.context["user"] = User.objects.get(user) | ||||
|             except User.DoesNotExist: | ||||
|                 raise falcon.HTTPUnauthorized("Unauthorized", "Invalid credentials", ("Basic",)) | ||||
|  | ||||
|             retval = func(resource, req, resp, *args, **kwargs) | ||||
|             if conn: | ||||
|                 conn.unbind_s() | ||||
|             return retval | ||||
|         return wrapped | ||||
|     return wrapper | ||||
|  | ||||
|  | ||||
| def login_required(func): | ||||
|     return authenticate()(func) | ||||
|  | ||||
| def login_optional(func): | ||||
|     return authenticate(optional=True)(func) | ||||
|  | ||||
| def authorize_admin(func): | ||||
|     @whitelist_subnets(const.ADMIN_SUBNETS) | ||||
|     def wrapped(resource, req, resp, *args, **kwargs): | ||||
|         if req.context.get("user").is_admin(): | ||||
|             return func(resource, req, resp, *args, **kwargs) | ||||
|         logger.info("User '%s' not authorized to access administrative API", req.context.get("user").name) | ||||
|         raise falcon.HTTPForbidden("Forbidden", "User not authorized to perform administrative operations") | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| def authorize_server(func): | ||||
|     """ | ||||
|     Make sure the request originator has a certificate with server flags | ||||
|     """ | ||||
|     from asn1crypto import pem, x509 | ||||
|     def wrapped(resource, req, resp, *args, **kwargs): | ||||
|         buf = req.get_header("X-SSL-CERT") | ||||
|         if not buf: | ||||
|             logger.info("No TLS certificate presented to access administrative API call from %s" % req.context["remote"]["addr"]) | ||||
|             raise falcon.HTTPForbidden("Forbidden", "Machine not authorized to perform the operation") | ||||
|  | ||||
|         header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii")) | ||||
|         cert = x509.Certificate.load(der_bytes) | ||||
|         # TODO: validate serial | ||||
|         for extension in cert["tbs_certificate"]["extensions"]: | ||||
|             if extension["extn_id"].native == "extended_key_usage": | ||||
|                 if "server_auth" in extension["extn_value"].native: | ||||
|                     req.context["machine"] = cert.subject.native["common_name"] | ||||
|                     return func(resource, req, resp, *args, **kwargs) | ||||
|         logger.info("TLS authenticated machine '%s' not authorized to access administrative API", cert.subject.native["common_name"]) | ||||
|         raise falcon.HTTPForbidden("Forbidden", "Machine not authorized to perform the operation") | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| def validate_clock_skew(func): | ||||
|     def wrapped(resource, req, resp, *args, **kwargs): | ||||
|         try: | ||||
|             skew = abs((http_date_to_dt(req.headers["DATE"]) - datetime.utcnow())) | ||||
|         except KeyError: | ||||
|             raise falcon.HTTPBadRequest(title="Bad request", description="No date information specified in header") | ||||
|  | ||||
|         passed = skew < const.CLOCK_SKEW_TOLERANCE | ||||
|         clock_skew.labels(method=req.method, path=req.path, passed=int(passed)).observe(skew.total_seconds()) | ||||
|         if passed: | ||||
|             return func(resource, req, resp, *args, **kwargs) | ||||
|         else: | ||||
|             raise falcon.HTTPBadRequest(title="Bad request", description="Clock skew too large") | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| def cookie_login(func): | ||||
|     def wrapped(resource, req, resp, *args, **kwargs): | ||||
|         now = datetime.utcnow() | ||||
|         value = req.get_cookie_values(const.SESSION_COOKIE) | ||||
|         db.sessions.update_one({ | ||||
|             "secret": value, | ||||
|             "started": { | ||||
|                 "$lte": now | ||||
|             }, | ||||
|             "expires": { | ||||
|                 "$gte": now | ||||
|             }, | ||||
|         }, { | ||||
|             "$set": { | ||||
|                 "last_seen": now, | ||||
|            } | ||||
|         }) | ||||
|         return func(resource, req, resp, *args, **kwargs) | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| def generate_password(length): | ||||
|     letters = string.ascii_lowercase + string.ascii_uppercase + string.digits | ||||
|     return "".join(random.choice(letters) for i in range(length)) | ||||
|  | ||||
|  | ||||
| def register_session(func): | ||||
|     def wrapped(resource, req, resp, *args, **kwargs): | ||||
|         now = datetime.utcnow() | ||||
|         value = generate_password(50) | ||||
|         db.sessions.insert({ | ||||
|             "user": req.context["user"].name, | ||||
|             "secret": value, | ||||
|             "last_seen": now, | ||||
|             "started": now, | ||||
|             "expires": now + timedelta(seconds=const.SESSION_AGE), | ||||
|             "remote": str(req.context["remote"]), | ||||
|         }) | ||||
|         resp.set_cookie(const.SESSION_COOKIE, value, | ||||
|             max_age=const.SESSION_AGE) | ||||
|         return func(resource, req, resp, *args, **kwargs) | ||||
|     return wrapped | ||||
							
								
								
									
										450
									
								
								pinecrypt/server/authority.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										450
									
								
								pinecrypt/server/authority.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,450 @@ | ||||
| import click | ||||
| import logging | ||||
| import os | ||||
| import re | ||||
| import socket | ||||
| import pytz | ||||
| from oscrypto import asymmetric | ||||
| from asn1crypto import pem, x509 | ||||
| from asn1crypto.csr import CertificationRequest | ||||
| from certbuilder import CertificateBuilder | ||||
| from pinecrypt.server import mailer, const, errors, config, db | ||||
| from pinecrypt.server.common import cn_to_dn, generate_serial, cert_to_dn | ||||
| from crlbuilder import CertificateListBuilder, pem_armor_crl | ||||
| from csrbuilder import CSRBuilder, pem_armor_csr | ||||
| from datetime import datetime, timedelta | ||||
| from bson.objectid import ObjectId | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
| # Cache CA certificate | ||||
| with open(const.AUTHORITY_CERTIFICATE_PATH, "rb") as fh: | ||||
|     certificate_buf = fh.read() | ||||
|     header, _, certificate_der_bytes = pem.unarmor(certificate_buf) | ||||
|     certificate = x509.Certificate.load(certificate_der_bytes) | ||||
|     public_key = asymmetric.load_public_key(certificate["tbs_certificate"]["subject_public_key_info"]) | ||||
|  | ||||
| with open(const.AUTHORITY_PRIVATE_KEY_PATH, "rb") as fh: | ||||
|     key_buf = fh.read() | ||||
|     header, _, key_der_bytes = pem.unarmor(key_buf) | ||||
|     private_key = asymmetric.load_private_key(key_der_bytes) | ||||
|  | ||||
|  | ||||
| def self_enroll(skip_notify=False): | ||||
|     common_name = const.HOSTNAME | ||||
|  | ||||
|     try: | ||||
|         cert, cert_doc, pem_buf = get_signed(common_name=common_name,namespace=const.AUTHORITY_NAMESPACE) | ||||
|         self_public_key = asymmetric.load_public_key(cert["tbs_certificate"]["subject_public_key_info"]) | ||||
|         private_key = asymmetric.load_private_key(const.SELF_KEY_PATH) | ||||
|     except (NameError, FileNotFoundError, errors.CertificateDoesNotExist) as error:  # certificate or private key not found | ||||
|         click.echo("Generating private key for frontend: %s" % const.SELF_KEY_PATH) | ||||
|         with open(const.SELF_KEY_PATH, 'wb') as fh: | ||||
|             if public_key.algorithm == "ec": | ||||
|                 self_public_key, private_key = asymmetric.generate_pair("ec", curve=public_key.curve) | ||||
|             elif public_key.algorithm == "rsa": | ||||
|                 self_public_key, private_key = asymmetric.generate_pair("rsa", bit_size=public_key.bit_size) | ||||
|             else: | ||||
|                 raise NotImplemented("CA certificate public key algorithm %s not supported" % public_key.algorithm) | ||||
|             fh.write(asymmetric.dump_private_key(private_key, None)) | ||||
|     else: | ||||
|         now = datetime.utcnow().replace(tzinfo=pytz.UTC) | ||||
|         if now + timedelta(days=1) < cert_doc["expires"].replace(tzinfo=pytz.UTC) and os.path.exists(const.SELF_CERT_PATH): | ||||
|             click.echo("Self certificate still valid, delete to self-enroll again") | ||||
|             return | ||||
|  | ||||
|  | ||||
|     builder = CSRBuilder({"common_name": common_name}, self_public_key) | ||||
|     request = builder.build(private_key) | ||||
|  | ||||
|     now = datetime.utcnow().replace(tzinfo=pytz.UTC) | ||||
|  | ||||
|     d ={} | ||||
|     d["submitted"] = now | ||||
|     d["common_name"] = common_name | ||||
|     d["request_buf"] = request.dump() | ||||
|     d["status"] = "csr" | ||||
|     d["user"] = {} | ||||
|  | ||||
|     doc = db.certificates.find_one_and_update({ | ||||
|         "common_name":d["common_name"] | ||||
|     }, { | ||||
|         "$set": d, | ||||
|         "$setOnInsert": { | ||||
|             "created": now, | ||||
|             "ip": [], | ||||
|        }}, | ||||
|         upsert=True, | ||||
|         return_document=db.return_new) | ||||
|  | ||||
|     id = str(doc.get("_id")) | ||||
|     cert, buf = sign(mongo_id=id, skip_notify=skip_notify, overwrite=True, | ||||
|         profile="Gateway", namespace=const.AUTHORITY_NAMESPACE) | ||||
|  | ||||
|     with open(const.SELF_CERT_PATH + ".part", "wb") as fh: | ||||
|         fh.write(buf) | ||||
|     os.rename(const.SELF_CERT_PATH + ".part", const.SELF_CERT_PATH) | ||||
|  | ||||
|  | ||||
| def get_common_name_id(cn): | ||||
|     cn = cn.lower() | ||||
|     doc = db.certificates.find_one({"common_name": cn}) | ||||
|  | ||||
|     if not doc: | ||||
|         raise ValueError("Object not found with common name %s" % cn) | ||||
|  | ||||
|     return str(doc["_id"]) | ||||
|  | ||||
| def list_revoked(limit=0): | ||||
|     # TODO: sort recent to oldest | ||||
|     for cert_revoked_doc in db.certificates.find({"status": "revoked"}): | ||||
|         cert = x509.Certificate.load(cert_revoked_doc["cert_buf"]) | ||||
|         yield cert_revoked_doc, cert | ||||
|         if limit:  # TODO: Use mongo for this | ||||
|             limit -= 1 | ||||
|             if limit <= 0: | ||||
|                 return | ||||
|  | ||||
| # TODO: it should be possible to regex search common_name directly from mongodb | ||||
| def list_signed(common_name=None): | ||||
|     for cert_doc in db.certificates.find({"status" : "signed"}): | ||||
|         if common_name: | ||||
|             if common_name.startswith("^"): | ||||
|                 if not re.match(common_name, cert_doc["common_name"]): | ||||
|                     continue | ||||
|             else: | ||||
|                 if common_name != cert_doc["common_name"]: | ||||
|                     continue | ||||
|         cert = x509.Certificate.load(cert_doc["cert_buf"]) | ||||
|         yield cert_doc, cert | ||||
|  | ||||
| def list_requests(): | ||||
|     for request in db.certificates.find({"status": "csr"}): | ||||
|         csr = CertificationRequest.load(request["request_buf"]) | ||||
|         yield csr, request, "." in request["common_name"] | ||||
|  | ||||
| def list_replicas(): | ||||
|     """ | ||||
|     Return list of Mongo objects referring to all active replicas | ||||
|     """ | ||||
|     for doc in db.certificates.find({"status" : "signed", "profile.ou": "Gateway"}): | ||||
|         yield doc | ||||
|  | ||||
| def get_ca_cert(): | ||||
|     fh = open(const.AUTHORITY_CERTIFICATE_PATH, "rb") | ||||
|     server_certificate = asymmetric.load_certificate(fh.read()) | ||||
|     fh.close() | ||||
|     return server_certificate | ||||
|  | ||||
| def get_request(id): | ||||
|     if not id: | ||||
|         raise ValueError("Invalid id parameter %s" % id) | ||||
|  | ||||
|     csr_doc = db.certificates.find_one({"_id": ObjectId(id), "status": "csr"}) | ||||
|  | ||||
|     if not csr_doc: | ||||
|        raise errors.RequestDoesNotExist("Certificate signing request with id %s does not exist" % id) | ||||
|  | ||||
|     csr = CertificationRequest.load(csr_doc["request_buf"]) | ||||
|     return csr, csr_doc, pem_armor_csr(csr) | ||||
|  | ||||
| def get_by_serial(serial): | ||||
|     serial_string = "%x" % serial | ||||
|     query = {"serial_number": serial_string} | ||||
|  | ||||
|     cert_doc = db.certificates.find_one(query) | ||||
|  | ||||
|     if not cert_doc: | ||||
|         raise errors.CertificateDoesNotExist("Certificate with serial %s not found" % serial) | ||||
|  | ||||
|     cert = x509.Certificate.load(cert_doc["cert_buf"]) | ||||
|     return cert_doc, cert | ||||
|  | ||||
| def get_signed(mongo_id=False, common_name=False, namespace=const.AUTHORITY_NAMESPACE): | ||||
|  | ||||
|     if mongo_id: | ||||
|         query = {"_id": ObjectId(mongo_id), "status": "signed"} | ||||
|     elif common_name: | ||||
|         common_name = "%s.%s" % (common_name, namespace) | ||||
|         query = {"common_name": common_name, "status": "signed"} | ||||
|     else: | ||||
|         raise ValueError("No Id or common name specified for signed certificate search") | ||||
|  | ||||
|     cert_doc = db.certificates.find_one(query) | ||||
|  | ||||
|     if not cert_doc: | ||||
|         raise errors.CertificateDoesNotExist("We did not found certificate with CN %s" % repr(common_name)) | ||||
|  | ||||
|     cert = x509.Certificate.load(cert_doc["cert_buf"]) | ||||
|     pem_buf = asymmetric.dump_certificate(cert) | ||||
|     return cert, cert_doc, pem_buf | ||||
|  | ||||
|  | ||||
| # TODO: get revoked cert from database by serial | ||||
| def get_revoked(serial): | ||||
|  | ||||
|     if isinstance(serial, int): | ||||
|         serial = "%x" % serial | ||||
|  | ||||
|     query = {"serial_number":serial, "status": "revoked"} | ||||
|     cert_doc = db.certificates.find_one(query) | ||||
|  | ||||
|     if not cert_doc: | ||||
|         raise errors.CertificateDoesNotExist | ||||
|  | ||||
|     cert_pem_buf = pem.armor("CERTIFICATE",cert_doc["cert_buf"]) | ||||
|     return cert_doc, cert_pem_buf | ||||
|  | ||||
|  | ||||
| def store_request(buf, overwrite=False, address="", user="", namespace=const.MACHINE_NAMESPACE): | ||||
|     """ | ||||
|     Store CSR for later processing | ||||
|     """ | ||||
|     # TODO: Raise exception for any CSR where CN is set to one of servers/replicas | ||||
|     now = datetime.utcnow().replace(tzinfo=pytz.UTC) | ||||
|  | ||||
|     if not buf: | ||||
|         raise ValueError("No signing request supplied") | ||||
|  | ||||
|     if pem.detect(buf): | ||||
|         header, _, der_bytes = pem.unarmor(buf) | ||||
|         csr = CertificationRequest.load(der_bytes) | ||||
|     else: | ||||
|         csr = CertificationRequest.load(buf) | ||||
|         der_bytes = csr.dump() | ||||
|  | ||||
|     common_name = csr["certification_request_info"]["subject"].native["common_name"].lower() | ||||
|  | ||||
|     if not re.match(const.RE_COMMON_NAME, common_name): | ||||
|         raise ValueError("Invalid common name %s" % repr(common_name)) | ||||
|  | ||||
|  | ||||
|     query = {"common_name": common_name, "status": "csr"} | ||||
|     doc = db.certificates.find_one(query) | ||||
|     d ={} | ||||
|     user_object = {} | ||||
|  | ||||
|     if doc and not overwrite: | ||||
|         if doc["request_buf"] == der_bytes: | ||||
|             raise errors.RequestExists("Request already exists") | ||||
|         else: | ||||
|             raise errors.DuplicateCommonNameError("Another request with same common name already exists") | ||||
|     else: | ||||
|         # TODO: does CSR contain any timestamp?? | ||||
|         d["submitted"] = now | ||||
|         d["common_name"] = common_name | ||||
|         d["request_buf"] = der_bytes | ||||
|         d["status"] = "csr" | ||||
|  | ||||
|     pem_buf = pem_armor_csr(csr) | ||||
|     attach_csr = pem_buf, "application/x-pem-file", common_name + ".csr" | ||||
|     mailer.send("request-stored.md", attachments=(attach_csr,), common_name=common_name) | ||||
|     user_object["request_addresss"] = address | ||||
|     user_object["name"] = user | ||||
|  | ||||
|     try: | ||||
|         hostname, aliaslist, ipaddrlist = socket.gethostbyaddr(address) | ||||
|     except (socket.herror, OSError):  # Failed to resolve hostname or resolved to multiple | ||||
|         pass | ||||
|     else: | ||||
|         user_object["request_hostname"] = hostname | ||||
|  | ||||
|     d["user"] = user_object | ||||
|  | ||||
|     doc = db.certificates.find_one_and_update({ | ||||
|         "common_name":d["common_name"] | ||||
|     }, { | ||||
|         "$set": d, | ||||
|         "$setOnInsert": { | ||||
|             "created": now, | ||||
|             "ip": [], | ||||
|         }}, | ||||
|         upsert=True, | ||||
|         return_document=db.return_new) | ||||
|  | ||||
|     return doc | ||||
|  | ||||
| def revoke(mongo_id, reason, user="root"): | ||||
|     """ | ||||
|     Revoke valid certificate | ||||
|     """ | ||||
|     cert, cert_doc, pem_buf = get_signed(mongo_id) | ||||
|     common_name = cert_doc["common_name"] | ||||
|  | ||||
|     if reason not in ("key_compromise", "ca_compromise", "affiliation_changed", | ||||
|         "superseded", "cessation_of_operation", "certificate_hold", | ||||
|         "remove_from_crl", "privilege_withdrawn"): | ||||
|         raise ValueError("Invalid revocation reason %s" % reason) | ||||
|  | ||||
|     logger.info("Revoked certificate %s by %s", common_name, user) | ||||
|  | ||||
|     if mongo_id: | ||||
|         query = {"_id": ObjectId(mongo_id), "status": "signed"} | ||||
|     elif common_name: | ||||
|         query = {"common_name": common_name, "status": "signed"} | ||||
|     else: | ||||
|         raise ValueError("No common name or Id specified") | ||||
|  | ||||
|     prev = db.certificates.find_one(query) | ||||
|     newValue = { "$set": { "status": "revoked", "revocation_reason": reason, "revoked": datetime.utcnow().replace(tzinfo=pytz.UTC)} } | ||||
|     db.certificates.find_one_and_update(query,newValue) | ||||
|  | ||||
|     attach_cert = pem_buf, "application/x-pem-file", common_name + ".crt" | ||||
|  | ||||
|     mailer.send("certificate-revoked.md", | ||||
|         attachments=(attach_cert,), | ||||
|         serial_hex="%x" % cert.serial_number, | ||||
|         common_name=common_name) | ||||
|  | ||||
| def export_crl(pem=True): | ||||
|     builder = CertificateListBuilder( | ||||
|         const.AUTHORITY_CRL_URL, | ||||
|         certificate, | ||||
|         generate_serial() | ||||
|     ) | ||||
|  | ||||
|     # Get revoked certificates from database | ||||
|     for cert_revoked_doc in db.certificates.find({"status": "revoked"}): | ||||
|         builder.add_certificate( | ||||
|             int(cert_revoked_doc["serial"][:-4],16), | ||||
|             datetime.utcfromtimestamp(cert_revoked_doc["revoked"]).replace(tzinfo=pytz.UTC), | ||||
|             cert_revoked_doc["revocation_reason"] | ||||
|         ) | ||||
|  | ||||
|     certificate_list = builder.build(private_key) | ||||
|  | ||||
|     if pem: | ||||
|         return pem_armor_crl(certificate_list) | ||||
|     return certificate_list.dump() | ||||
|  | ||||
|  | ||||
| def delete_request(id, user="root"): | ||||
|  | ||||
|     if not id: | ||||
|         raise ValueError("No ID specified") | ||||
|  | ||||
|     query = {"_id": ObjectId(id), "status": "csr"} | ||||
|     doc = db.certificates.find_one(query) | ||||
|  | ||||
|     if not doc: | ||||
|         logger.info("Signing request with id %s not found" % ( | ||||
|         id)) | ||||
|         raise errors.RequestDoesNotExist | ||||
|  | ||||
|     res = db.certificates.delete_one(query) | ||||
|  | ||||
|     logger.info("Rejected signing request %s %s by %s" % (doc["common_name"], | ||||
|         id, user)) | ||||
|  | ||||
|  | ||||
| def sign(profile, skip_notify=False, overwrite=False, signer=None, namespace=const.MACHINE_NAMESPACE, mongo_id=None): | ||||
|     # TODO: buf is now DER format, convert to PEM just to get POC work | ||||
|     if mongo_id: | ||||
|         csr_doc = db.certificates.find_one({"_id": ObjectId(mongo_id)}) | ||||
|         csr = CertificationRequest.load(csr_doc["request_buf"]) | ||||
|         csr_buf_pem = pem.armor("CERTIFICATE REQUEST",csr_doc["request_buf"]) | ||||
|     else: | ||||
|         raise ValueError("ID missing, what CSR to sign") | ||||
|  | ||||
|  | ||||
|     assert isinstance(csr, CertificationRequest) | ||||
|  | ||||
|     csr_pubkey = asymmetric.load_public_key(csr["certification_request_info"]["subject_pk_info"]) | ||||
|     common_name = csr["certification_request_info"]["subject"].native["common_name"].lower() | ||||
|  | ||||
|     assert "." not in common_name  # TODO: correct validation | ||||
|  | ||||
|     common_name = "%s.%s" % (common_name, namespace) | ||||
|  | ||||
|     attachments = [ | ||||
|         (csr_buf_pem, "application/x-pem-file", common_name + ".csr"), | ||||
|     ] | ||||
|  | ||||
|     revoked_path = None | ||||
|     overwritten = False | ||||
|  | ||||
|     query = {"common_name": common_name, "status": "signed"} | ||||
|     prev = db.certificates.find_one(query) | ||||
|  | ||||
|     if prev: | ||||
|         if overwrite: | ||||
|             newValue = { "$set": { "status": "revoked", "revoked": datetime.utcnow().replace(tzinfo=pytz.UTC), "revocation_reason": "superseded"} } | ||||
|             doc = db.certificates.find_one_and_update(query,newValue,return_document=db.return_new) | ||||
|             overwritten = True | ||||
|         else: | ||||
|             raise FileExistsError("Will not overwrite existing certificate") | ||||
|  | ||||
|     profile = config.get("SignatureProfile", profile)["value"] | ||||
|     builder = CertificateBuilder(cn_to_dn(common_name, | ||||
|         ou=profile["ou"]), csr_pubkey) | ||||
|     builder.serial_number = generate_serial() | ||||
|  | ||||
|     now = datetime.utcnow().replace(tzinfo=pytz.UTC) | ||||
|     builder.begin_date = now - const.CLOCK_SKEW_TOLERANCE | ||||
|     builder.end_date = now + timedelta(days=profile["lifetime"]) | ||||
|     builder.issuer = certificate | ||||
|     builder.ca = profile["ca"] | ||||
|     subject_alt_name = profile.get("san") | ||||
|     if subject_alt_name: | ||||
|         builder.subject_alt_domains = [subject_alt_name, common_name] | ||||
|     else: | ||||
|         builder.subject_alt_domains = [common_name] | ||||
|     if profile.get("server_auth"): | ||||
|         builder.extended_key_usage.add("server_auth") | ||||
|         builder.extended_key_usage.add("ike_intermediate") | ||||
|     if profile.get("client_auth"): | ||||
|         builder.extended_key_usage.add("client_auth") | ||||
|     if not const.AUTHORITY_OCSP_DISABLED: | ||||
|         builder.ocsp_url = const.AUTHORITY_OCSP_URL | ||||
|     if const.AUTHORITY_CRL_ENABLED: | ||||
|         builder.crl_url = const.AUTHORITY_CRL_URL | ||||
|  | ||||
|     end_entity_cert = builder.build(private_key) | ||||
|     # PEM format cert | ||||
|     end_entity_cert_buf = asymmetric.dump_certificate(end_entity_cert) | ||||
|  | ||||
|     # Write certificate to database | ||||
|     # DER format cert | ||||
|     cert_der_bytes = asymmetric.dump_certificate(end_entity_cert,encoding="der") | ||||
|  | ||||
|     d = { | ||||
|         "common_name": common_name, | ||||
|         "status": "signed", | ||||
|         "serial_number": "%x" % builder.serial_number, | ||||
|         "signed": builder.begin_date, | ||||
|         "expires": builder.end_date, | ||||
|         "cert_buf": cert_der_bytes, | ||||
|         "profile": profile, | ||||
|         "distinguished_name": cert_to_dn(end_entity_cert), | ||||
|         "dns": { | ||||
|             "fqdn": common_name, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if subject_alt_name: | ||||
|         d["dns"]["san"] = subject_alt_name | ||||
|  | ||||
|     if signer: | ||||
|         user_obj = {} | ||||
|         user_obj["signature"] = {"username": signer} | ||||
|         d["user"] = user_obj | ||||
|  | ||||
|     db.certificates.update_one({ | ||||
|         "_id": ObjectId(mongo_id), | ||||
|     }, { | ||||
|         "$set": d, | ||||
|         "$setOnInsert": { | ||||
|             "created": now, | ||||
|             "ip": [], | ||||
|         } | ||||
|     }) | ||||
|  | ||||
|     attachments.append((end_entity_cert_buf, "application/x-pem-file", common_name + ".crt")) | ||||
|     cert_serial_hex = "%x" % end_entity_cert.serial_number | ||||
|  | ||||
|     # TODO: Copy attributes from revoked certificate | ||||
|  | ||||
|     if not skip_notify: | ||||
|         mailer.send("certificate-signed.md", **locals()) | ||||
|  | ||||
|     return end_entity_cert, end_entity_cert_buf | ||||
							
								
								
									
										685
									
								
								pinecrypt/server/cli.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										685
									
								
								pinecrypt/server/cli.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,685 @@ | ||||
| # coding: utf-8 | ||||
|  | ||||
| try: | ||||
|     import coverage | ||||
| except ImportError: | ||||
|     pass | ||||
| else: | ||||
|     if coverage.process_startup(): | ||||
|         print("Enabled code coverage tracking") | ||||
|  | ||||
| import falcon | ||||
| import click | ||||
| import logging | ||||
| import os | ||||
| import pymongo | ||||
| import signal | ||||
| import socket | ||||
| import sys | ||||
| import pytz | ||||
| from asn1crypto import pem, x509 | ||||
| from certbuilder import CertificateBuilder, pem_armor_certificate | ||||
| from datetime import datetime, timedelta | ||||
| from oscrypto import asymmetric | ||||
| from pinecrypt.server import const, mongolog, mailer, db | ||||
| from pinecrypt.server.middleware import NormalizeMiddleware, PrometheusEndpoint | ||||
| from pinecrypt.server.common import cn_to_dn, generate_serial | ||||
| from time import sleep | ||||
| from wsgiref.simple_server import make_server | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
| mongolog.register() | ||||
|  | ||||
| def graceful_exit(signal_number, stack_frame): | ||||
|     print("Received signal %d, exiting now" % signal_number) | ||||
|     sys.exit(0) | ||||
|  | ||||
| def fqdn_required(func): | ||||
|     def wrapped(**args): | ||||
|         common_name = args.get("common_name") | ||||
|         if "." in common_name: | ||||
|             logger.info("Using fully qualified hostname %s" % common_name) | ||||
|         else: | ||||
|             raise ValueError("Fully qualified hostname not specified as common name, make sure hostname -f works") | ||||
|         return func(**args) | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| def waitfile(path): | ||||
|     def wrapper(func): | ||||
|         def wrapped(**args): | ||||
|             while not os.path.exists(path): | ||||
|                 sleep(1) | ||||
|             return func(**args) | ||||
|         return wrapped | ||||
|     return wrapper | ||||
|  | ||||
|  | ||||
| @click.command("log", help="Dump logs") | ||||
| def pinecone_log(): | ||||
|     for record in mongolog.collection.find(): | ||||
|         print(record["created"].strftime("%Y-%m-%d %H:%M:%S"), | ||||
|             record["severity"], | ||||
|             record["message"]) | ||||
|  | ||||
|  | ||||
| @click.command("users", help="List users") | ||||
| def pinecone_users(): | ||||
|     from pinecrypt.server.user import User | ||||
|     admins = set(User.objects.filter_admins()) | ||||
|     for user in User.objects.all(): | ||||
|         click.echo("%s;%s;%s;%s;%s" % ( | ||||
|             "admin" if user in admins else "user", | ||||
|             user.name, user.given_name, user.surname, user.mail)) | ||||
|  | ||||
|  | ||||
| @click.command("list", help="List certificates") | ||||
| @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") | ||||
| @click.option("--show-extensions", "-e", default=False, is_flag=True, help="Show X.509 Certificate Extensions") | ||||
| @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 pinecone_list(verbose, show_key_type, show_extensions, show_path, show_signed, show_revoked, hide_requests): | ||||
|     from pinecrypt.server import db | ||||
|     for o in db.certificates.find(): | ||||
|         print(o["common_name"], o["status"], o.get("instance"), o.get("remote"), o.get("last_seen")) | ||||
|  | ||||
|  | ||||
| @click.command("list", help="List sessions") | ||||
| def pinecone_session_list(): | ||||
|     from pinecrypt.server import db | ||||
|     for o in db.sessions.find(): | ||||
|         print(o["user"], o["started"], o.get("expires"), o.get("last_seen")) | ||||
|  | ||||
|  | ||||
| @click.command("sign", help="Sign certificate") | ||||
| @click.argument("common_name") | ||||
| @click.option("--profile", "-p", default="Roadwarrior", help="Profile") | ||||
| @click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN") | ||||
| def pinecone_sign(common_name, overwrite, profile): | ||||
|     from pinecrypt.server import authority | ||||
|     authority.sign(common_name, overwrite=overwrite, profile=profile) | ||||
|  | ||||
|  | ||||
| @click.command("revoke", help="Revoke certificate") | ||||
| @click.option("--reason", "-r", default="key_compromise", help="Revocation reason, one of: key_compromise affiliation_changed superseded cessation_of_operation privilege_withdrawn") | ||||
| @click.argument("common_name") | ||||
| def pinecone_revoke(common_name, reason): | ||||
|     from pinecrypt.server import authority | ||||
|     authority.revoke(common_name, reason) | ||||
|  | ||||
|  | ||||
| @click.command("kinit", help="Initialize Kerberos credential cache for LDAP") | ||||
| def pinecone_housekeeping_kinit(): | ||||
|  | ||||
|     # Update LDAP service ticket if Certidude is joined to domain | ||||
|     if not os.path.exists("/etc/krb5.keytab"): | ||||
|         raise click.ClickException("No Kerberos keytab configured") | ||||
|  | ||||
|     _, kdc = const.LDAP_ACCOUNTS_URI.rsplit("/", 1) | ||||
|     cmd = "KRB5CCNAME=%s.part kinit -k %s$ -S ldap/%s@%s -t /etc/krb5.keytab" % ( | ||||
|         const.LDAP_GSSAPI_CRED_CACHE, | ||||
|         const.HOSTNAME.upper(), kdc, const.KERBEROS_REALM | ||||
|     ) | ||||
|     click.echo("Executing: %s" % cmd) | ||||
|     if os.system(cmd): | ||||
|         raise click.ClickException("Failed to initialize Kerberos credential cache!") | ||||
|     os.system("chown certidude:certidude %s.part" % const.LDAP_GSSAPI_CRED_CACHE) | ||||
|     os.rename("%s.part" % const.LDAP_GSSAPI_CRED_CACHE, const.LDAP_GSSAPI_CRED_CACHE) | ||||
|  | ||||
|  | ||||
| @click.command("daily", help="Send notifications about expired certificates") | ||||
| def pinecone_housekeeping_expiration(): | ||||
|     from pinecrypt.server import authority | ||||
|     threshold_move = datetime.utcnow().replace(tzinfo=pytz.UTC) - const.CLOCK_SKEW_TOLERANCE | ||||
|     threshold_notify = datetime.utcnow().replace(tzinfo=pytz.UTC) + timedelta(hours=48) | ||||
|     expired = [] | ||||
|     about_to_expire = [] | ||||
|  | ||||
|     # Collect certificates which have expired and are about to expire | ||||
|     for common_name, path, buf, cert, signed, expires in authority.list_signed(): | ||||
|         if expires.replace(tzinfo=pytz.UTC) < threshold_move: | ||||
|             expired.append((common_name, path, cert)) | ||||
|         elif expires.replace(tzinfo=pytz.UTC) < threshold_notify: | ||||
|             about_to_expire.append((common_name, path, cert)) | ||||
|  | ||||
|     # Send e-mail notifications | ||||
|     if expired or about_to_expire: | ||||
|         mailer.send("expiration-notification.md", **locals()) | ||||
|  | ||||
|     # Move valid, but now expired certificates | ||||
|     for common_name, path, cert in expired: | ||||
|         expired_path = os.path.join(const.EXPIRED_DIR, "%040x.pem" % cert.serial_number) | ||||
|         click.echo("Moving %s to %s" % (path, expired_path)) | ||||
|         os.rename(path, expired_path) | ||||
|         os.remove(os.path.join(const.SIGNED_BY_SERIAL_DIR, "%040x.pem" % cert.serial_number)) | ||||
|  | ||||
|     # Move revoked certificate which have expired | ||||
|     for common_name, path, buf, cert, signed, expires, revoked, reason in authority.list_revoked(): | ||||
|         if expires.replace(tzinfo=pytz.UTC) < threshold_move: | ||||
|             expired_path = os.path.join(const.EXPIRED_DIR, "%040x.pem" % cert.serial_number) | ||||
|             click.echo("Moving %s to %s" % (path, expired_path)) | ||||
|             os.rename(path, expired_path) | ||||
|  | ||||
|     # TODO: Send separate e-mails to subjects | ||||
|  | ||||
|  | ||||
| @click.command("ocsp-responder") | ||||
| @waitfile(const.AUTHORITY_CERTIFICATE_PATH) | ||||
| def pinecone_serve_ocsp_responder(): | ||||
|     from pinecrypt.server.api.ocsp import app | ||||
|     app.run(port=5001, debug=const.DEBUG) | ||||
|  | ||||
| @click.command("events") | ||||
| def pinecone_serve_events(): | ||||
|     from pinecrypt.server.api.events import app | ||||
|     app.run(port=8001, debug=const.DEBUG) | ||||
|  | ||||
| @click.command("builder") | ||||
| def pinecone_serve_builder(): | ||||
|     from pinecrypt.server.api.builder import app | ||||
|     app.run(port=7001, debug=const.DEBUG) | ||||
|  | ||||
| @click.command("provision", help="Provision keys") | ||||
| def pinecone_provision(): | ||||
|     default_policy = "REJECT" if const.DEBUG else "DROP" | ||||
|  | ||||
|     click.echo("Setting up firewall rules") | ||||
|     if const.REPLICAS: | ||||
|         # TODO: atomic update with `ipset restore` | ||||
|         for replica in const.REPLICAS: | ||||
|             for fam, _, _, _, addrs in socket.getaddrinfo(replica, None): | ||||
|                 if fam == 10: | ||||
|                     os.system("ipset add ipset6-mongo-replicas %s" % addrs[0]) | ||||
|                 elif fam == 2: | ||||
|                     os.system("ipset add ipset4-mongo-replicas %s" % addrs[0]) | ||||
|  | ||||
|     os.system("ipset create -exist -quiet ipset4-client-ingress hash:ip timeout 3600 counters") | ||||
|     os.system("ipset create -exist -quiet ipset6-client-ingress hash:ip family inet6 timeout 3600 counters") | ||||
|  | ||||
|     os.system("ipset create -exist -quiet ipset4-client-egress hash:ip timeout 3600 counters") | ||||
|     os.system("ipset create -exist -quiet ipset6-client-egress hash:ip family inet6 timeout 3600 counters") | ||||
|  | ||||
|     os.system("ipset create -exist -quiet ipset4-mongo-replicas hash:ip") | ||||
|     os.system("ipset create -exist -quiet ipset6-mongo-replicas hash:ip family inet6") | ||||
|  | ||||
|     os.system("ipset create -exist -quiet ipset4-prometheus-subnets hash:net") | ||||
|     os.system("ipset create -exist -quiet ipset6-prometheus-subnets hash:net family inet6") | ||||
|  | ||||
|     for subnet in const.PROMETHEUS_SUBNETS: | ||||
|         os.system("ipset add -exist -quiet ipset%d-prometheus-subnets %s" % (subnet.version, subnet)) | ||||
|  | ||||
|  | ||||
|     def g(): | ||||
|         yield "*filter" | ||||
|         yield ":INBOUND_BLOCKED - [0:0]" | ||||
|         yield "-A INBOUND_BLOCKED -j %s -m comment --comment \"Default policy\"" % default_policy | ||||
|  | ||||
|         yield ":OUTBOUND_CLIENT - [0:0]" | ||||
|         yield "-A OUTBOUND_CLIENT -m set ! --match-set ipset4-client-ingress dst -j SET --add-set ipset4-client-ingress dst" | ||||
|         yield "-A OUTBOUND_CLIENT -j ACCEPT" | ||||
|  | ||||
|         yield ":INBOUND_CLIENT - [0:0]" | ||||
|         yield "-A INBOUND_CLIENT -m set ! --match-set ipset4-client-ingress src -j SET --add-set ipset4-client-ingress src" | ||||
|         yield "-A INBOUND_CLIENT -j ACCEPT" | ||||
|  | ||||
|         yield ":INPUT DROP [0:0]" | ||||
|         yield "-A INPUT -i lo -j ACCEPT -m comment --comment \"Allow loopback\"" | ||||
|         yield "-A INPUT -p icmp -j ACCEPT -m comment --comment \"Allow ping\"" | ||||
|         yield "-A INPUT -p esp -j ACCEPT -m comment --comment \"Allow ESP traffic\"" | ||||
|         yield "-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -m comment --comment \"Allow returning packets\"" | ||||
|         yield "-A INPUT -p udp --dport 53 -j ACCEPT -m comment --comment \"Allow GoreDNS over UDP\"" | ||||
|         yield "-A INPUT -p tcp --dport 53 -j ACCEPT -m comment --comment \"Allow GoreDNS over TCP\"" | ||||
|         yield "-A INPUT -p tcp --dport 80 -j ACCEPT -m comment --comment \"Allow insecure HTTP\"" | ||||
|         yield "-A INPUT -p tcp --dport 443 -j ACCEPT -m comment --comment \"Allow HTTPS / OpenVPN TCP\"" | ||||
|         yield "-A INPUT -p tcp --dport 8443 -j ACCEPT -m comment --comment \"Allow mutually authenticated HTTPS\"" | ||||
|         yield "-A INPUT -p udp --dport 1194 -j ACCEPT -m comment --comment \"Allow OpenVPN UDP\"" | ||||
|         yield "-A INPUT -p udp --dport 500 -j ACCEPT -m comment --comment \"Allow IPsec IKE\"" | ||||
|         yield "-A INPUT -p udp --dport 4500 -j ACCEPT -m comment --comment \"Allow IPsec NAT traversal\"" | ||||
|         if const.REPLICAS: | ||||
|             yield "-A INPUT -p tcp --dport 27017 -j ACCEPT -m set --match-set ipset4-mongo-replicas src -m comment --comment \"Allow MongoDB internode\"" | ||||
|         yield "-A INPUT -p tcp --dport 9090 -j ACCEPT -m set --match-set ipset4-prometheus-subnets src -m comment --comment \"Allow Prometheus\"" | ||||
|         yield "-A INPUT -j INBOUND_BLOCKED" | ||||
|  | ||||
|         yield ":FORWARD DROP [0:0]" | ||||
|         yield "-A FORWARD -i tunudp0 -j INBOUND_CLIENT -m comment --comment \"Inbound traffic from OpenVPN UDP clients\"" | ||||
|         yield "-A FORWARD -i tuntcp0 -j INBOUND_CLIENT -m comment --comment \"Inbound traffic from OpenVPN TCP clients\"" | ||||
|         yield "-A FORWARD -m policy --dir in --pol ipsec  -j INBOUND_CLIENT -m comment --comment \"Inbound traffic from IPSec clients\"" | ||||
|         yield "-A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j OUTBOUND_CLIENT -m comment --comment \"Outbound traffic to clients\"" | ||||
|         yield "-A FORWARD -j %s -m comment --comment \"Default policy\"" % default_policy | ||||
|  | ||||
|         yield ":OUTPUT DROP [0:0]" | ||||
|         yield "-A OUTPUT -j ACCEPT" | ||||
|         yield "COMMIT" | ||||
|  | ||||
|         yield "*nat" | ||||
|         yield ":PREROUTING ACCEPT [0:0]" | ||||
|         yield ":INPUT ACCEPT [0:0]" | ||||
|         yield ":OUTPUT ACCEPT [0:0]" | ||||
|         yield ":POSTROUTING ACCEPT [0:0]" | ||||
|         yield "-A POSTROUTING -j MASQUERADE" | ||||
|         yield "COMMIT" | ||||
|  | ||||
|     with open("/tmp/rules4", "w") as fh: | ||||
|         for line in g(): | ||||
|             fh.write(line) | ||||
|             fh.write("\n") | ||||
|  | ||||
|     os.system("iptables-restore < /tmp/rules4") | ||||
|     os.system("sed -e 's/ipset4/ipset6/g' -e 's/p icmp/p ipv6-icmp/g' /tmp/rules4 > /tmp/rules6") | ||||
|     os.system("ip6tables-restore < /tmp/rules6") | ||||
|     os.system("sysctl -w net.ipv6.conf.all.forwarding=1") | ||||
|     os.system("sysctl -w net.ipv6.conf.default.forwarding=1") | ||||
|     os.system("sysctl -w net.ipv4.ip_forward=1") | ||||
|  | ||||
|     if const.REPLICAS: | ||||
|         click.echo("Provisioning MongoDB replicaset") | ||||
|         # WTF https://github.com/docker-library/mongo/issues/339 | ||||
|         c = pymongo.MongoClient("localhost", 27017) | ||||
|         config ={"_id" : "rs0", "members": [ | ||||
|             {"_id": index, "host": "%s:27017" % hostname} for index, hostname in enumerate(const.REPLICAS)]} | ||||
|         print("Provisioning MongoDB replicaset: %s" % repr(config)) | ||||
|         try: | ||||
|             c.admin.command("replSetInitiate", config) | ||||
|         except pymongo.errors.OperationFailure: | ||||
|             print("Looks like it's already initialized") | ||||
|             pass | ||||
|  | ||||
|     # Expand variables | ||||
|     distinguished_name = cn_to_dn(const.AUTHORITY_COMMON_NAME) | ||||
|  | ||||
|     # Generate and sign CA key | ||||
|     if os.path.exists(const.AUTHORITY_CERTIFICATE_PATH) and os.path.exists(const.AUTHORITY_PRIVATE_KEY_PATH): | ||||
|         click.echo("Authority keypair already exists") | ||||
|     else: | ||||
|         if const.AUTHORITY_KEYTYPE == "ec": | ||||
|             click.echo("Generating %s EC key for CA ..." % const.CURVE_NAME) | ||||
|             public_key, private_key = asymmetric.generate_pair("ec", curve=const.CURVE_NAME) | ||||
|         else: | ||||
|             click.echo("Generating %d-bit RSA key for CA ..." % const.KEY_SIZE) | ||||
|             public_key, private_key = asymmetric.generate_pair("rsa", bit_size=const.KEY_SIZE) | ||||
|  | ||||
|         # https://technet.microsoft.com/en-us/library/aa998840(v=exchg.141).aspx | ||||
|         builder = CertificateBuilder(distinguished_name, public_key) | ||||
|         builder.self_signed = True | ||||
|         builder.ca = True | ||||
|         builder.serial_number = generate_serial() | ||||
|  | ||||
|         now = datetime.utcnow().replace(tzinfo=pytz.UTC) | ||||
|         builder.begin_date = now - const.CLOCK_SKEW_TOLERANCE | ||||
|         builder.end_date = now + timedelta(days=const.AUTHORITY_LIFETIME_DAYS) | ||||
|  | ||||
|         certificate = builder.build(private_key) | ||||
|  | ||||
|         header, _, der_bytes = pem.unarmor(pem_armor_certificate(certificate)) | ||||
|  | ||||
|         obj = { | ||||
|             "name": "root", | ||||
|             "key": asymmetric.dump_private_key(private_key, None), | ||||
|             "cert": pem_armor_certificate(certificate) | ||||
|         } | ||||
|  | ||||
|         if const.SECRET_STORAGE == "db": | ||||
|             db.secrets.create_index("name", unique=True) | ||||
|             try: | ||||
|                 db.secrets.insert_one(obj) | ||||
|             except pymongo.errors.DuplicateKeyError: | ||||
|                 obj = db.secrets.find_one({"name": "root"}) | ||||
|  | ||||
|         # Set permission bits to 600 | ||||
|         os.umask(0o177) | ||||
|         with open(const.AUTHORITY_PRIVATE_KEY_PATH + ".part", "wb") as f: | ||||
|             f.write(obj["key"]) | ||||
|  | ||||
|         # Set permission bits to 640 | ||||
|         os.umask(0o137) | ||||
|         with open(const.AUTHORITY_CERTIFICATE_PATH + ".part", "wb") as f: | ||||
|             f.write(obj["cert"]) | ||||
|  | ||||
|         os.rename(const.AUTHORITY_PRIVATE_KEY_PATH + ".part", | ||||
|             const.AUTHORITY_PRIVATE_KEY_PATH) | ||||
|         os.rename(const.AUTHORITY_CERTIFICATE_PATH + ".part", | ||||
|             const.AUTHORITY_CERTIFICATE_PATH) | ||||
|  | ||||
|         click.echo("Authority certificate written to: %s" % const.AUTHORITY_CERTIFICATE_PATH) | ||||
|  | ||||
|     click.echo("Attempting self-enroll") | ||||
|     from pinecrypt.server import authority | ||||
|     authority.self_enroll(skip_notify=True) | ||||
|  | ||||
|     myips = set() | ||||
|     for fam, _, _, _, addrs in socket.getaddrinfo(const.FQDN, None): | ||||
|         if fam in(2, 10): | ||||
|             myips.add(addrs[0]) | ||||
|  | ||||
|     # Insert/update DNS records for the replica itself | ||||
|     click.echo("Updating self DNS records: %s -> %s" % (const.FQDN, repr(myips))) | ||||
|     db.certificates.update_one({ | ||||
|         "common_name": const.FQDN, | ||||
|         "status": "signed", | ||||
|     }, { | ||||
|         "$set": { | ||||
|             "dns": { | ||||
|                 "fqdn": const.FQDN, | ||||
|                 "san": const.AUTHORITY_NAMESPACE, | ||||
|             }, | ||||
|             "ip": list(myips), | ||||
|         } | ||||
|     }) | ||||
|  | ||||
|     # TODO: use this task to send notification emails maybe? | ||||
|     click.echo("Finished starting up") | ||||
|     sleep(86400) | ||||
|  | ||||
|  | ||||
| @click.command("backend", help="Serve main backend") | ||||
| @waitfile(const.SELF_CERT_PATH) | ||||
| def pinecone_serve_backend(): | ||||
|     from pinecrypt.server.tokens import TokenManager | ||||
|     from pinecrypt.server.api.signed import SignedCertificateDetailResource | ||||
|     from pinecrypt.server.api.request import RequestListResource, RequestDetailResource | ||||
|     from pinecrypt.server.api.script import ScriptResource | ||||
|     from pinecrypt.server.api.tag import TagResource, TagDetailResource | ||||
|     from pinecrypt.server.api.bootstrap import BootstrapResource | ||||
|     from pinecrypt.server.api.token import TokenResource | ||||
|     from pinecrypt.server.api.session import SessionResource, CertificateAuthorityResource | ||||
|     from pinecrypt.server.api.revoked import RevokedCertificateDetailResource | ||||
|     from pinecrypt.server.api.log import LogResource | ||||
|     from pinecrypt.server.api.revoked import RevocationListResource | ||||
|  | ||||
|     app = falcon.App(middleware=NormalizeMiddleware()) | ||||
|     app.req_options.strip_url_path_trailing_slash = True | ||||
|     app.req_options.auto_parse_form_urlencoded = True | ||||
|     app.add_route("/metrics", PrometheusEndpoint()) | ||||
|  | ||||
|     # CN to Id api call | ||||
|     app.add_route("/api/signed/{cn}", SignedCertificateDetailResource(), suffix="cn") | ||||
|     app.add_route("/api/request/{cn}", RequestDetailResource(), suffix="cn") | ||||
|     app.add_route("/api/signed/{cn}/script", ScriptResource(), suffix="cn") | ||||
|     app.add_route("/api/signed/{cn}/tag", TagResource(), suffix="cn") | ||||
|     app.add_route("/api/signed/{cn}/tag/{tag}", TagDetailResource(), suffix="cn") | ||||
|  | ||||
|  | ||||
|     # Certificate authority API calls | ||||
|     app.add_route("/api/certificate", CertificateAuthorityResource()) | ||||
|     app.add_route("/api/signed/id/{id}", SignedCertificateDetailResource()) | ||||
|     app.add_route("/api/request/id/{id}", RequestDetailResource()) | ||||
|     app.add_route("/api/request", RequestListResource()) | ||||
|     app.add_route("/api/revoked/{serial_number}", RevokedCertificateDetailResource()) | ||||
|     app.add_route("/api/log", LogResource()) | ||||
|     app.add_route("/api/revoked", RevocationListResource()) | ||||
|  | ||||
|     token_resource = None | ||||
|     token_manager = None | ||||
|     if const.USER_ENROLLMENT_ALLOWED: # TODO: add token enable/disable flag for config | ||||
|         token_manager = TokenManager() | ||||
|         token_resource = TokenResource(token_manager) | ||||
|         app.add_route("/api/token", token_resource) | ||||
|  | ||||
|     app.add_route("/api/session", SessionResource(token_manager)) | ||||
|  | ||||
|     # Extended attributes for scripting etc. | ||||
|     app.add_route("/api/signed/id/{id}/script", ScriptResource()) | ||||
|  | ||||
|     # API calls used by pushed events on the JS end | ||||
|     app.add_route("/api/signed/id/{id}/tag", TagResource()) | ||||
|  | ||||
|     # API call used to delete existing tags | ||||
|     app.add_route("/api/signed/id/{id}/tag/{tag}", TagDetailResource()) | ||||
|  | ||||
|     # Bootstrap resource | ||||
|     app.add_route("/api/bootstrap", BootstrapResource()) | ||||
|  | ||||
|     signal.signal(signal.SIGTERM, graceful_exit) | ||||
|     with make_server("127.0.0.1", 4001, app) as httpd: | ||||
|         httpd.serve_forever() | ||||
|  | ||||
|  | ||||
| @click.command("test", help="Test mailer") | ||||
| @click.argument("recipient") | ||||
| def pinecone_test(recipient): | ||||
|     from pinecrypt.server import mailer | ||||
|     mailer.send("test.md", to=recipient) | ||||
|  | ||||
|  | ||||
| @click.command("list", help="List tokens") | ||||
| def pinecone_token_list(): | ||||
|     from pinecrypt.server.tokens import TokenManager | ||||
|     token_manager = TokenManager(const.TOKEN_DATABASE) | ||||
|     cols = "uuid", "expires", "subject", "state" | ||||
|     now = datetime.utcnow().replace(tzinfo=pytz.UTC) | ||||
|     for token in token_manager.list(expired=True, used=True): | ||||
|         token["state"] = "used" if token.get("used") else ("valid" if token.get("expires") > now  else "expired") | ||||
|         print(";".join([str(token.get(col)) for col in cols])) | ||||
|  | ||||
|  | ||||
| @click.command("purge", help="Purge tokens") | ||||
| @click.option("-a", "--all", default=False, is_flag=True, help="Purge all not only expired tokens") | ||||
| def pinecone_token_purge(all): | ||||
|     from pinecrypt.server.tokens import TokenManager | ||||
|     token_manager = TokenManager(const.TOKEN_DATABASE) | ||||
|     print(token_manager.purge(all)) | ||||
|  | ||||
|  | ||||
| @click.command("issue", help="Issue token") | ||||
| @click.option("-m", "--subject-mail", default=None, help="Subject e-mail override") | ||||
| @click.argument("subject") | ||||
| def pinecone_token_issue(subject, subject_mail): | ||||
|     from pinecrypt.server.tokens import TokenManager | ||||
|     from pinecrypt.server.user import User | ||||
|     token_manager = TokenManager(const.TOKEN_DATABASE) | ||||
|     token_manager.issue(None, User.objects.get(subject), subject_mail) | ||||
|  | ||||
|  | ||||
| @click.group("housekeeping", help="Housekeeping tasks") | ||||
| def pinecone_housekeeping(): pass | ||||
|  | ||||
|  | ||||
| @click.group("token", help="Token management") | ||||
| def pinecone_token(): pass | ||||
|  | ||||
|  | ||||
| @click.group("serve", help="Entrypoints") | ||||
| def pinecone_serve(): pass | ||||
|  | ||||
|  | ||||
| @click.group("session", help="Session management") | ||||
| def pinecone_session(): pass | ||||
|  | ||||
|  | ||||
| @click.group() | ||||
| def entry_point(): pass | ||||
|  | ||||
|  | ||||
| @click.command("openvpn", help="Start OpenVPN server process") | ||||
| @click.option("--local", "-l", default="0.0.0.0", help="OpenVPN listening address, defaults to all interfaces") | ||||
| @click.option("--proto", "-t", default="udp", type=click.Choice(["udp", "tcp"]), help="OpenVPN transport protocol, UDP by default") | ||||
| @click.option("--client-subnet-slot", "-s", type=int, help="Client subnet slot index") | ||||
| @waitfile(const.SELF_CERT_PATH) | ||||
| def pinecone_serve_openvpn(local, proto, client_subnet_slot): | ||||
|     from pinecrypt.server import config | ||||
|     # TODO: Generate (per-client configs) from MongoDB | ||||
|     executable = "/usr/sbin/openvpn" | ||||
|  | ||||
|     args = executable, | ||||
|     slot4 = const.CLIENT_SUBNET4_SLOTS[client_subnet_slot] | ||||
|     args += "--server", str(slot4.network_address), str(slot4.netmask), | ||||
|     if const.CLIENT_SUBNET6: | ||||
|         args += "--server-ipv6", str(const.CLIENT_SUBNET6_SLOTS[client_subnet_slot]), | ||||
|     args += "--local", local | ||||
|  | ||||
|     # Support only two modes TCP 443 and UDP 1194 | ||||
|     if proto == "tcp": | ||||
|         args += "--dev", "tuntcp0", | ||||
|         args += "--port-share", "127.0.0.1", "1443", | ||||
|         args += "--proto", "tcp-server", | ||||
|         args += "--port", "443", | ||||
|         args += "--socket-flags", "TCP_NODELAY", | ||||
|         instance = "%s-openvpn-tcp-443" % const.FQDN | ||||
|     else: | ||||
|         args += "--dev", "tunudp0", | ||||
|         args += "--proto", "udp", | ||||
|         args += "--port", "1194", | ||||
|         instance = "%s-openvpn-udp-1194" % const.FQDN | ||||
|     args += "--setenv", "instance", instance | ||||
|     db.certificates.update_many({ | ||||
|         "instance": instance | ||||
|     }, { | ||||
|         "$unset": { | ||||
|             "ip": "", | ||||
|             "instance": "", | ||||
|         } | ||||
|     }) | ||||
|  | ||||
|     # Send keep alive packets, mainly for UDP | ||||
|     args += "--keepalive", "60", "120", | ||||
|  | ||||
|     args += "--opt-verify", | ||||
|  | ||||
|     args += "--key", const.SELF_KEY_PATH | ||||
|     args += "--cert", const.SELF_CERT_PATH | ||||
|     args += "--ca", const.AUTHORITY_CERTIFICATE_PATH | ||||
|  | ||||
|     if const.PUSH_SUBNETS: | ||||
|         args += "--push", "route-metric 1000" | ||||
|     for subnet in const.PUSH_SUBNETS: | ||||
|         if subnet.version == 4: | ||||
|             args += "--push", "route %s %s" % (subnet.network_address, subnet.netmask), | ||||
|         elif subnet.version == 6: | ||||
|             if not const.CLIENT_SUBNET6: | ||||
|                 raise ValueError("Can't push IPv6 routes if no IPv6 client subnet is configured") | ||||
|             args += "--push", "route-ipv6 %s" % subnet | ||||
|         else: | ||||
|             raise NotImplementedError() | ||||
|  | ||||
|     # TODO: Figure out how to do dhparam without blocking initially | ||||
|     if os.path.exists(const.DHPARAM_PATH): | ||||
|         args += "--dh", const.DHPARAM_PATH | ||||
|     else: | ||||
|         args += "--dh", "none" | ||||
|  | ||||
|     # For more info see: openvpn --show-tls | ||||
|     args += "--tls-version-min", config.get("Globals", "OPENVPN_TLS_VERSION_MIN")["value"] | ||||
|     args += "--tls-ciphersuites", config.get("Globals", "OPENVPN_TLS_CIPHERSUITES")["value"], # Used by TLS 1.3 | ||||
|     args += "--tls-cipher", config.get("Globals", "OPENVPN_TLS_CIPHER")["value"], # Used by TLS 1.2 | ||||
|  | ||||
|     # Data channel encryption parameters | ||||
|     # TODO: Rename to --data-cipher when OpenVPN 2.5 becomes available | ||||
|     args += "--cipher", config.get("Globals", "OPENVPN_CIPHER")["value"] | ||||
|     args += "--auth", config.get("Globals", "OPENVPN_AUTH")["value"] | ||||
|  | ||||
|     # Just to sanity check ourselves | ||||
|     args += "--tls-cert-profile", "preferred", | ||||
|  | ||||
|     # Disable cipher negotiation since we know what we want | ||||
|     args += "--ncp-disable", | ||||
|  | ||||
|     args += "--script-security", "2", | ||||
|     args += "--learn-address", "/helpers/openvpn-learn-address.py" | ||||
|     args += "--client-connect", "/helpers/openvpn-client-connect.py" | ||||
|     args += "--verb", "0", | ||||
|  | ||||
|     logger.info("Executing: %s" % (" ".join(args))) | ||||
|     os.execv(executable, args) | ||||
|  | ||||
|  | ||||
| @click.command("strongswan", help="Start StrongSwan") | ||||
| @click.option("--client-subnet-slot", "-s", type=int, help="Client subnet slot index") | ||||
| @waitfile(const.SELF_CERT_PATH) | ||||
| def pinecone_serve_strongswan(client_subnet_slot): | ||||
|     from pinecrypt.server import config | ||||
|     slots = [] | ||||
|     slots.append(const.CLIENT_SUBNET4_SLOTS[client_subnet_slot]) | ||||
|     if const.CLIENT_SUBNET6: | ||||
|         slots.append(const.CLIENT_SUBNET6_SLOTS[client_subnet_slot]) | ||||
|  | ||||
|     with open("/etc/ipsec.conf", "w") as fh: | ||||
|         fh.write("config setup\n") | ||||
|         fh.write("  strictcrlpolicy=yes\n") | ||||
|         fh.write("  charondebug=\"cfg 2\"\n") | ||||
|  | ||||
|         fh.write("\n") | ||||
|         fh.write("ca authority\n") | ||||
|         fh.write("  auto=add\n") | ||||
|         fh.write("  cacert=%s\n" % const.AUTHORITY_CERTIFICATE_PATH) | ||||
|         fh.write("\n") | ||||
|         fh.write("conn s2c\n") | ||||
|         fh.write("  auto=add\n") | ||||
|         fh.write("  keyexchange=ikev2\n") | ||||
|  | ||||
|         fh.write("  left=%s\n" % const.AUTHORITY_NAMESPACE) | ||||
|         fh.write("  leftsendcert=always\n") | ||||
|         fh.write("  leftallowany=yes\n") # For load-balancing | ||||
|         fh.write("  leftcert=%s\n" % const.SELF_CERT_PATH) | ||||
|         if const.PUSH_SUBNETS: | ||||
|             fh.write("  leftsubnet=%s\n" % ",".join([str(j) for j in const.PUSH_SUBNETS])) | ||||
|         fh.write("  leftupdown=/helpers/updown.py\n") | ||||
|  | ||||
|         fh.write("  right=%any\n") | ||||
|         fh.write("  rightsourceip=%s\n" % ",".join([str(j) for j in slots])) | ||||
|         fh.write("  ike=%s!\n" % config.get("Globals", "STRONGSWAN_IKE")["value"]) | ||||
|         fh.write("  esp=%s!\n" % config.get("Globals", "STRONGSWAN_ESP")["value"]) | ||||
|     with open("/etc/ipsec.conf") as fh: | ||||
|         print(fh.read()) | ||||
|  | ||||
|     # Why do you do this StrongSwan?! You will parse the cert anyway, | ||||
|     # why do I need to distinguish ECDSA vs RSA in config?! | ||||
|     with open(const.SELF_CERT_PATH, "rb") as fh: | ||||
|         certificate_buf = fh.read() | ||||
|         header, _, certificate_der_bytes = pem.unarmor(certificate_buf) | ||||
|         certificate = x509.Certificate.load(certificate_der_bytes) | ||||
|         public_key = asymmetric.load_public_key(certificate["tbs_certificate"]["subject_public_key_info"]) | ||||
|     with open("/etc/ipsec.secrets", "w") as fh: | ||||
|         fh.write(": %s %s\n" % ( | ||||
|           "ECDSA" if public_key.algorithm == "ec" else "RSA", | ||||
|           const.SELF_KEY_PATH | ||||
|         )) | ||||
|     executable = "/usr/sbin/ipsec" | ||||
|     args = executable, "start", "--nofork" | ||||
|     logger.info("Executing: %s" % (" ".join(args))) | ||||
|     instance = "%s-ipsec" % const.FQDN | ||||
|  | ||||
|     db.certificates.update_many({ | ||||
|         "instance": instance | ||||
|     }, { | ||||
|         "$unset": { | ||||
|             "ip": "", | ||||
|             "instance": "", | ||||
|         } | ||||
|     }) | ||||
|  | ||||
|     # TODO: Find better way to push env vars to updown script | ||||
|     with open("/instance", "w") as fh: | ||||
|         fh.write(instance) | ||||
|     os.execv(executable, args) | ||||
|  | ||||
|  | ||||
| pinecone_serve.add_command(pinecone_serve_openvpn) | ||||
| pinecone_serve.add_command(pinecone_serve_strongswan) | ||||
| pinecone_serve.add_command(pinecone_serve_syslog) | ||||
| pinecone_serve.add_command(pinecone_serve_backend) | ||||
| pinecone_serve.add_command(pinecone_serve_ocsp_responder) | ||||
| pinecone_serve.add_command(pinecone_serve_events) | ||||
| pinecone_serve.add_command(pinecone_serve_builder) | ||||
| pinecone_session.add_command(pinecone_session_list) | ||||
| pinecone_token.add_command(pinecone_token_list) | ||||
| pinecone_token.add_command(pinecone_token_purge) | ||||
| pinecone_token.add_command(pinecone_token_issue) | ||||
| pinecone_housekeeping.add_command(pinecone_housekeeping_kinit) | ||||
| pinecone_housekeeping.add_command(pinecone_housekeeping_expiration) | ||||
| entry_point.add_command(pinecone_token) | ||||
| entry_point.add_command(pinecone_serve) | ||||
| entry_point.add_command(pinecone_sign) | ||||
| entry_point.add_command(pinecone_revoke) | ||||
| entry_point.add_command(pinecone_list) | ||||
| entry_point.add_command(pinecone_housekeeping) | ||||
| entry_point.add_command(pinecone_users) | ||||
| entry_point.add_command(pinecone_test) | ||||
| entry_point.add_command(pinecone_log) | ||||
| entry_point.add_command(pinecone_provision) | ||||
| entry_point.add_command(pinecone_session) | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     entry_point() | ||||
							
								
								
									
										35
									
								
								pinecrypt/server/common.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								pinecrypt/server/common.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
|  | ||||
| from pinecrypt.server import const | ||||
| from random import SystemRandom | ||||
| from time import time_ns | ||||
|  | ||||
| random = SystemRandom() | ||||
|  | ||||
| MAPPING = dict( | ||||
|   common_name="CN", | ||||
|   organizational_unit_name="OU", | ||||
|   organization_name="O" | ||||
| ) | ||||
|  | ||||
|  | ||||
| def cert_to_dn(cert): | ||||
|     d = [] | ||||
|     for key, value in cert["tbs_certificate"]["subject"].native.items(): | ||||
|         if not isinstance(value, list): | ||||
|             value = [value] | ||||
|         for comp in value: | ||||
|             d.append("%s=%s" % (MAPPING[key], comp)) | ||||
|     return ", ".join(d) | ||||
|  | ||||
|  | ||||
| def cn_to_dn(common_name, ou=None): | ||||
|     d = {"common_name": common_name} | ||||
|     if ou: | ||||
|         d["organizational_unit_name"] = ou | ||||
|     if const.AUTHORITY_ORGANIZATION: | ||||
|         d["organization_name"] = const.AUTHORITY_ORGANIZATION | ||||
|     return d | ||||
|  | ||||
|  | ||||
| def generate_serial(): | ||||
|     return time_ns() << 56 | random.randint(0, 2 ** 56 - 1) | ||||
							
								
								
									
										89
									
								
								pinecrypt/server/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								pinecrypt/server/config.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| import pymongo | ||||
| from pinecrypt.server import const | ||||
| from pymongo import MongoClient | ||||
| from time import sleep | ||||
|  | ||||
|  | ||||
| client = MongoClient(const.MONGO_URI) | ||||
| db = client.get_default_database() | ||||
| collection = db["certidude_config"] | ||||
|  | ||||
| def populate(tp, key, value): | ||||
|     collection.update_one({ | ||||
|         "key": key, | ||||
|         "type": tp, | ||||
|     }, { | ||||
|        "$setOnInsert": { | ||||
|            "value": value, | ||||
|            "enabled": True | ||||
|        } | ||||
|     }, upsert=True) | ||||
|  | ||||
|  | ||||
| def get(tp, key): | ||||
|     return collection.find_one({ | ||||
|         "key": key, | ||||
|         "type": tp, | ||||
|     }) | ||||
|  | ||||
|  | ||||
| def options(tp): | ||||
|     retval = [] | ||||
|     for j in collection.find({"type": tp}): | ||||
|         j.pop("_id") | ||||
|         retval.append(j) | ||||
|     return sorted(retval, key=lambda e: e["key"]) | ||||
|  | ||||
|  | ||||
| def get_all(tp): | ||||
|     return collection.find({ | ||||
|         "type": tp, | ||||
|     }) | ||||
|  | ||||
|  | ||||
| def fixtures(): | ||||
|     # Signature profile for Certidude gateway replicas | ||||
|     populate("SignatureProfile", "Gateway", dict( | ||||
|         ou="Gateway", | ||||
|         san=const.AUTHORITY_NAMESPACE, | ||||
|         ca=False, | ||||
|         lifetime=365 * 5, | ||||
|         server_auth=True, | ||||
|         client_auth=True, | ||||
|         common_name="RE_FQDN", | ||||
|     )) | ||||
|  | ||||
|     # Signature profile for laptops | ||||
|     populate("SignatureProfile", "Roadwarrior", dict( | ||||
|         ou="Roadwarrior", | ||||
|         ca=False, | ||||
|         common_name="RE_HOSTNAME", | ||||
|         client_auth=True, | ||||
|         lifetime=365 * 5, | ||||
|     )) | ||||
|  | ||||
|     # Insert these to database so upgrading to version which defaults to | ||||
|     # different ciphers won't break any existing deployments | ||||
|     d = "ECDHE-ECDSA" if const.AUTHORITY_KEYTYPE == "ec" else "DHE-RSA" | ||||
|     populate("Globals", "OPENVPN_TLS_CIPHER", "TLS-%s-WITH-AES-256-GCM-SHA384" % d)  # Used by TLS 1.2 | ||||
|     populate("Globals", "OPENVPN_TLS_CIPHERSUITES", "TLS_AES_256_GCM_SHA384")  # Used by TLS 1.3 | ||||
|     populate("Globals", "OPENVPN_TLS_VERSION_MIN", "1.2")  # 1.3 is not supported by Ubuntu 18.04 | ||||
|     populate("Globals", "OPENVPN_CIPHER", "AES-128-GCM") | ||||
|     populate("Globals", "OPENVPN_AUTH", "SHA384") | ||||
|  | ||||
|     d = "ecp384" if const.AUTHORITY_KEYTYPE == "ec" else "modp2048" | ||||
|     populate("Globals", "STRONGSWAN_DHGROUP", d) | ||||
|     populate("Globals", "STRONGSWAN_IKE", "aes256-sha384-prfsha384-%s" % d) | ||||
|     populate("Globals", "STRONGSWAN_ESP", "aes128gcm16-aes128gmac-%s" % d) | ||||
|  | ||||
| # Populate MongoDB during import because this module is loaded | ||||
| # from several entrypoints in non-deterministic order | ||||
| # TODO: Add Prometheus metric a'la "waiting for mongo" | ||||
| while True: | ||||
|     try: | ||||
|         fixtures() | ||||
|     except pymongo.errors.ServerSelectionTimeoutError: | ||||
|         sleep(1) | ||||
|         continue | ||||
|     else: | ||||
|         break | ||||
							
								
								
									
										172
									
								
								pinecrypt/server/const.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								pinecrypt/server/const.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
|  | ||||
| import click | ||||
| import os | ||||
| import re | ||||
| import socket | ||||
| import sys | ||||
| from datetime import timedelta | ||||
| from ipaddress import ip_network | ||||
| from math import log, ceil | ||||
|  | ||||
| RE_USERNAME = r"^[a-z][a-z0-9]+$" | ||||
| RE_FQDN =  r"^(([a-z0-9]|[a-z0-9][a-z0-9\-_]*[a-z0-9])\.)+([a-z0-9]|[a-z0-9][a-z0-9\-_]*[a-z0-9])?$" | ||||
| RE_HOSTNAME =  r"^[a-z0-9]([a-z0-9\-_]{0,61}[a-z0-9])?$" | ||||
| RE_COMMON_NAME = r"^[A-Za-z0-9\-\_]+$" | ||||
|  | ||||
| # Make sure locales don't mess up anything | ||||
| assert re.match(RE_USERNAME, "abstuzxy19") | ||||
|  | ||||
| # To be migrated to Mongo or removed | ||||
| def parse_tag_types(d): | ||||
|     r = [] | ||||
|     for j in d.split(","): | ||||
|         r.append(j.split("/")) | ||||
|     return r | ||||
| TAG_TYPES = parse_tag_types(os.getenv("TAG_TYPES", "owner/str,location/str,phone/str,other/str")) | ||||
| SCRIPT_DIR = "" | ||||
| IMAGE_BUILDER_PROFILES = [] | ||||
| SERVICE_PROTOCOLS = ["ikev2", "openvpn"] | ||||
|  | ||||
| MONGO_URI = os.getenv("MONGO_URI") | ||||
| REPLICAS = os.getenv("REPLICAS") | ||||
| if REPLICAS: | ||||
|     REPLICAS = REPLICAS.split(",") | ||||
|     if MONGO_URI: | ||||
|         raise ValueError("Simultanously specifying MONGO_URI and REPLICAS doesn't make sense") | ||||
|     MONGO_URI = "mongodb://%s/default?replicaSet=rs0" % (",".join(["%s:27017" % j for j in REPLICAS])) | ||||
| elif not MONGO_URI: | ||||
|     MONGO_URI = "mongodb://127.0.0.1:27017/default?replicaSet=rs0" | ||||
|  | ||||
| KEY_SIZE = 4096 | ||||
| CURVE_NAME = "secp384r1" | ||||
|  | ||||
| # Kerberos-like clock skew tolerance | ||||
| CLOCK_SKEW_TOLERANCE = timedelta(minutes=5) | ||||
|  | ||||
| AUTHORITY_PRIVATE_KEY_PATH = "/var/lib/certidude/authority-secrets/ca_key.pem" | ||||
| AUTHORITY_CERTIFICATE_PATH = "/var/lib/certidude/server-secrets/ca_cert.pem" | ||||
| SELF_CERT_PATH = "/var/lib/certidude/server-secrets/self_cert.pem" | ||||
| SELF_KEY_PATH = "/var/lib/certidude/server-secrets/self_key.pem" | ||||
| DHPARAM_PATH = "/var/lib/certidude/server-secrets/dhparam.pem" | ||||
| BUILDER_TARBALLS = "" | ||||
|  | ||||
| try: | ||||
|     FQDN = socket.getaddrinfo(socket.gethostname(), 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME)[0][3] | ||||
| except socket.gaierror: | ||||
|     FQDN = socket.gethostname() | ||||
|  | ||||
| try: | ||||
|     HOSTNAME, DOMAIN = FQDN.split(".", 1) | ||||
| except ValueError: # If FQDN is not configured | ||||
|     click.echo("FQDN not configured: %s" % repr(FQDN)) | ||||
|     sys.exit(255) | ||||
|  | ||||
| def getenv_in(key, default, *vals): | ||||
|     val = os.getenv(key, default) | ||||
|     if val not in (default,) + vals: | ||||
|         raise ValueError("Got %s for %s, expected one of %s" % (repr(val), key, vals)) | ||||
|     return val | ||||
|  | ||||
| # Authority namespace corresponds to DNS entry which represents refers to all replicas | ||||
| AUTHORITY_NAMESPACE = os.getenv("AUTHORITY_NAMESPACE", FQDN) | ||||
| if FQDN != AUTHORITY_NAMESPACE and not FQDN.endswith(".%s" % AUTHORITY_NAMESPACE): | ||||
|     raise ValueError("Instance fully qualified domain name %s does not belong under %s, was expecing something like replica1.%s" % ( | ||||
|         repr(FQDN), repr(AUTHORITY_NAMESPACE), AUTHORITY_NAMESPACE)) | ||||
| USER_NAMESPACE = "u.%s" % AUTHORITY_NAMESPACE | ||||
| MACHINE_NAMESPACE = "m.%s" % AUTHORITY_NAMESPACE | ||||
| AUTHORITY_COMMON_NAME = "Pinecrypt Gateway at %s" % AUTHORITY_NAMESPACE | ||||
| AUTHORITY_ORGANIZATION = os.getenv("AUTHORITY_ORGANIZATION") | ||||
| AUTHORITY_LIFETIME_DAYS = 20*365 | ||||
|  | ||||
| # Mailer settings | ||||
| SMTP_HOST = os.getenv("SMTP_HOST", "localhost") | ||||
| SMTP_PORT = os.getenv("SMTP_PORT", 25) | ||||
| SMTP_TLS = getenv_in("SMTP_TLS", "tls", "starttls", "none") | ||||
| SMTP_USERNAME = os.getenv("SMTP_USERNAME") | ||||
| SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") | ||||
| SMTP_SENDER_NAME = os.getenv("SMTP_SENDER_NAME", "Pinecrypt Gateway at %s" % AUTHORITY_NAMESPACE) | ||||
| SMTP_SENDER_ADDR = os.getenv("SMTP_SENDER_ADDR") | ||||
|  | ||||
| # Stuff that gets embedded in each issued certificate | ||||
| AUTHORITY_CERTIFICATE_URL = "http://%s/api/certificate" % AUTHORITY_NAMESPACE | ||||
| AUTHORITY_CRL_ENABLED = os.getenv("AUTHORITY_CRL_ENABLED", False) | ||||
| AUTHORITY_CRL_URL = "http://%s/api/revoked/" % AUTHORITY_NAMESPACE | ||||
| AUTHORITY_OCSP_URL = "http://%s/api/ocsp/" % AUTHORITY_NAMESPACE | ||||
| AUTHORITY_OCSP_DISABLED = os.getenv("AUTHORITY_OCSP_DISABLED", False) | ||||
| AUTHORITY_KEYTYPE = getenv_in("AUTHORITY_KEYTYPE", "ec", "rsa") | ||||
|  | ||||
| # Tokens | ||||
| TOKEN_URL = "https://%(authority_name)s/#action=enroll&title=dev.lan&token=%(token)s&subject=%(subject_username)s&protocols=%(protocols)s" | ||||
| TOKEN_LIFETIME = 3600 * 24 | ||||
| TOKEN_OVERWRITE_PERMITTED = os.getenv("TOKEN_OVERWRITE_PERMITTED") | ||||
| # TODO: Check if we don't have base or servers | ||||
|  | ||||
| AUTHENTICATION_BACKENDS = set(["ldap"]) | ||||
| MAIL_SUFFIX = os.getenv("MAIL_SUFFIX") | ||||
|  | ||||
| KERBEROS_KEYTAB = os.getenv("KERBEROS_KEYTAB", "/var/lib/certidude/server-secrets/krb5.keytab") | ||||
| KERBEROS_REALM = os.getenv("KERBEROS_REALM") | ||||
| LDAP_AUTHENTICATION_URI = os.getenv("LDAP_AUTHENTICATION_URI") | ||||
| LDAP_GSSAPI_CRED_CACHE = os.getenv("LDAP_GSSAPI_CRED_CACHE", "/run/certidude/krb5cc") | ||||
| LDAP_ACCOUNTS_URI = os.getenv("LDAP_ACCOUNTS_URI") | ||||
| LDAP_BIND_DN = os.getenv("LDAP_BIND_DN") | ||||
| LDAP_BIND_PASSWORD = os.getenv("LDAP_BIND_PASSWORD") | ||||
| LDAP_BASE = os.getenv("LDAP_BASE") | ||||
| LDAP_MAIL_ATTRIBUTE = os.getenv("LDAP_MAIL_ATTRIBUTE", "mail") | ||||
| LDAP_USER_FILTER = os.getenv("LDAP_USER_FILTER", "(samaccountname=%s)") | ||||
| LDAP_ADMIN_FILTER = os.getenv("LDAP_ADMIN_FILTER", "(samaccountname=%s)") | ||||
| LDAP_COMPUTER_FILTER = os.getenv("LDAP_COMPUTER_FILTER", "()") | ||||
|  | ||||
| import ldap | ||||
| LDAP_CA_CERT = os.getenv("LDAP_CA_CERT") | ||||
| if LDAP_CA_CERT: | ||||
|     ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT) | ||||
|  | ||||
| if os.getenv("LDAP_DEBUG"): | ||||
|     ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) | ||||
|     ldap.set_option(ldap.OPT_DEBUG_LEVEL, 1) | ||||
|  | ||||
| def getenv_subnets(key, default=""): | ||||
|     return set([ip_network(j) for j in os.getenv(key, default).replace(",", " ").split(" ") if j]) | ||||
|  | ||||
| USER_SUBNETS = getenv_subnets("AUTH_USER_SUBNETS", "0.0.0.0/0 ::/0") | ||||
| ADMIN_SUBNETS = getenv_subnets("AUTH_ADMIN_SUBNETS", "0.0.0.0/0 ::/0") | ||||
| AUTOSIGN_SUBNETS = getenv_subnets("AUTH_AUTOSIGN_SUBNETS", "") | ||||
| REQUEST_SUBNETS = getenv_subnets("AUTH_REQUEST_SUBNETS", "0.0.0.0/0 ::/0").union(AUTOSIGN_SUBNETS) | ||||
| CRL_SUBNETS = getenv_subnets("AUTH_CRL_SUBNETS", "0.0.0.0/0 ::/0") | ||||
| OVERWRITE_SUBNETS = getenv_subnets("AUTH_OVERWRITE_SUBNETS", "") | ||||
| MACHINE_ENROLLMENT_SUBNETS = getenv_subnets("AUTH_MACHINE_ENROLLMENT_SUBNETS", "0.0.0.0/0 ::/0") | ||||
| KERBEROS_SUBNETS = getenv_subnets("AUTH_KERBEROS_SUBNETS", "0.0.0.0/0 ::/0") | ||||
| PROMETHEUS_SUBNETS = getenv_subnets("PROMETHEUS_SUBNETS", "") | ||||
|  | ||||
| BOOTSTRAP_TEMPLATE = "" | ||||
| USER_ENROLLMENT_ALLOWED = True | ||||
| USER_MULTIPLE_CERTIFICATES = True | ||||
|  | ||||
| REQUEST_SUBMISSION_ALLOWED = os.getenv("REQUEST_SUBMISSION_ALLOWED") | ||||
| REVOCATION_LIST_LIFETIME = os.getenv("REVOCATION_LIST_LIFETIME") | ||||
|  | ||||
| PUSH_SUBNETS = [ip_network(j) for j in os.getenv("PUSH_SUBNETS", "").replace(" ", ",").split(",") if j] | ||||
|  | ||||
| CLIENT_SUBNET4 = ip_network(os.getenv("CLIENT_SUBNET4", "192.168.33.0/24")) | ||||
| CLIENT_SUBNET6 = ip_network(os.getenv("CLIENT_SUBNET6")) if os.getenv("CLIENT_SUBNET6") else None | ||||
| CLIENT_SUBNET_SLOT_COUNT = int(os.getenv("CLIENT_SUBNET_COUNT", 4)) | ||||
| divisions = ceil(log(CLIENT_SUBNET_SLOT_COUNT) / log(2)) | ||||
| CLIENT_SUBNET4_SLOTS = list(CLIENT_SUBNET4.subnets(divisions)) | ||||
| CLIENT_SUBNET6_SLOTS = list(CLIENT_SUBNET6.subnets(divisions)) if CLIENT_SUBNET6 else [] | ||||
|  | ||||
| if CLIENT_SUBNET4.netmask == str("255.255.255.255"): | ||||
|     raise ValueError("Invalid client subnet specification: %s" % CLIENT_SUBNET4) | ||||
|  | ||||
| if "%s" not in LDAP_USER_FILTER: | ||||
|     raise ValueError("No placeholder %s for username in 'ldap user filter'") | ||||
| if "%s" not in LDAP_ADMIN_FILTER: | ||||
|     raise ValueError("No placeholder %s for username in 'ldap admin filter'") | ||||
|  | ||||
| AUDIT_EMAIL = os.getenv("AUDIT_EMAIL") | ||||
| DEBUG = bool(os.getenv("DEBUG")) | ||||
|  | ||||
| SESSION_COOKIE = "sha512brownies" | ||||
| SESSION_AGE = 3600 | ||||
|  | ||||
| SECRET_STORAGE = getenv_in("SECRET_STORAGE", "fs", "db") | ||||
							
								
								
									
										12
									
								
								pinecrypt/server/db.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								pinecrypt/server/db.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
|  | ||||
| from pinecrypt.server import const | ||||
| from pymongo import MongoClient, ReturnDocument | ||||
|  | ||||
| return_new = ReturnDocument.AFTER | ||||
| client = MongoClient(const.MONGO_URI) | ||||
| db = client.get_default_database() | ||||
| certificates = db["certidude_certificates"] | ||||
| eventlog = db["certidude_logs"] | ||||
| tokens = db["certidude_tokens"] | ||||
| sessions = db["certidude_sessions"] | ||||
| secrets = db["certidude_secrets"] | ||||
							
								
								
									
										84
									
								
								pinecrypt/server/decorators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								pinecrypt/server/decorators.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| import falcon | ||||
| import ipaddress | ||||
| import json | ||||
| import logging | ||||
| import types | ||||
| from datetime import date, datetime, timedelta | ||||
| from urllib.parse import urlparse | ||||
| from bson.objectid import ObjectId | ||||
|  | ||||
| logger = logging.getLogger("api") | ||||
|  | ||||
|  | ||||
| def csrf_protection(func): | ||||
|     """ | ||||
|     Protect resource from common CSRF attacks by checking user agent and referrer | ||||
|     """ | ||||
|  | ||||
|     def wrapped(self, req, resp, *args, **kwargs): | ||||
|         # Assume curl and python-requests are used intentionally | ||||
|         if req.user_agent.startswith("curl/") or req.user_agent.startswith("python-requests/"): | ||||
|             return func(self, req, resp, *args, **kwargs) | ||||
|  | ||||
|         # For everything else assert referrer | ||||
|         referrer = req.headers.get("REFERER") | ||||
|  | ||||
|  | ||||
|         if referrer: | ||||
|             scheme, netloc, path, params, query, fragment = urlparse(referrer) | ||||
|             if ":" in netloc: | ||||
|                 host, port = netloc.split(":", 1) | ||||
|             else: | ||||
|                 host, port = netloc, None | ||||
|             if host == req.host: | ||||
|                 return func(self, req, resp, *args, **kwargs) | ||||
|  | ||||
|         # Kaboom! | ||||
|         logger.warning("Prevented clickbait from '%s' with user agent '%s'", | ||||
|             referrer or "-", req.user_agent) | ||||
|         raise falcon.HTTPForbidden("Forbidden", | ||||
|             "No suitable UA or referrer provided, cross-site scripting disabled") | ||||
|     return wrapped | ||||
|  | ||||
|  | ||||
| class MyEncoder(json.JSONEncoder): | ||||
|     def default(self, obj): | ||||
|         from pinecrypt.server.user import User | ||||
|         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, timedelta): | ||||
|             return obj.total_seconds() | ||||
|         if isinstance(obj, types.GeneratorType): | ||||
|             return tuple(obj) | ||||
|         if isinstance(obj, User): | ||||
|             return dict(name=obj.name, given_name=obj.given_name, | ||||
|                 surname=obj.surname, mail=obj.mail) | ||||
|         if isinstance(obj, ObjectId): | ||||
|             return str(obj) | ||||
|         return json.JSONEncoder.default(self, obj) | ||||
|  | ||||
|  | ||||
| def serialize(func): | ||||
|     """ | ||||
|     Falcon response serialization | ||||
|     """ | ||||
|  | ||||
|     def wrapped(instance, req, resp, **kwargs): | ||||
|         retval = func(instance, req, resp, **kwargs) | ||||
|         if not resp.text and not resp.location: | ||||
|             if not req.client_accepts("application/json"): | ||||
|                 logger.debug("Client did not accept application/json") | ||||
|                 raise falcon.HTTPUnsupportedMediaType( | ||||
|                     "Client did not 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.text = json.dumps(retval, cls=MyEncoder) | ||||
|     return wrapped | ||||
|  | ||||
							
								
								
									
										24
									
								
								pinecrypt/server/errors.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								pinecrypt/server/errors.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
|  | ||||
| class AuthorityError(Exception): | ||||
|     pass | ||||
|  | ||||
| class RequestExists(AuthorityError): | ||||
|     pass | ||||
|  | ||||
| class RequestDoesNotExist(AuthorityError): | ||||
|     pass | ||||
|  | ||||
| class CertificateDoesNotExist(AuthorityError): | ||||
|     pass | ||||
|  | ||||
| class TokenDoesNotExist(AuthorityError): | ||||
|     pass | ||||
|  | ||||
| class FatalError(AuthorityError): | ||||
|     """ | ||||
|     Exception to be raised when user intervention is required | ||||
|     """ | ||||
|     pass | ||||
|  | ||||
| class DuplicateCommonNameError(FatalError): | ||||
|     pass | ||||
							
								
								
									
										65
									
								
								pinecrypt/server/mailer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								pinecrypt/server/mailer.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
|  | ||||
| import click | ||||
| import smtplib | ||||
| from pinecrypt.server import const | ||||
| from pinecrypt.server.user import User | ||||
| from markdown import markdown | ||||
| from jinja2 import Environment, PackageLoader | ||||
| from email.mime.multipart import MIMEMultipart | ||||
| from email.mime.text import MIMEText | ||||
| from email.mime.base import MIMEBase | ||||
| from email.header import Header | ||||
|  | ||||
| env = Environment(loader=PackageLoader("pinecrypt.server", "templates/mail")) | ||||
|  | ||||
| assert env.get_template("test.md") | ||||
|  | ||||
|  | ||||
| def send(template, to=None, attachments=(), **context): | ||||
|     recipients = () | ||||
|     if to: | ||||
|         recipients = (to,) + recipients | ||||
|     if const.AUDIT_EMAIL: | ||||
|         recipients += (const.AUDIT_EMAIL,) | ||||
|  | ||||
|     click.echo("Sending e-mail %s to %s" % (template, recipients)) | ||||
|  | ||||
|     subject, text = env.get_template(template).render(context).split("\n\n", 1) | ||||
|     html = markdown(text) | ||||
|  | ||||
|     msg = MIMEMultipart("alternative") | ||||
|     msg["Subject"] = Header(subject) | ||||
|     msg["From"] = Header(const.SMTP_SENDER_NAME) | ||||
|     msg["From"].append("<%s>" % const.SMTP_SENDER_ADDR) | ||||
|  | ||||
|     if recipients: | ||||
|         msg["To"] = Header() | ||||
|         for user in recipients: | ||||
|             if isinstance(user, User): | ||||
|                 full_name, user = user.format() | ||||
|                 if full_name: | ||||
|                     msg["To"].append(full_name) | ||||
|             msg["To"].append(user) | ||||
|             msg["To"].append(", ") | ||||
|  | ||||
|     part1 = MIMEText(text, "plain", "utf-8") | ||||
|     part2 = MIMEText(html, "html", "utf-8") | ||||
|  | ||||
|     msg.attach(part1) | ||||
|     msg.attach(part2) | ||||
|  | ||||
|     for attachment, content_type, suggested_filename in attachments: | ||||
|         part = MIMEBase(*content_type.split("/")) | ||||
|         part.add_header("Content-Disposition", "attachment", filename=suggested_filename) | ||||
|         part.set_payload(attachment) | ||||
|         msg.attach(part) | ||||
|  | ||||
|     click.echo("Sending %s to %s" % (template, msg["to"])) | ||||
|     cls = smtplib.SMTP_SSL if const.SMTP_TLS == "tls" else smtplib.SMTP | ||||
|     conn = cls(const.SMTP_HOST, const.SMTP_PORT) | ||||
|     if const.SMTP_TLS == "starttls": | ||||
|         conn.starttls() | ||||
|     if const.SMTP_USERNAME and const.SMTP_PASSWORD: | ||||
|         conn.login(const.SMTP_USERNAME, const.SMTP_PASSWORD) | ||||
|     conn.sendmail(const.SMTP_SENDER_ADDR, [u.mail if isinstance(u, User) else u for u in recipients], msg.as_string()) | ||||
|  | ||||
							
								
								
									
										48
									
								
								pinecrypt/server/middleware.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								pinecrypt/server/middleware.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| import ipaddress | ||||
| import socket | ||||
| import time | ||||
| from user_agents import parse | ||||
| from prometheus_client import Counter, Histogram, generate_latest | ||||
|  | ||||
| class NormalizeMiddleware(object): | ||||
|     def __init__(self): | ||||
|  | ||||
|         self.requests = Counter( | ||||
|             "http_total_request", | ||||
|             "Counter of total HTTP requests", | ||||
|             ["method", "path", "status"]) | ||||
|  | ||||
|         self.request_historygram = Histogram( | ||||
|             "request_latency_seconds", | ||||
|             "Histogram of request latency", | ||||
|             ["method", "path", "status"]) | ||||
|  | ||||
|     def process_request(self, req, resp, *args): | ||||
|         req.context["remote"] = { | ||||
|             "addr": ipaddress.ip_address(req.access_route[0]), | ||||
|         } | ||||
|  | ||||
|         if req.user_agent: | ||||
|             req.context["remote"]["user_agent"] = parse(req.user_agent) | ||||
|  | ||||
|         # TODO: This is potentially dangerous and should be toggleable | ||||
|         try: | ||||
|             hostname, aliaslist, ipaddrlist = socket.gethostbyaddr(str(req.context["remote"]["addr"])) | ||||
|         except (socket.herror, OSError): # Failed to resolve hostname or resolved to multiple | ||||
|             pass | ||||
|         else: | ||||
|             req.context["remote"]["hostname"] = hostname | ||||
|         req.start_time = time.time() | ||||
|  | ||||
|  | ||||
|     def process_response(self, req, resp, resource, req_succeeded): | ||||
|         resp_time = time.time() - req.start_time | ||||
|         self.requests.labels(method=req.method, path=req.path, status=resp.status).inc() | ||||
|         self.request_historygram.labels(method=req.method, path=req.path, status=resp.status).observe(resp_time) | ||||
|  | ||||
| class PrometheusEndpoint(object): | ||||
|     def on_get(self, req, resp): | ||||
|         data = generate_latest() | ||||
|         resp.content_type = "text/plain; version=0.0.4; charset=utf-8" | ||||
|         resp.text = str(data.decode("utf-8")) | ||||
|  | ||||
							
								
								
									
										27
									
								
								pinecrypt/server/mongolog.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								pinecrypt/server/mongolog.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
|  | ||||
| import logging | ||||
| from datetime import datetime | ||||
| from pinecrypt.server import db | ||||
|  | ||||
| class LogHandler(logging.Handler): | ||||
|     def emit(self, record): | ||||
|         d= {} | ||||
|         d["created"] = datetime.utcfromtimestamp(record.created) | ||||
|         d["facility"] = record.name | ||||
|         d["level"] = record.levelno | ||||
|         d["severity"] = record.levelname.lower() | ||||
|         d["message"] = record.msg % record.args | ||||
|         d["module"] = record.module | ||||
|         d["func"] = record.funcName | ||||
|         d["lineno"] = record.lineno | ||||
|         d["exception"] = logging._defaultFormatter.formatException(record.exc_info) if record.exc_info else "", | ||||
|         d["process"] = record.process | ||||
|         d["thread"] = record.thread | ||||
|         d["thread_name"] = record.threadName | ||||
|         db.eventlog.insert(d) | ||||
|  | ||||
| def register(): | ||||
|     for j in logging.Logger.manager.loggerDict.values(): | ||||
|         if isinstance(j, logging.Logger): | ||||
|             j.setLevel(logging.DEBUG) | ||||
|             j.addHandler(LogHandler()) | ||||
							
								
								
									
										43
									
								
								pinecrypt/server/profile.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								pinecrypt/server/profile.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
|  | ||||
| from pinecrypt.server import const | ||||
|  | ||||
| class SignatureProfile(object): | ||||
|     def __init__(self, slug, title, ou, ca, lifetime, key_usage, extended_key_usage, common_name, revoked_url, responder_url): | ||||
|         self.slug = slug | ||||
|         self.title = title | ||||
|         self.ou = ou or None | ||||
|         self.ca = ca | ||||
|         self.lifetime = lifetime | ||||
|         self.key_usage = set(key_usage.split(" ")) if key_usage else set() | ||||
|         self.extended_key_usage = set(extended_key_usage.split(" ")) if extended_key_usage else set() | ||||
|         self.responder_url = responder_url | ||||
|         self.revoked_url = revoked_url | ||||
|  | ||||
|         if common_name.startswith("^"): | ||||
|             self.common_name = common_name | ||||
|         elif common_name == "RE_HOSTNAME": | ||||
|             self.common_name = const.RE_HOSTNAME | ||||
|         elif common_name == "RE_FQDN": | ||||
|             self.common_name = const.RE_FQDN | ||||
|         elif common_name == "RE_COMMON_NAME": | ||||
|             self.common_name = const.RE_COMMON_NAME | ||||
|         else: | ||||
|             raise ValueError("Invalid common name constraint %s" % common_name) | ||||
|  | ||||
|     def serialize(self): | ||||
|         return dict([(key, getattr(self,key)) for key in ( | ||||
|             "slug", "title", "ou", "ca", "lifetime", "key_usage", "extended_key_usage", "common_name", "responder_url", "revoked_url")]) | ||||
|  | ||||
|     def __repr__(self): | ||||
|         bits = [] | ||||
|         if self.lifetime >= 365: | ||||
|             bits.append("%d years" % (self.lifetime / 365)) | ||||
|         if self.lifetime % 365: | ||||
|             bits.append("%d days" % (self.lifetime % 365)) | ||||
|         return "%s (title=%s, ca=%s, ou=%s, lifetime=%s, key_usage=%s, extended_key_usage=%s, common_name=%s, responder_url=%s, revoked_url=%s)" % ( | ||||
|             self.slug, self.title, self.ca, self.ou, " ".join(bits), | ||||
|             self.key_usage, self.extended_key_usage, | ||||
|             repr(self.common_name), | ||||
|             repr(self.responder_url), | ||||
|             repr(self.revoked_url)) | ||||
|  | ||||
							
								
								
									
										6
									
								
								pinecrypt/server/templates/mail/certificate-revoked.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								pinecrypt/server/templates/mail/certificate-revoked.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| Revoked {{ common_name }} ({{ serial_hex }}) | ||||
|  | ||||
| This is simply to notify that certificate {{ common_name }} | ||||
| was revoked. | ||||
|  | ||||
| Services making use of this certificates might become unavailable. | ||||
							
								
								
									
										14
									
								
								pinecrypt/server/templates/mail/certificate-signed.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								pinecrypt/server/templates/mail/certificate-signed.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| Signed {{ common_name }} ({{ cert_serial_hex }}) | ||||
|  | ||||
| This is simply to notify that certificate {{ common_name }} | ||||
| with serial number {{ cert_serial_hex }} | ||||
| was signed{% if signer %} by {{ signer }}{% endif %}. | ||||
|  | ||||
| The certificate is valid from {{ builder.begin_date }} until | ||||
| {{ builder.end_date }}. | ||||
|  | ||||
| {% if overwritten %} | ||||
| By doing so existing certificate with the same common name | ||||
| and serial number {{ prev_serial_hex }} was rejected | ||||
| and services making use of that certificate might become unavailable. | ||||
| {% endif %} | ||||
							
								
								
									
										18
									
								
								pinecrypt/server/templates/mail/expiration-notification.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								pinecrypt/server/templates/mail/expiration-notification.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| {% if expired %}{{ expired | length }} have expired{% endif %}{% if expired and about_to_expire %}, {% endif %}{% if about_to_expire %}{{ about_to_expire | length }} about to expire{% endif %} | ||||
|  | ||||
| {% if about_to_expire %} | ||||
| Following certificates are about to expire within following 48 hours: | ||||
|  | ||||
| {% for common_name, path, cert in expired %} | ||||
|   * {{ common_name }}, {{ "%x" % cert.serial_number }} | ||||
| {% endfor %} | ||||
| {% endif %} | ||||
|  | ||||
| {% if expired %} | ||||
| Following certificates have expired: | ||||
|  | ||||
| {% for common_name, path, cert in expired %} | ||||
|   * {{ common_name }}, {{ "%x" % cert.serial_number }} | ||||
| {% endfor %} | ||||
| {% endif %} | ||||
|  | ||||
							
								
								
									
										5
									
								
								pinecrypt/server/templates/mail/request-stored.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								pinecrypt/server/templates/mail/request-stored.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| Stored request {{ common_name }} | ||||
|  | ||||
| This is simply to notify that certificate signing request for {{ common_name }} | ||||
| was stored. You may log in with a certificate authority administration account to sign it. | ||||
|  | ||||
							
								
								
									
										3
									
								
								pinecrypt/server/templates/mail/test.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								pinecrypt/server/templates/mail/test.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| Test mail | ||||
|  | ||||
| Testing! | ||||
							
								
								
									
										12
									
								
								pinecrypt/server/templates/mail/token.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								pinecrypt/server/templates/mail/token.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| Token for {{ subject }} | ||||
|  | ||||
| {% if issuer == subject %} | ||||
| Token has been issued for {{ subject }} for retrieving profile from link below. | ||||
| {% else %} | ||||
| {{ issuer }} has provided {{ subject }} a token for retrieving | ||||
| profile from the link below. | ||||
| {% endif %} | ||||
|  | ||||
| Click <a href="{{ url }}" target="_blank">here</a> to claim the token. | ||||
| Token is usable until {{  token_expires }}{% if token_timezone %} ({{ token_timezone }} time){% endif %}. | ||||
|  | ||||
							
								
								
									
										98
									
								
								pinecrypt/server/tokens.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								pinecrypt/server/tokens.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| import string | ||||
| import pytz | ||||
| import pymongo | ||||
| from datetime import datetime, timedelta | ||||
| from pinecrypt.server import mailer, const, errors, db | ||||
| from pinecrypt.server.common import random | ||||
|  | ||||
|  | ||||
| class TokenManager(): | ||||
|     def consume(self, uuid): | ||||
|         now = datetime.utcnow().replace(tzinfo=pytz.UTC) | ||||
|  | ||||
|         doc = db.tokens.find_one_and_update({ | ||||
|             "uuid": uuid, | ||||
|             "created": {"$lte": now + const.CLOCK_SKEW_TOLERANCE}, | ||||
|             "expires": {"$gte": now - const.CLOCK_SKEW_TOLERANCE}, | ||||
|             "used": False | ||||
|         }, { | ||||
|             "$set": { | ||||
|               "used": now | ||||
|             } | ||||
|         }, return_document=pymongo.ReturnDocument.AFTER) | ||||
|  | ||||
|         if not doc: | ||||
|             raise errors.TokenDoesNotExist | ||||
|  | ||||
|         return doc["subject"], doc["mail"], doc["created"], doc["expires"], doc["profile"] | ||||
|  | ||||
|     def issue(self, issuer, subject, subject_mail=None): | ||||
|         # Expand variables | ||||
|         subject_username = subject.name | ||||
|         if not subject_mail: | ||||
|           subject_mail = subject.mail | ||||
|  | ||||
|         # Generate token | ||||
|         token = "".join(random.choice(string.ascii_lowercase + | ||||
|                                       string.ascii_uppercase + string.digits) for _ in range(32)) | ||||
|         token_created = datetime.utcnow().replace(tzinfo=pytz.UTC) | ||||
|         token_expires = token_created + timedelta(seconds=const.TOKEN_LIFETIME) | ||||
|  | ||||
|         d = {} | ||||
|         d["expires"] = token_expires | ||||
|         d["uuid"] = token | ||||
|         d["issuer"] = issuer.name if issuer else None | ||||
|         d["subject"] = subject_username | ||||
|         d["mail"] = subject_mail | ||||
|         d["used"] = False | ||||
|         d["profile"] = "Roadwarrior" | ||||
|  | ||||
|         db.tokens.update_one({ | ||||
|             "subject": subject_username, | ||||
|             "mail": subject_mail, | ||||
|             "used": False | ||||
|         }, { | ||||
|             "$set": d, | ||||
|             "$setOnInsert": { | ||||
|                 "created": token_created, | ||||
|             } | ||||
|         }, upsert=True) | ||||
|  | ||||
|         # Token lifetime in local time, to select timezone: dpkg-reconfigure tzdata | ||||
|         try: | ||||
|             with open("/etc/timezone") as fh: | ||||
|                 token_timezone = fh.read().strip() | ||||
|         except EnvironmentError: | ||||
|             token_timezone = None | ||||
|  | ||||
|         authority_name = const.AUTHORITY_NAMESPACE | ||||
|         protocols = ",".join(const.SERVICE_PROTOCOLS) | ||||
|         url = const.TOKEN_URL % locals() | ||||
|  | ||||
|         context = globals() | ||||
|         context.update(locals()) | ||||
|  | ||||
|         mailer.send("token.md", to=subject_mail, **context) | ||||
|         return token | ||||
|  | ||||
|     def list(self, expired=False, used=False): | ||||
|         query = {} | ||||
|  | ||||
|         if not used: | ||||
|             query["used"] = {"$eq": False} | ||||
|  | ||||
|         if not expired: | ||||
|             query["expires"] = {"$gte": datetime.utcnow().replace(tzinfo=pytz.UTC)} | ||||
|  | ||||
|         def g(): | ||||
|             for token in db.tokens.find(query).sort("expires", -1): | ||||
|                 token.pop("_id") | ||||
|                 token["uuid"] = token["uuid"][0:8] | ||||
|                 yield token | ||||
|         return tuple(g()) | ||||
|  | ||||
|     def purge(self, all=False): | ||||
|         query = {} | ||||
|         if not all: | ||||
|             query["expires"] = {"$lt": datetime.utcnow().replace(tzinfo=pytz.UTC)} | ||||
|         return db.tokens.remove(query) | ||||
							
								
								
									
										137
									
								
								pinecrypt/server/user.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								pinecrypt/server/user.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
|  | ||||
| import click | ||||
| import ldap | ||||
| import ldap.filter | ||||
| import ldap.sasl | ||||
| import os | ||||
| from pinecrypt.server import const | ||||
|  | ||||
| class User(object): | ||||
|     def __init__(self, username, mail, given_name="", surname=""): | ||||
|         self.name = username | ||||
|         self.mail = mail | ||||
|         self.given_name = given_name | ||||
|         self.surname = surname | ||||
|  | ||||
|     def format(self): | ||||
|         if self.given_name or self.surname: | ||||
|             return " ".join([j for j in [self.given_name, self.surname] if j]), "<%s>" % self.mail | ||||
|         else: | ||||
|             return None, self.mail | ||||
|  | ||||
|     def __repr__(self): | ||||
|         return " ".join([j for j in self.format() if j]) | ||||
|  | ||||
|     def __hash__(self): | ||||
|         return hash(self.mail) | ||||
|  | ||||
|     def __eq__(self, other): | ||||
|         if other == None: | ||||
|             return False | ||||
|         assert isinstance(other, User), "%s is not instance of User" % repr(other) | ||||
|         return self.mail == other.mail | ||||
|  | ||||
|     def is_admin(self): | ||||
|         if not hasattr(self, "_is_admin"): | ||||
|             self._is_admin = self.objects.is_admin(self) | ||||
|         return self._is_admin | ||||
|  | ||||
|     class DoesNotExist(Exception): | ||||
|         pass | ||||
|  | ||||
|  | ||||
| class DirectoryConnection(object): | ||||
|     def __enter__(self): | ||||
|         if const.LDAP_CA_CERT and not os.path.exists(const.LDAP_CA_CERT): | ||||
|             raise FileNotFoundError(const.LDAP_CA_CERT) | ||||
|         if const.LDAP_BIND_DN and const.LDAP_BIND_PASSWORD: | ||||
|             self.conn = ldap.initialize(const.LDAP_ACCOUNTS_URI, bytes_mode=False) | ||||
|             self.conn.simple_bind_s(const.LDAP_BIND_DN, const.LDAP_BIND_PASSWORD) | ||||
|         else: | ||||
|             if not os.path.exists(const.LDAP_GSSAPI_CRED_CACHE): | ||||
|                 raise ValueError("Ticket cache at %s not initialized, unable to " | ||||
|                     "authenticate with computer account against LDAP server!" % const.LDAP_GSSAPI_CRED_CACHE) | ||||
|             os.environ["KRB5CCNAME"] = const.LDAP_GSSAPI_CRED_CACHE | ||||
|             self.conn = ldap.initialize(const.LDAP_ACCOUNTS_URI, bytes_mode=False) | ||||
|             self.conn.set_option(ldap.OPT_REFERRALS, 0) | ||||
|             click.echo("Connecting to %s using Kerberos ticket cache from %s" % | ||||
|                 (const.LDAP_ACCOUNTS_URI, const.LDAP_GSSAPI_CRED_CACHE)) | ||||
|             self.conn.sasl_interactive_bind_s('', ldap.sasl.gssapi()) | ||||
|  | ||||
|         return self.conn | ||||
|  | ||||
|     def __exit__(self, type, value, traceback): | ||||
|         self.conn.unbind_s() | ||||
|  | ||||
|  | ||||
| class ActiveDirectoryUserManager(object): | ||||
|     def get(self, dirty_username): | ||||
|         username = ldap.filter.escape_filter_chars(dirty_username) | ||||
|  | ||||
|         with DirectoryConnection() as conn: | ||||
|             ft = const.LDAP_USER_FILTER % username | ||||
|             attribs = "cn", "givenName", "sn", const.LDAP_MAIL_ATTRIBUTE, "userPrincipalName" | ||||
|             r = conn.search_s(const.LDAP_BASE, 2, ft, attribs) | ||||
|             for dn, entry in r: | ||||
|                 if not dn: | ||||
|                     continue | ||||
|                 if entry.get("givenname") and entry.get("sn"): | ||||
|                     given_name, = entry.get("givenName") | ||||
|                     surname, = entry.get("sn") | ||||
|                 else: | ||||
|                     cn, = entry.get("cn") | ||||
|                     if b" " in cn: | ||||
|                         given_name, surname = cn.split(b" ", 1) | ||||
|                     else: | ||||
|                         given_name, surname = cn, b"" | ||||
|  | ||||
|                 mail, = entry.get(const.LDAP_MAIL_ATTRIBUTE) or ((username + "@" + const.DOMAIN).encode("ascii"),) | ||||
|                 return User(username, mail.decode("ascii"), | ||||
|                     given_name.decode("utf-8"), surname.decode("utf-8")) | ||||
|             raise User.DoesNotExist("User %s does not exist" % username) | ||||
|  | ||||
|     def filter(self, ft): | ||||
|         with DirectoryConnection() as conn: | ||||
|             attribs = "givenName", "surname", "samaccountname", "cn", const.LDAP_MAIL_ATTRIBUTE, "userPrincipalName" | ||||
|             r = conn.search_s(const.LDAP_BASE, 2, ft, attribs) | ||||
|             for dn,entry in r: | ||||
|                 if not dn: | ||||
|                     continue | ||||
|                 username, = entry.get("sAMAccountName") | ||||
|                 cn, = entry.get("cn") | ||||
|                 mail, = entry.get(const.LDAP_MAIL_ATTRIBUTE) or entry.get("userPrincipalName") or (username + b"@" + const.DOMAIN.encode("ascii"),) | ||||
|                 if entry.get("givenName") and entry.get("sn"): | ||||
|                     given_name, = entry.get("givenName") | ||||
|                     surname, = entry.get("sn") | ||||
|                 else: | ||||
|                     cn, = entry.get("cn") | ||||
|                     if b" " in cn: | ||||
|                         given_name, surname = cn.split(b" ", 1) | ||||
|                     else: | ||||
|                         given_name, surname = cn, b"" | ||||
|                 yield User(username.decode("utf-8"), mail.decode("utf-8"), | ||||
|                     given_name.decode("utf-8"), surname.decode("utf-8")) | ||||
|  | ||||
|     def filter_admins(self): | ||||
|         """ | ||||
|         Return admin User objects | ||||
|         """ | ||||
|         return self.filter(const.LDAP_ADMIN_FILTER % "*") | ||||
|  | ||||
|     def all(self): | ||||
|         """ | ||||
|         Return all valid User objects | ||||
|         """ | ||||
|         return self.filter(ft=const.LDAP_USER_FILTER % "*") | ||||
|  | ||||
|     def is_admin(self, user): | ||||
|         with DirectoryConnection() as conn: | ||||
|             ft = const.LDAP_ADMIN_FILTER % user.name | ||||
|             r = conn.search_s(const.LDAP_BASE, 2, ft, ["cn"]) | ||||
|             for dn, entry in r: | ||||
|                 if not dn: | ||||
|                     continue | ||||
|                 return True | ||||
|             return False | ||||
|  | ||||
| User.objects = ActiveDirectoryUserManager() | ||||
							
								
								
									
										23
									
								
								requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								requirements.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| certbuilder | ||||
| click>=6.7 | ||||
| configparser>=3.5.0 | ||||
| crlbuilder | ||||
| csrbuilder | ||||
| falcon | ||||
| gssapi | ||||
| humanize | ||||
| ipaddress | ||||
| ipsecparse | ||||
| jinja2 | ||||
| markdown | ||||
| oscrypto | ||||
| prometheus_client | ||||
| pymongo | ||||
| python-ldap | ||||
| pytz | ||||
| requests | ||||
| user-agents | ||||
| sanic | ||||
| sanic-prometheus | ||||
| motor | ||||
| asyncio | ||||
							
								
								
									
										51
									
								
								setup.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								setup.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| #!/usr/bin/env python3 | ||||
| # coding: utf-8 | ||||
| import os | ||||
| from setuptools import setup | ||||
|  | ||||
| setup( | ||||
|     name = "pinecone", | ||||
|     version = "0.2.1", | ||||
|     author = u"Pinecrypt Labs", | ||||
|     author_email = "info@pinecrypt.com", | ||||
|     description = "Pinecrypt Gateway", | ||||
|     license = "MIT", | ||||
|     keywords = "falcon http jinja2 x509 pkcs11 webcrypto kerberos ldap", | ||||
|     url = "http://github.com/laurivosandi/certidude", | ||||
|     packages=[ | ||||
|         "pinecrypt.server", | ||||
|         "pinecrypt.server.api", | ||||
|         "pinecrypt.server.api.utils" | ||||
|     ], | ||||
|     long_description=open("README.md").read(), | ||||
|     # Include here only stuff required to run certidude client | ||||
|     install_requires=[ | ||||
|         "asn1crypto", | ||||
|         "click", | ||||
|         "configparser", | ||||
|         "certbuilder", | ||||
|         "csrbuilder", | ||||
|         "crlbuilder", | ||||
|         "jinja2", | ||||
|     ], | ||||
|     scripts=[ | ||||
|         "misc/pinecone" | ||||
|     ], | ||||
|     include_package_data = True, | ||||
|     package_data={ | ||||
|         "pinecrypt": ["pinecrypt/server/templates/*", "pinecrypt/server/builder/*"], | ||||
|     }, | ||||
|     classifiers=[ | ||||
|         "Development Status :: 4 - Beta", | ||||
|         "Environment :: Console", | ||||
|         "Intended Audience :: Developers", | ||||
|         "Intended Audience :: System Administrators", | ||||
|         "License :: Freely Distributable", | ||||
|         "License :: OSI Approved :: MIT License", | ||||
|         "Natural Language :: English", | ||||
|         "Operating System :: POSIX :: Linux", | ||||
|         "Programming Language :: Python", | ||||
|         "Programming Language :: Python :: 3 :: Only", | ||||
|     ], | ||||
| ) | ||||
|  | ||||
		Reference in New Issue
	
	Block a user