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