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 |     rev: 3.9.2 | ||||||
|     hooks: |     hooks: | ||||||
|     -   id: flake8 |     -   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 | -   repo: https://github.com/jorisroovers/gitlint | ||||||
|     rev: v0.15.1 |     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