Separated codebase from development repo

This commit is contained in:
Lauri Võsandi 2021-05-27 15:38:44 +03:00
parent be22183439
commit 8ebf375b4b
49 changed files with 3678 additions and 1 deletions

6
.flake8 Normal file
View 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
View File

@ -0,0 +1,2 @@
*.pyc
*.swp

View File

@ -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
View 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
View 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

0
README.md Normal file
View File

45
config/strongswan.conf Normal file
View 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
}
}

View 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)

View 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
View 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
View 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
View File

View File

View File

View 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,
)

View 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)

View 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

View 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())

View 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"})

View 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()

View 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"])

View 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

View 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)
)

View 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")
)

View 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}
})

View 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"))

View 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

View 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
View 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()

View 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)

View 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
View 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
View 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"]

View 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

View 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

View 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())

View 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"))

View 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())

View 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))

View 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.

View 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 %}

View 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 %}

View 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.

View File

@ -0,0 +1,3 @@
Test mail
Testing!

View 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 %}.

View 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
View 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
View 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
View 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",
],
)