Separated codebase from development repo
This commit is contained in:
parent
be22183439
commit
8ebf375b4b
6
.flake8
Normal file
6
.flake8
Normal file
@ -0,0 +1,6 @@
|
||||
[flake8]
|
||||
inline-quotes = "
|
||||
multiline-quotes = """
|
||||
indent-size = 4
|
||||
max-line-length = 160
|
||||
ignore = Q003 E128 E704
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*.pyc
|
||||
*.swp
|
@ -3,7 +3,7 @@ repos:
|
||||
rev: 3.9.2
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [flake8-typing-imports==1.10.0]
|
||||
additional_dependencies: [flake8-typing-imports==1.10.0,flake8-quotes==3.2.0]
|
||||
|
||||
- repo: https://github.com/jorisroovers/gitlint
|
||||
rev: v0.15.1
|
||||
|
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@ -0,0 +1,28 @@
|
||||
FROM ubuntu:20.04 as base
|
||||
ENV container docker
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV LC_ALL C.UTF-8
|
||||
ENV DEBIAN_FRONTEND noninteractive
|
||||
RUN echo force-unsafe-io > /etc/dpkg/dpkg.cfg.d/docker-apt-speedup \
|
||||
&& echo "Dpkg::Use-Pty=0;" > /etc/apt/apt.conf.d/99quieter \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get install -y -qq \
|
||||
bash build-essential python3-dev cython3 libffi-dev libssl-dev \
|
||||
libkrb5-dev ldap-utils libsasl2-modules-gssapi-mit libsasl2-dev libldap2-dev \
|
||||
python python3-pip python3-cffi iptables ipset \
|
||||
strongswan libstrongswan-extra-plugins libcharon-extra-plugins \
|
||||
openvpn libncurses5-dev gawk wget unzip git rsync \
|
||||
&& apt-get clean \
|
||||
&& rm /etc/dpkg/dpkg.cfg.d/docker-apt-speedup
|
||||
|
||||
WORKDIR /src
|
||||
COPY requirements.txt /src/
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
COPY config/strongswan.conf /etc/strongswan.conf
|
||||
COPY pinecrypt/. /src/pinecrypt/
|
||||
COPY helpers /helpers/
|
||||
COPY MANIFEST.in setup.py README.md /src/
|
||||
COPY misc/. /src/misc/
|
||||
RUN python3 -m compileall .
|
||||
RUN pip3 install --no-cache-dir .
|
||||
RUN rm -Rfv /src
|
6
MANIFEST.in
Normal file
6
MANIFEST.in
Normal file
@ -0,0 +1,6 @@
|
||||
include README.md
|
||||
include pinecrypt/server/templates/mail/*.md
|
||||
include pinecrypt/server/builder/overlay/usr/bin/pinecrypt.server-*
|
||||
include pinecrypt/server/builder/overlay/etc/uci-defaults/*
|
||||
include pinecrypt/server/builder/overlay/etc/profile
|
||||
include pinecrypt/server/builder/*.sh
|
45
config/strongswan.conf
Normal file
45
config/strongswan.conf
Normal file
@ -0,0 +1,45 @@
|
||||
charon {
|
||||
load_modular = yes
|
||||
plugins {
|
||||
include /etc/strongswan.d/charon/ccm.conf
|
||||
include /etc/strongswan.d/charon/nonce.conf
|
||||
include /etc/strongswan.d/charon/updown.conf
|
||||
include /etc/strongswan.d/charon/pkcs11.conf
|
||||
include /etc/strongswan.d/charon/connmark.conf
|
||||
include /etc/strongswan.d/charon/pkcs1.conf
|
||||
include /etc/strongswan.d/charon/constraints.conf
|
||||
include /etc/strongswan.d/charon/revocation.conf
|
||||
include /etc/strongswan.d/charon/openssl.conf
|
||||
include /etc/strongswan.d/charon/aes.conf
|
||||
include /etc/strongswan.d/charon/resolve.conf
|
||||
include /etc/strongswan.d/charon/curve25519.conf
|
||||
include /etc/strongswan.d/charon/gcm.conf
|
||||
include /etc/strongswan.d/charon/random.conf
|
||||
include /etc/strongswan.d/charon/pkcs7.conf
|
||||
include /etc/strongswan.d/charon/af-alg.conf
|
||||
include /etc/strongswan.d/charon/x509.conf
|
||||
include /etc/strongswan.d/charon/rdrand.conf
|
||||
include /etc/strongswan.d/charon/hmac.conf
|
||||
include /etc/strongswan.d/charon/gmp.conf
|
||||
include /etc/strongswan.d/charon/pubkey.conf
|
||||
include /etc/strongswan.d/charon/ctr.conf
|
||||
include /etc/strongswan.d/charon/certexpire.conf
|
||||
include /etc/strongswan.d/charon/socket-default.conf
|
||||
include /etc/strongswan.d/charon/lookip.conf
|
||||
include /etc/strongswan.d/charon/mgf1.conf
|
||||
include /etc/strongswan.d/charon/unity.conf
|
||||
include /etc/strongswan.d/charon/sha2.conf
|
||||
include /etc/strongswan.d/charon/stroke.conf
|
||||
include /etc/strongswan.d/charon/aesni.conf
|
||||
include /etc/strongswan.d/charon/agent.conf
|
||||
include /etc/strongswan.d/charon/kernel-libipsec.conf
|
||||
include /etc/strongswan.d/charon/curl.conf
|
||||
include /etc/strongswan.d/charon/pkcs8.conf
|
||||
include /etc/strongswan.d/charon/cmac.conf
|
||||
include /etc/strongswan.d/charon/attr.conf
|
||||
include /etc/strongswan.d/charon/error-notify.conf
|
||||
include /etc/strongswan.d/charon/pem.conf
|
||||
include /etc/strongswan.d/charon/pkcs12.conf
|
||||
include /etc/strongswan.d/charon/kernel-netlink.conf
|
||||
}
|
||||
}
|
15
helpers/openvpn-client-connect.py
Executable file
15
helpers/openvpn-client-connect.py
Executable file
@ -0,0 +1,15 @@
|
||||
#!/usr/bin/python3
|
||||
import os
|
||||
import sys
|
||||
from pinecrypt.server import db
|
||||
|
||||
# This implements OCSP like functionality
|
||||
|
||||
obj = db.certificates.find_one({
|
||||
# TODO: use digest instead
|
||||
"serial_number": "%x" % int(os.environ["tls_serial_0"]),
|
||||
"status":"signed",
|
||||
})
|
||||
|
||||
if not obj:
|
||||
sys.exit(1)
|
28
helpers/openvpn-learn-address.py
Executable file
28
helpers/openvpn-learn-address.py
Executable file
@ -0,0 +1,28 @@
|
||||
#!/usr/bin/python3
|
||||
import os
|
||||
import sys
|
||||
from pinecrypt.server import db
|
||||
from datetime import datetime
|
||||
|
||||
operation, addr = sys.argv[1:3]
|
||||
if operation == "delete":
|
||||
pass
|
||||
else:
|
||||
common_name = sys.argv[3]
|
||||
db.certificates.update_one({
|
||||
# TODO: use digest instead
|
||||
"serial_number": "%x" % int(os.environ["tls_serial_0"]),
|
||||
"status":"signed",
|
||||
}, {
|
||||
"$set": {
|
||||
"last_seen": datetime.utcnow(),
|
||||
"instance": os.environ["instance"],
|
||||
"remote": {
|
||||
"port": int(os.environ["untrusted_port"]),
|
||||
"addr": os.environ["untrusted_ip"],
|
||||
}
|
||||
},
|
||||
"$addToSet": {
|
||||
"ip": addr
|
||||
}
|
||||
})
|
31
helpers/updown.py
Executable file
31
helpers/updown.py
Executable file
@ -0,0 +1,31 @@
|
||||
#!/usr/bin/python3
|
||||
import sys
|
||||
import os
|
||||
from pinecrypt.server import db
|
||||
from datetime import datetime
|
||||
|
||||
addrs = set()
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith("PLUTO_PEER_SOURCEIP"):
|
||||
addrs.add(value)
|
||||
|
||||
with open("/instance") as fh:
|
||||
instance = fh.read().strip()
|
||||
|
||||
db.certificates.update_one({
|
||||
"distinguished_name": os.environ["PLUTO_PEER_ID"],
|
||||
"status":"signed",
|
||||
}, {
|
||||
"$set": {
|
||||
"last_seen": datetime.utcnow(),
|
||||
"instance": instance,
|
||||
"remote": {
|
||||
"addr": os.environ["PLUTO_PEER"]
|
||||
}
|
||||
},
|
||||
"$addToSet": {
|
||||
"ip": {
|
||||
"$each": list(addrs)
|
||||
}
|
||||
}
|
||||
})
|
6
misc/pinecone
Normal file
6
misc/pinecone
Normal file
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from pinecrypt.server.cli import entry_point
|
||||
|
||||
if __name__ == "__main__":
|
||||
entry_point()
|
0
pinecrypt/__init__.py
Normal file
0
pinecrypt/__init__.py
Normal file
0
pinecrypt/server/__init__.py
Normal file
0
pinecrypt/server/__init__.py
Normal file
0
pinecrypt/server/api/__init__.py
Normal file
0
pinecrypt/server/api/__init__.py
Normal file
44
pinecrypt/server/api/bootstrap.py
Normal file
44
pinecrypt/server/api/bootstrap.py
Normal file
@ -0,0 +1,44 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from pinecrypt.server import authority, const, config
|
||||
from pinecrypt.server.common import cert_to_dn
|
||||
from pinecrypt.server.decorators import serialize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BootstrapResource(object):
|
||||
@serialize
|
||||
def on_get(self, req, resp):
|
||||
"""
|
||||
Return publicly accessible info unlike /api/session
|
||||
"""
|
||||
return dict(
|
||||
hostname=const.FQDN,
|
||||
namespace=const.AUTHORITY_NAMESPACE,
|
||||
replicas=[doc["common_name"] for doc in authority.list_replicas()],
|
||||
globals=list(config.get_all("Globals")),
|
||||
openvpn=dict(
|
||||
tls_version_min=config.get("Globals", "OPENVPN_TLS_VERSION_MIN")["value"],
|
||||
tls_ciphersuites=config.get("Globals", "OPENVPN_TLS_CIPHERSUITES")["value"],
|
||||
tls_cipher=config.get("Globals", "OPENVPN_TLS_CIPHER")["value"],
|
||||
cipher=config.get("Globals", "OPENVPN_CIPHER")["value"],
|
||||
auth=config.get("Globals", "OPENVPN_AUTH")["value"]
|
||||
),
|
||||
strongswan=dict(
|
||||
dhgroup=config.get("Globals", "STRONGSWAN_DHGROUP")["value"],
|
||||
ike=config.get("Globals", "STRONGSWAN_IKE")["value"],
|
||||
esp=config.get("Globals", "STRONGSWAN_ESP")["value"],
|
||||
),
|
||||
certificate=dict(
|
||||
algorithm=authority.public_key.algorithm,
|
||||
common_name=authority.certificate.subject.native["common_name"],
|
||||
distinguished_name=cert_to_dn(authority.certificate),
|
||||
md5sum=hashlib.md5(authority.certificate_buf).hexdigest(),
|
||||
blob=authority.certificate_buf.decode("ascii"),
|
||||
organization=authority.certificate["tbs_certificate"]["subject"].native.get("organization_name"),
|
||||
signed=authority.certificate["tbs_certificate"]["validity"]["not_before"].native.replace(tzinfo=None),
|
||||
expires=authority.certificate["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None)
|
||||
),
|
||||
user_enrollment_allowed=const.USER_ENROLLMENT_ALLOWED,
|
||||
user_multiple_certificates=const.USER_MULTIPLE_CERTIFICATES,
|
||||
)
|
55
pinecrypt/server/api/builder.py
Normal file
55
pinecrypt/server/api/builder.py
Normal file
@ -0,0 +1,55 @@
|
||||
|
||||
import click
|
||||
import os
|
||||
import asyncio
|
||||
from sanic import Sanic
|
||||
from sanic.exceptions import ServerError
|
||||
from sanic.response import file_stream
|
||||
from pinecrypt.server import const
|
||||
|
||||
app = Sanic("builder")
|
||||
app.config.RESPONSE_TIMEOUT = 300
|
||||
|
||||
@app.route("/api/build/")
|
||||
async def view_build(request):
|
||||
build_script_path = "/builder/script/mfp.sh"
|
||||
suffix = "-glinet_gl-ar150-squashfs-sysupgrade.bin"
|
||||
suggested_filename = "mfp%s" % suffix
|
||||
build = "/builder/src"
|
||||
log_path = build + "/build.log"
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
build_script_path,
|
||||
stdout=open(log_path, "w"),
|
||||
close_fds=True,
|
||||
shell=False,
|
||||
cwd=os.path.dirname(os.path.realpath(build_script_path)),
|
||||
env={
|
||||
"PROFILE": "glinet_gl-ar150",
|
||||
"PATH": "/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"AUTHORITY_NAMESPACE": const.AUTHORITY_NAMESPACE,
|
||||
"BUILD": build,
|
||||
"OVERLAY": build + "/overlay/"
|
||||
},
|
||||
startupinfo=None,
|
||||
creationflags=0,
|
||||
)
|
||||
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode:
|
||||
raise ServerError("Build script finished with non-zero exitcode, see %s for more information" % log_path)
|
||||
|
||||
for root, dirs, files in os.walk("/builder/src/bin/targets"):
|
||||
for filename in files:
|
||||
if filename.endswith(suffix):
|
||||
path = os.path.join(root, filename)
|
||||
click.echo("Serving: %s" % path)
|
||||
return await file_stream(
|
||||
path,
|
||||
headers={
|
||||
"Content-Disposition": "attachment; filename=%s" % suggested_filename
|
||||
}
|
||||
)
|
||||
raise ServerError("Failed to find image builder directory in %s" % build)
|
||||
|
121
pinecrypt/server/api/events.py
Normal file
121
pinecrypt/server/api/events.py
Normal file
@ -0,0 +1,121 @@
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from oscrypto import asymmetric
|
||||
from json import dumps
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from pinecrypt.server import const
|
||||
from prometheus_client import Counter
|
||||
from sanic import Sanic
|
||||
from sanic.response import stream
|
||||
from sanic_prometheus import monitor
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
|
||||
streams_opened = Counter("pinecrypt_events_stream_opened",
|
||||
"Event stream opened count")
|
||||
events_emitted = Counter("pinecrypt_events_emitted",
|
||||
"Events emitted count")
|
||||
|
||||
|
||||
app = Sanic("events")
|
||||
monitor(app).expose_endpoint()
|
||||
app.config.RESPONSE_TIMEOUT = 999
|
||||
|
||||
def cookie_login(func):
|
||||
@wraps(func)
|
||||
async def wrapped(request, *args, **kwargs):
|
||||
if request.method != "GET":
|
||||
raise ValueError("For now stick with read-only operations for cookie auth")
|
||||
value = request.cookies.get(const.SESSION_COOKIE)
|
||||
now = datetime.utcnow()
|
||||
await app.db.certidude_sessions.update_one({
|
||||
"secret": value,
|
||||
"started": {
|
||||
"$lte": now
|
||||
},
|
||||
"expires": {
|
||||
"$gte": now
|
||||
},
|
||||
}, {
|
||||
"$set": {
|
||||
"last_seen": now,
|
||||
}
|
||||
})
|
||||
return await func(request, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
@app.listener("before_server_start")
|
||||
async def setup_db(app, loop):
|
||||
# TODO: find cleaner way to do this, for more see
|
||||
# https://github.com/sanic-org/sanic/issues/919
|
||||
app.db = AsyncIOMotorClient(const.MONGO_URI).get_default_database()
|
||||
|
||||
|
||||
# TODO: Change to /api/event/log and simplify nginx config to /api/event
|
||||
@app.route("/api/event/")
|
||||
@cookie_login
|
||||
async def view_event(request):
|
||||
async def g(resp):
|
||||
await resp.write("data: response-generator-started\n\n")
|
||||
streams_opened.inc()
|
||||
|
||||
async with app.db.watch(full_document="updateLookup") as stream:
|
||||
await resp.write("data: watch-stream-opened\n\n")
|
||||
async for event in stream:
|
||||
|
||||
if event.get("ns").get("coll") == "certidude_certificates":
|
||||
if event.get("operationType") == "insert" and event["fullDocument"].get("status") == "csr":
|
||||
await resp.write("event: request-submitted\ndata: %s\n\n" % str(event["documentKey"].get("_id")))
|
||||
events_emitted.inc()
|
||||
|
||||
if event.get("operationType") == "update" and event["updateDescription"].get("updatedFields").get("status") == "signed":
|
||||
await resp.write("event: request-signed\ndata: %s\n\n" % str(event["documentKey"].get("_id")))
|
||||
events_emitted.inc()
|
||||
|
||||
if event.get("operationType") == "insert" and event["fullDocument"].get("status") == "signed":
|
||||
await resp.write("event: request-signed\ndata: %s\n\n" % event["fullDocument"].get("common_name"))
|
||||
events_emitted.inc()
|
||||
|
||||
if event.get("operationType") == "update" and event["fullDocument"].get("status") == "revoked":
|
||||
await resp.write("event: certificate-revoked\ndata: %s\n\n" % str(event["documentKey"].get("_id")))
|
||||
events_emitted.inc()
|
||||
|
||||
if event.get("operationType") == "delete":
|
||||
await resp.write("event: request-deleted\ndata: %s\n\n" % str(event["documentKey"].get("_id")))
|
||||
events_emitted.inc()
|
||||
|
||||
if event.get("operationType") == "update" and "tags" in event.get("updateDescription").get("updatedFields"):
|
||||
await resp.write("event: tag-update\ndata: %s\n\n" % event["fullDocument"].get("common_name"))
|
||||
events_emitted.inc()
|
||||
|
||||
if event.get("operationType") == "update" and "attributes" in event.get("updateDescription").get("updatedFields"):
|
||||
await resp.write("event: attribute-update\ndata: %s\n\n" % str(event["documentKey"].get("_id")))
|
||||
events_emitted.inc()
|
||||
|
||||
|
||||
if event.get("ns").get("coll") == "certidude_logs":
|
||||
|
||||
from pinecrypt.server.decorators import MyEncoder
|
||||
|
||||
obj=dict(
|
||||
created=event["fullDocument"].get("created"),
|
||||
message=event["fullDocument"].get("message"),
|
||||
severity=event["fullDocument"].get("severity")
|
||||
)
|
||||
|
||||
await resp.write("event: log-entry\ndata: %s\n\n" % dumps(obj, cls=MyEncoder))
|
||||
events_emitted.inc()
|
||||
return stream(g, content_type="text/event-stream")
|
||||
|
||||
|
||||
@app.route("/api/event/request-signed/<id>")
|
||||
async def publish(request, id):
|
||||
pipeline = [{"$match": { "operationType": "update", "fullDocument.status": "signed", "documentKey._id": ObjectId(id)}}]
|
||||
resp = await request.respond(content_type="application/x-x509-user-cert")
|
||||
async with app.db["certidude_certificates"].watch(pipeline, full_document="updateLookup") as stream:
|
||||
async for event in stream:
|
||||
cert_der = event["fullDocument"].get("cert_buf")
|
||||
cert_pem = asymmetric.dump_certificate(asymmetric.load_certificate(cert_der))
|
||||
await resp.send(cert_pem, True)
|
||||
return resp
|
13
pinecrypt/server/api/log.py
Normal file
13
pinecrypt/server/api/log.py
Normal file
@ -0,0 +1,13 @@
|
||||
from pinecrypt.server.decorators import serialize
|
||||
from pinecrypt.server import db
|
||||
from .utils.firewall import cookie_login
|
||||
|
||||
class LogResource(object):
|
||||
@serialize
|
||||
@cookie_login
|
||||
def on_get(self, req, resp):
|
||||
def g():
|
||||
for log in db.eventlog.find({}).limit(req.get_param_as_int("limit", required=True)).sort("created", -1):
|
||||
log.pop("_id")
|
||||
yield log
|
||||
return tuple(g())
|
127
pinecrypt/server/api/ocsp.py
Normal file
127
pinecrypt/server/api/ocsp.py
Normal file
@ -0,0 +1,127 @@
|
||||
import pytz
|
||||
from asn1crypto.util import timezone
|
||||
from asn1crypto import ocsp
|
||||
from pinecrypt.server import const, authority
|
||||
from datetime import datetime, timedelta
|
||||
from math import inf
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from oscrypto import asymmetric
|
||||
from prometheus_client import Counter, Histogram
|
||||
from sanic import Sanic, response
|
||||
from sanic_prometheus import monitor
|
||||
|
||||
ocsp_request_valid = Counter("pinecrypt_ocsp_request_valid",
|
||||
"Valid OCSP requests")
|
||||
ocsp_request_list_size = Histogram("pinecrypt_ocsp_request_list_size",
|
||||
"Histogram of OCSP request list size",
|
||||
buckets=(1, 2, 3, inf))
|
||||
ocsp_request_size_bytes = Histogram("pinecrypt_ocsp_request_size_bytes",
|
||||
"Histogram of OCSP request size in bytes",
|
||||
buckets=(100, 200, 500, 1000, 2000, 5000, 10000, inf))
|
||||
ocsp_request_nonces = Histogram("pinecrypt_ocsp_request_nonces",
|
||||
"Histogram of nonce count per request",
|
||||
buckets=(1, 2, 3, inf))
|
||||
ocsp_response_status = Counter("pinecrypt_ocsp_response_status",
|
||||
"Status responses", ["status"])
|
||||
|
||||
app = Sanic("events")
|
||||
monitor(app).expose_endpoint()
|
||||
|
||||
|
||||
@app.listener("before_server_start")
|
||||
async def setup_db(app, loop):
|
||||
# TODO: find cleaner way to do this, for more see
|
||||
# https://github.com/sanic-org/sanic/issues/919
|
||||
app.ctx.db = AsyncIOMotorClient(const.MONGO_URI).get_default_database()
|
||||
|
||||
|
||||
@app.route("/api/ocsp/", methods=["POST"])
|
||||
async def view_ocsp_responder(request):
|
||||
ocsp_request_size_bytes.observe(len(request.body))
|
||||
ocsp_req = ocsp.OCSPRequest.load(request.body)
|
||||
|
||||
server_certificate = authority.get_ca_cert()
|
||||
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
response_extensions = []
|
||||
|
||||
nonces = 0
|
||||
for ext in ocsp_req["tbs_request"]["request_extensions"]:
|
||||
if ext["extn_id"].native == "nonce":
|
||||
nonces += 1
|
||||
response_extensions.append(
|
||||
ocsp.ResponseDataExtension({
|
||||
"extn_id": "nonce",
|
||||
"critical": False,
|
||||
"extn_value": ext["extn_value"]
|
||||
})
|
||||
)
|
||||
|
||||
ocsp_request_nonces.observe(nonces)
|
||||
ocsp_request_valid.inc()
|
||||
|
||||
responses = []
|
||||
|
||||
ocsp_request_list_size.observe(len(ocsp_req["tbs_request"]["request_list"]))
|
||||
for item in ocsp_req["tbs_request"]["request_list"]:
|
||||
serial = item["req_cert"]["serial_number"].native
|
||||
assert serial > 0, "Serial number correctness check failed"
|
||||
|
||||
doc = await app.ctx.db.certidude_certificates.find_one({"serial_number": "%x" % serial})
|
||||
if doc:
|
||||
if doc["status"] == "signed":
|
||||
status = ocsp.CertStatus(name="good", value=None)
|
||||
ocsp_response_status.labels("good").inc()
|
||||
elif doc["status"] == "revoked":
|
||||
status = ocsp.CertStatus(
|
||||
name="revoked",
|
||||
value={
|
||||
"revocation_time": doc["revoked"].replace(tzinfo=pytz.UTC),
|
||||
"revocation_reason": doc["revocation_reason"],
|
||||
})
|
||||
ocsp_response_status.labels("revoked").inc()
|
||||
else:
|
||||
# This should not happen, if it does database is mangled
|
||||
raise ValueError("Invalid/unknown certificate status '%s'" % doc["status"])
|
||||
else:
|
||||
status = ocsp.CertStatus(name="unknown", value=None)
|
||||
ocsp_response_status.labels("unknown").inc()
|
||||
|
||||
responses.append({
|
||||
"cert_id": {
|
||||
"hash_algorithm": {
|
||||
"algorithm": "sha1"
|
||||
},
|
||||
"issuer_name_hash": server_certificate.asn1.subject.sha1,
|
||||
"issuer_key_hash": server_certificate.public_key.asn1.sha1,
|
||||
"serial_number": serial,
|
||||
},
|
||||
"cert_status": status,
|
||||
"this_update": now - const.CLOCK_SKEW_TOLERANCE,
|
||||
"next_update": now + timedelta(minutes=15) + const.CLOCK_SKEW_TOLERANCE,
|
||||
"single_extensions": []
|
||||
})
|
||||
|
||||
response_data = ocsp.ResponseData({
|
||||
"responder_id": ocsp.ResponderId(name="by_key", value=server_certificate.public_key.asn1.sha1),
|
||||
"produced_at": now,
|
||||
"responses": responses,
|
||||
"response_extensions": response_extensions
|
||||
})
|
||||
|
||||
return response.raw(ocsp.OCSPResponse({
|
||||
"response_status": "successful",
|
||||
"response_bytes": {
|
||||
"response_type": "basic_ocsp_response",
|
||||
"response": {
|
||||
"tbs_response_data": response_data,
|
||||
"certs": [server_certificate.asn1],
|
||||
"signature_algorithm": {"algorithm": "sha1_ecdsa" if authority.public_key.algorithm == "ec" else "sha1_rsa" },
|
||||
"signature": (asymmetric.ecdsa_sign if authority.public_key.algorithm == "ec" else asymmetric.rsa_pkcs1v15_sign)(
|
||||
authority.private_key,
|
||||
response_data.dump(),
|
||||
"sha1"
|
||||
)
|
||||
}
|
||||
}
|
||||
}).dump(), headers={"Content-Type": "application/ocsp-response"})
|
268
pinecrypt/server/api/request.py
Normal file
268
pinecrypt/server/api/request.py
Normal file
@ -0,0 +1,268 @@
|
||||
import click
|
||||
import falcon
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
from asn1crypto import pem
|
||||
from asn1crypto.csr import CertificationRequest
|
||||
from pinecrypt.server import const, errors, authority
|
||||
from pinecrypt.server.decorators import csrf_protection, MyEncoder
|
||||
from pinecrypt.server.user import DirectoryConnection
|
||||
from oscrypto import asymmetric
|
||||
from .utils.firewall import whitelist_subnets, whitelist_content_types, \
|
||||
login_required, login_optional, authorize_admin, validate_clock_skew
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
openssl genrsa -out test.key 1024
|
||||
openssl req -new -sha256 -key test.key -out test.csr -subj "/CN=test"
|
||||
curl -f -L -H "Content-type: application/pkcs10" --data-binary @test.csr \
|
||||
http://ca.example.lan/api/request/?wait=yes
|
||||
"""
|
||||
|
||||
class RequestListResource(object):
|
||||
@login_optional
|
||||
@whitelist_subnets(const.REQUEST_SUBNETS)
|
||||
@whitelist_content_types("application/pkcs10")
|
||||
@validate_clock_skew
|
||||
def on_post(self, req, resp):
|
||||
"""
|
||||
Validate and parse certificate signing request, the RESTful way
|
||||
Endpoint urls
|
||||
/request/?wait=yes
|
||||
/request/autosign=1
|
||||
/request
|
||||
"""
|
||||
reasons = []
|
||||
body = req.stream.read(req.content_length)
|
||||
|
||||
try:
|
||||
header, _, der_bytes = pem.unarmor(body)
|
||||
csr = CertificationRequest.load(der_bytes)
|
||||
except ValueError:
|
||||
logger.info("Malformed certificate signing request submission from %s blocked", req.context["remote"]["addr"])
|
||||
raise falcon.HTTPBadRequest(
|
||||
"Bad request",
|
||||
"Malformed certificate signing request")
|
||||
else:
|
||||
req_public_key = asymmetric.load_public_key(csr["certification_request_info"]["subject_pk_info"])
|
||||
if authority.public_key.algorithm != req_public_key.algorithm:
|
||||
logger.info("Attempt to submit %s based request from %s blocked, only %s allowed" % (
|
||||
req_public_key.algorithm.upper(),
|
||||
req.context["remote"]["addr"],
|
||||
authority.public_key.algorithm.upper()))
|
||||
raise falcon.HTTPBadRequest(
|
||||
"Bad request",
|
||||
"Unsupported key algorithm %s, expected %s" % (req_public_key.algorithm, authority.public_key.algorithm))
|
||||
|
||||
try:
|
||||
common_name = csr["certification_request_info"]["subject"].native["common_name"]
|
||||
except KeyError:
|
||||
logger.info("Malformed certificate signing request without common name submitted from %s" % req.context["remote"]["addr"])
|
||||
raise falcon.HTTPBadRequest(title="Bad request",description="Common name missing from certificate signing request")
|
||||
|
||||
"""
|
||||
Determine whether autosign is allowed to overwrite already issued
|
||||
certificates automatically
|
||||
"""
|
||||
|
||||
overwrite_allowed = False
|
||||
for subnet in const.OVERWRITE_SUBNETS:
|
||||
if req.context["remote"]["addr"] in subnet:
|
||||
overwrite_allowed = True
|
||||
break
|
||||
|
||||
|
||||
"""
|
||||
Handle domain computer automatic enrollment
|
||||
"""
|
||||
machine = req.context.get("machine")
|
||||
if machine:
|
||||
reasons.append("machine enrollment not allowed from %s" % req.context["remote"]["addr"])
|
||||
for subnet in const.MACHINE_ENROLLMENT_SUBNETS:
|
||||
if req.context["remote"]["addr"] in subnet:
|
||||
if common_name != machine:
|
||||
raise falcon.HTTPBadRequest(
|
||||
"Bad request",
|
||||
"Common name %s differs from Kerberos credential %s!" % (common_name, machine))
|
||||
|
||||
hit = False
|
||||
with DirectoryConnection() as conn:
|
||||
ft = const.LDAP_COMPUTER_FILTER % ("%s$" % machine)
|
||||
attribs = "cn",
|
||||
r = conn.search_s(const.LDAP_BASE, 2, ft, attribs)
|
||||
for dn, entry in r:
|
||||
if not dn:
|
||||
continue
|
||||
else:
|
||||
hit = True
|
||||
break
|
||||
|
||||
if hit:
|
||||
# Automatic enroll with Kerberos machine cerdentials
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
try:
|
||||
mongo_doc = authority.store_request(body,address=str(req.context["remote"]["addr"]))
|
||||
cert, resp.text = authority.sign(mongo_id=str(mongo_doc["_id"]),
|
||||
profile="Roadwarrior", overwrite=overwrite_allowed) # TODO: handle thrown exception
|
||||
logger.info("Automatically enrolled Kerberos authenticated machine %s (%s) from %s",
|
||||
machine, dn, req.context["remote"]["addr"])
|
||||
return
|
||||
except errors.RequestExists:
|
||||
reasons.append("same request already uploaded exists")
|
||||
# We should still redirect client to long poll URL below
|
||||
except errors.DuplicateCommonNameError:
|
||||
logger.warning("rejected signing request with overlapping common name from %s",
|
||||
req.context["remote"]["addr"])
|
||||
raise falcon.HTTPConflict(
|
||||
"CSR with such CN already exists",
|
||||
"Will not overwrite existing certificate signing request, explicitly delete CSR and try again")
|
||||
|
||||
else:
|
||||
logger.error("Kerberos authenticated machine %s didn't fit the 'ldap computer filter' criteria %s" % (machine, ft))
|
||||
|
||||
|
||||
"""
|
||||
Process automatic signing if the IP address is whitelisted,
|
||||
autosigning was requested and certificate can be automatically signed
|
||||
"""
|
||||
|
||||
if req.get_param_as_bool("autosign"):
|
||||
for subnet in const.AUTOSIGN_SUBNETS:
|
||||
if req.context["remote"]["addr"] in subnet:
|
||||
try:
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
mongo_doc = authority.store_request(body,address=str(req.context["remote"]["addr"]))
|
||||
_, resp.text = authority.sign(mongo_id=str(mongo_doc["_id"]),
|
||||
overwrite=overwrite_allowed, profile="Roadwarrior")
|
||||
|
||||
logger.info("Signed %s as %s is whitelisted for autosign", common_name, req.context["remote"]["addr"])
|
||||
return
|
||||
except EnvironmentError:
|
||||
logger.info("Autosign for %s from %s failed, signed certificate already exists",
|
||||
common_name, req.context["remote"]["addr"])
|
||||
reasons.append("autosign failed, signed certificate already exists")
|
||||
break
|
||||
else:
|
||||
reasons.append("IP address not whitelisted for autosign")
|
||||
else:
|
||||
reasons.append("autosign not requested")
|
||||
|
||||
# Attempt to save the request otherwise
|
||||
try:
|
||||
mongo_doc = authority.store_request(body,
|
||||
address=str(req.context["remote"]["addr"]))
|
||||
except errors.RequestExists:
|
||||
reasons.append("same request already uploaded exists")
|
||||
# We should still redirect client to long poll URL below
|
||||
except errors.DuplicateCommonNameError:
|
||||
logger.warning("rejected signing request with overlapping common name from %s",
|
||||
req.context["remote"]["addr"])
|
||||
raise falcon.HTTPConflict(
|
||||
"CSR with such CN already exists",
|
||||
"Will not overwrite existing certificate signing request, explicitly delete CSR and try again")
|
||||
|
||||
# Wait the certificate to be signed if waiting is requested
|
||||
logger.info("Signing request %s from %s put on hold, %s", common_name, req.context["remote"]["addr"], ", ".join(reasons))
|
||||
|
||||
if req.get_param("wait"):
|
||||
header, _, der_bytes = pem.unarmor(body)
|
||||
url = "https://%s/api/event/request-signed/%s" % (const.AUTHORITY_NAMESPACE, str(mongo_doc["_id"]))
|
||||
click.echo("Redirecting to: %s" % url)
|
||||
resp.status = falcon.HTTP_SEE_OTHER
|
||||
resp.set_header("Location", url)
|
||||
else:
|
||||
# Request was accepted, but not processed
|
||||
resp.status = falcon.HTTP_202
|
||||
resp.text = ". ".join(reasons)
|
||||
|
||||
if req.client_accepts("application/json"):
|
||||
resp.text = json.dumps({"title":"Accepted", "description":resp.text, "id":str(mongo_doc["_id"])},
|
||||
cls=MyEncoder)
|
||||
|
||||
|
||||
class RequestDetailResource(object):
|
||||
def on_get(self, req, resp, id):
|
||||
"""
|
||||
Fetch certificate signing request as PEM
|
||||
"""
|
||||
|
||||
try:
|
||||
csr, csr_doc, buf = authority.get_request(id)
|
||||
except errors.RequestDoesNotExist:
|
||||
logger.warning("Failed to serve non-existant request %s to %s",
|
||||
id, req.context["remote"]["addr"])
|
||||
raise falcon.HTTPNotFound()
|
||||
|
||||
resp.set_header("Content-Type", "application/pkcs10")
|
||||
logger.debug("Signing request %s was downloaded by %s",
|
||||
csr_doc["common_name"], req.context["remote"]["addr"])
|
||||
|
||||
preferred_type = req.client_prefers(("application/json", "application/x-pem-file"))
|
||||
|
||||
if preferred_type == "application/x-pem-file":
|
||||
# For certidude client, curl scripts etc
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
resp.set_header("Content-Disposition", ("attachment; filename=%s.pem" % csr_doc["common_name"]))
|
||||
resp.text = buf
|
||||
elif preferred_type == "application/json":
|
||||
# For web interface events
|
||||
resp.set_header("Content-Type", "application/json")
|
||||
resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % csr_doc["common_name"]))
|
||||
resp.text = json.dumps(dict(
|
||||
submitted=csr_doc["submitted"],
|
||||
common_name=csr_doc["common_name"],
|
||||
id=str(csr_doc["_id"]),
|
||||
address=csr_doc["user"]["request_addresss"],
|
||||
md5sum=hashlib.md5(buf).hexdigest(),
|
||||
sha1sum=hashlib.sha1(buf).hexdigest(),
|
||||
sha256sum=hashlib.sha256(buf).hexdigest(),
|
||||
sha512sum=hashlib.sha512(buf).hexdigest()), cls=MyEncoder)
|
||||
else:
|
||||
raise falcon.HTTPUnsupportedMediaType(
|
||||
"Client did not accept application/json or application/x-pem-file")
|
||||
|
||||
|
||||
@csrf_protection
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_post(self, req, resp, id):
|
||||
"""
|
||||
Sign a certificate signing request
|
||||
"""
|
||||
try:
|
||||
cert, buf = authority.sign(mongo_id=id,
|
||||
profile=req.get_param("profile", default="Roadwarrior"),
|
||||
overwrite=True,
|
||||
signer=req.context.get("user").name) #if user is cached in browser then there is no name
|
||||
# Mailing and long poll publishing implemented in the function above
|
||||
except EnvironmentError: # no such CSR
|
||||
raise falcon.HTTPNotFound(title="Not found",description="CSR not found with id %s" % id)
|
||||
|
||||
resp.text = "Certificate successfully signed"
|
||||
resp.status = falcon.HTTP_201
|
||||
resp.location = req.forwarded_uri.replace("request","sign")
|
||||
|
||||
cn = cert.subject.native.get("common_name")
|
||||
logger.info("Signing request %s signed by %s from %s", cn,
|
||||
req.context.get("user"), req.context["remote"]["addr"])
|
||||
|
||||
|
||||
@csrf_protection
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_delete(self, req, resp, id):
|
||||
try:
|
||||
authority.delete_request(id, user=req.context.get("user"))
|
||||
# Logging implemented in the function above
|
||||
except errors.RequestDoesNotExist as e:
|
||||
resp.text = "No certificate signing request for with id %s not found" % id
|
||||
logger.warning("User %s failed to delete signing request %s from %s, reason: %s",
|
||||
req.context["user"], id, req.context["remote"]["addr"], e)
|
||||
raise falcon.HTTPNotFound()
|
||||
except ValueError as e:
|
||||
resp.text = "No ID specified %s" % id
|
||||
logger.warning("User %s wanted to delete invalid signing request %s from %s, reason: %s",
|
||||
req.context["user"], id, req.context["remote"]["addr"], e)
|
||||
raise falcon.HTTPBadRequest()
|
46
pinecrypt/server/api/revoked.py
Normal file
46
pinecrypt/server/api/revoked.py
Normal file
@ -0,0 +1,46 @@
|
||||
import falcon
|
||||
import logging
|
||||
from pinecrypt.server import authority, const, errors
|
||||
from .utils.firewall import whitelist_subnets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RevocationListResource(object):
|
||||
@whitelist_subnets(const.CRL_SUBNETS)
|
||||
def on_get(self, req, resp):
|
||||
# Primarily offer DER encoded CRL as per RFC5280
|
||||
# This is also what StrongSwan expects
|
||||
if req.client_accepts("application/x-pkcs7-crl"):
|
||||
resp.set_header("Content-Type", "application/x-pkcs7-crl")
|
||||
resp.append_header(
|
||||
"Content-Disposition",
|
||||
("attachment; filename=%s.crl" % const.HOSTNAME))
|
||||
# Convert PEM to DER
|
||||
logger.debug("Serving revocation list (DER) to %s", req.context["remote"]["addr"])
|
||||
resp.text = authority.export_crl(pem=False)
|
||||
elif req.client_accepts("application/x-pem-file"):
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
resp.append_header(
|
||||
"Content-Disposition",
|
||||
("attachment; filename=%s-crl.pem" % const.HOSTNAME))
|
||||
logger.debug("Serving revocation list (PEM) to %s", req.context["remote"]["addr"])
|
||||
resp.text = authority.export_crl()
|
||||
else:
|
||||
logger.debug("Client %s asked revocation list in unsupported format" % req.context["remote"]["addr"])
|
||||
raise falcon.HTTPUnsupportedMediaType(
|
||||
"Client did not accept application/x-pkcs7-crl or application/x-pem-file")
|
||||
|
||||
|
||||
class RevokedCertificateDetailResource(object):
|
||||
def on_get(self, req, resp, serial_number):
|
||||
try:
|
||||
cert_doc, buf = authority.get_revoked(serial_number)
|
||||
except errors.CertificateDoesNotExist:
|
||||
logger.warning("Failed to serve non-existant revoked certificate with serial %s to %s",
|
||||
serial_number, req.context["remote"]["addr"])
|
||||
raise falcon.HTTPNotFound()
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
resp.set_header("Content-Disposition", ("attachment; filename=%s.pem" % cert_doc["serial_number"]))
|
||||
resp.text = buf
|
||||
logger.debug("Served revoked certificate with serial %s to %s",
|
||||
cert_doc["serial_number"], req.context["remote"]["addr"])
|
29
pinecrypt/server/api/script.py
Normal file
29
pinecrypt/server/api/script.py
Normal file
@ -0,0 +1,29 @@
|
||||
import logging
|
||||
import os
|
||||
from pinecrypt.server import authority, const
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from .utils.firewall import whitelist_subject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
env = Environment(loader=FileSystemLoader(const.SCRIPT_DIR), trim_blocks=True)
|
||||
|
||||
class ScriptResource(object):
|
||||
@whitelist_subject
|
||||
def on_get(self, req, resp, id):
|
||||
path, buf, cert, attribs = authority.get_attributes(id)
|
||||
# TODO: are keys unique?
|
||||
named_tags = {}
|
||||
other_tags = []
|
||||
cn = cert["common_name"]
|
||||
|
||||
script = named_tags.get("script", "default.sh")
|
||||
assert script in os.listdir(const.SCRIPT_DIR)
|
||||
resp.set_header("Content-Type", "text/x-shellscript")
|
||||
resp.body = env.get_template(os.path.join(script)).render(
|
||||
authority_name=const.FQDN,
|
||||
common_name=cn,
|
||||
other_tags=other_tags,
|
||||
named_tags=named_tags,
|
||||
attributes=attribs.get("user").get("machine"))
|
||||
logger.info("Served script %s for %s at %s" % (script, cn, req.context["remote"]["addr"]))
|
||||
# TODO: Assert time is within reasonable range
|
185
pinecrypt/server/api/session.py
Normal file
185
pinecrypt/server/api/session.py
Normal file
@ -0,0 +1,185 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from pinecrypt.server import authority, const, config
|
||||
from pinecrypt.server.decorators import serialize, csrf_protection
|
||||
from pinecrypt.server.user import User
|
||||
from .utils.firewall import login_required, authorize_admin, register_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CertificateAuthorityResource(object):
|
||||
def on_get(self, req, resp):
|
||||
logger.info("Served CA certificate to %s", req.context["remote"]["addr"])
|
||||
resp.stream = open(const.AUTHORITY_CERTIFICATE_PATH, "rb")
|
||||
resp.append_header("Content-Type", "application/x-x509-ca-cert")
|
||||
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" %
|
||||
const.HOSTNAME)
|
||||
|
||||
|
||||
class SessionResource(object):
|
||||
|
||||
def __init__(self, manager):
|
||||
self.token_manager = manager
|
||||
|
||||
@csrf_protection
|
||||
@serialize
|
||||
@login_required
|
||||
@register_session
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
def serialize_requests(g):
|
||||
for csr, request, server in g():
|
||||
try:
|
||||
submission_address = request["user"]["request_address"]
|
||||
except KeyError:
|
||||
submission_address = None
|
||||
try:
|
||||
submission_hostname = request["user"]["request_hostname"]
|
||||
except KeyError:
|
||||
submission_hostname = None
|
||||
yield dict(
|
||||
id=str(request["_id"]),
|
||||
submitted=request["submitted"],
|
||||
common_name=request["common_name"],
|
||||
address=submission_address,
|
||||
hostname=submission_hostname if submission_hostname != submission_address else None,
|
||||
md5sum=hashlib.md5(request["request_buf"]).hexdigest(),
|
||||
sha1sum=hashlib.sha1(request["request_buf"]).hexdigest(),
|
||||
sha256sum=hashlib.sha256(request["request_buf"]).hexdigest(),
|
||||
sha512sum=hashlib.sha512(request["request_buf"]).hexdigest()
|
||||
)
|
||||
|
||||
def serialize_revoked(g):
|
||||
for cert_obj, cert in g(limit=5):
|
||||
yield dict(
|
||||
id=str(cert_obj["_id"]),
|
||||
serial="%x" % cert.serial_number,
|
||||
common_name=cert_obj["common_name"],
|
||||
# TODO: key type, key length, key exponent, key modulo
|
||||
signed=cert_obj["signed"],
|
||||
expired=cert_obj["expires"],
|
||||
revoked=cert_obj["revoked"],
|
||||
reason=cert_obj["revocation_reason"],
|
||||
sha256sum=hashlib.sha256(cert_obj["cert_buf"]).hexdigest())
|
||||
|
||||
def serialize_certificates(g):
|
||||
for cert_doc, cert in g():
|
||||
try:
|
||||
tags = cert_doc["tags"]
|
||||
except KeyError: # No tags
|
||||
tags = None
|
||||
|
||||
|
||||
# TODO: Load attributes from databse
|
||||
attributes = {}
|
||||
|
||||
try:
|
||||
lease = dict(
|
||||
inner_address=cert_doc["ip"],
|
||||
outer_address=cert_doc["remote"]["addr"],
|
||||
last_seen=cert_doc["last_seen"],
|
||||
)
|
||||
except KeyError: # No such attribute(s)
|
||||
lease = None
|
||||
|
||||
try:
|
||||
#signer_username = getxattr(path, "user.signature.username").decode("ascii")
|
||||
signer_username = cert_doc["user"]["signature"]["username"]
|
||||
except KeyError:
|
||||
signer_username = None
|
||||
|
||||
# TODO: dedup
|
||||
serialized = dict(
|
||||
id=str(cert_doc["_id"]),
|
||||
serial="%x" % cert.serial_number,
|
||||
organizational_unit=cert.subject.native.get("organizational_unit_name"),
|
||||
common_name=cert_doc["common_name"],
|
||||
# TODO: key type, key length, key exponent, key modulo
|
||||
signed=cert_doc["signed"],
|
||||
expires=cert_doc["expires"],
|
||||
sha256sum=hashlib.sha256(cert_doc["cert_buf"]).hexdigest(),
|
||||
signer=signer_username,
|
||||
lease=lease,
|
||||
tags=tags,
|
||||
attributes=attributes or None,
|
||||
responder_url=None
|
||||
)
|
||||
|
||||
for e in cert["tbs_certificate"]["extensions"].native:
|
||||
if e["extn_id"] == "key_usage":
|
||||
serialized["key_usage"] = e["extn_value"]
|
||||
elif e["extn_id"] == "extended_key_usage":
|
||||
serialized["extended_key_usage"] = e["extn_value"]
|
||||
elif e["extn_id"] == "basic_constraints":
|
||||
serialized["basic_constraints"] = e["extn_value"]
|
||||
elif e["extn_id"] == "crl_distribution_points":
|
||||
for c in e["extn_value"]:
|
||||
serialized["revoked_url"] = c["distribution_point"]
|
||||
break
|
||||
serialized["extended_key_usage"] = e["extn_value"]
|
||||
elif e["extn_id"] == "authority_information_access":
|
||||
for a in e["extn_value"]:
|
||||
if a["access_method"] == "ocsp":
|
||||
serialized["responder_url"] = a["access_location"]
|
||||
else:
|
||||
raise NotImplementedError("Don't know how to handle AIA access method %s" % a["access_method"])
|
||||
elif e["extn_id"] == "authority_key_identifier":
|
||||
pass
|
||||
elif e["extn_id"] == "key_identifier":
|
||||
pass
|
||||
elif e["extn_id"] == "subject_alt_name":
|
||||
serialized["subject_alt_name"] = e["extn_value"][0]
|
||||
else:
|
||||
raise NotImplementedError("Don't know how to handle extension %s" % e["extn_id"])
|
||||
yield serialized
|
||||
|
||||
logger.info("Logged in authority administrator %s from %s with %s" % (
|
||||
req.context.get("user"), req.context["remote"]["addr"], req.context["remote"]["user_agent"]))
|
||||
return dict(
|
||||
user=dict(
|
||||
name=req.context.get("user").name,
|
||||
gn=req.context.get("user").given_name,
|
||||
sn=req.context.get("user").surname,
|
||||
mail=req.context.get("user").mail
|
||||
),
|
||||
request_submission_allowed=const.REQUEST_SUBMISSION_ALLOWED,
|
||||
service=dict(
|
||||
protocols=const.SERVICE_PROTOCOLS,
|
||||
),
|
||||
builder=dict(
|
||||
profiles=const.IMAGE_BUILDER_PROFILES or None
|
||||
),
|
||||
tokens=self.token_manager.list() if self.token_manager else None,
|
||||
tagging=[dict(name=t[0], type=t[1], title=t[0]) for t in const.TAG_TYPES],
|
||||
|
||||
mailer=dict(
|
||||
name=const.SMTP_SENDER_NAME,
|
||||
address=const.SMTP_SENDER_ADDR
|
||||
) if const.SMTP_SENDER_ADDR else None,
|
||||
events="/api/event/",
|
||||
requests=serialize_requests(authority.list_requests),
|
||||
signed=serialize_certificates(authority.list_signed),
|
||||
revoked=serialize_revoked(authority.list_revoked),
|
||||
signature=dict(
|
||||
revocation_list_lifetime=const.REVOCATION_LIST_LIFETIME,
|
||||
profiles=config.options("SignatureProfile"),
|
||||
),
|
||||
authorization=dict(
|
||||
admin_users=User.objects.filter_admins(),
|
||||
|
||||
user_subnets=const.USER_SUBNETS or None,
|
||||
autosign_subnets=const.AUTOSIGN_SUBNETS or None,
|
||||
request_subnets=const.REQUEST_SUBNETS or None,
|
||||
machine_enrollment_subnets=const.MACHINE_ENROLLMENT_SUBNETS or None,
|
||||
admin_subnets=const.ADMIN_SUBNETS or None,
|
||||
|
||||
ocsp_subnets=const.OCSP_SUBNETS or None,
|
||||
crl_subnets=const.CRL_SUBNETS or None,
|
||||
),
|
||||
features=dict(
|
||||
token=True,
|
||||
tagging=True,
|
||||
leases=True,
|
||||
logging=True)
|
||||
)
|
84
pinecrypt/server/api/signed.py
Normal file
84
pinecrypt/server/api/signed.py
Normal file
@ -0,0 +1,84 @@
|
||||
|
||||
import falcon
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
from pinecrypt.server import authority, errors
|
||||
from pinecrypt.server.decorators import csrf_protection
|
||||
from .utils.firewall import login_required, authorize_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SignedCertificateDetailResource(object):
|
||||
def on_get_cn(self, req, resp, cn):
|
||||
try:
|
||||
id = authority.get_common_name_id(cn)
|
||||
except ValueError:
|
||||
raise falcon.HTTPNotFound("Unknown Common name",
|
||||
"Object not found with common name %s" % cn)
|
||||
|
||||
id = authority.get_common_name_id(cn)
|
||||
url = req.forwarded_uri.replace(cn,"id/%s" % id)
|
||||
|
||||
resp.status = falcon.HTTP_307
|
||||
resp.location = url
|
||||
|
||||
|
||||
def on_get(self, req, resp, id):
|
||||
preferred_type = req.client_prefers(("application/json", "application/x-pem-file"))
|
||||
try:
|
||||
cert, cert_doc, pem_buf = authority.get_signed(mongo_id=id)
|
||||
except errors.CertificateDoesNotExist:
|
||||
logger.warning("Failed to serve non-existant certificate %s to %s",
|
||||
id, req.context["remote"]["addr"])
|
||||
raise falcon.HTTPNotFound()
|
||||
|
||||
cn = cert_doc["common_name"]
|
||||
|
||||
if preferred_type == "application/x-pem-file":
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
resp.set_header("Content-Disposition", ("attachment; filename=%s.pem" % cn))
|
||||
resp.text = pem_buf
|
||||
logger.debug("Served certificate %s to %s as application/x-pem-file",
|
||||
cn, req.context["remote"]["addr"])
|
||||
elif preferred_type == "application/json":
|
||||
resp.set_header("Content-Type", "application/json")
|
||||
resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % cn))
|
||||
try:
|
||||
signer_username = cert_doc["user"]["signature"]["username"]
|
||||
except KeyError:
|
||||
signer_username = None
|
||||
|
||||
resp.text = json.dumps(dict(
|
||||
common_name=cn,
|
||||
id=str(cert_doc["_id"]),
|
||||
signer=signer_username,
|
||||
serial="%040x" % cert.serial_number,
|
||||
organizational_unit=cert.subject.native.get("organizational_unit_name"),
|
||||
signed=cert["tbs_certificate"]["validity"]["not_before"].native.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
|
||||
expires=cert["tbs_certificate"]["validity"]["not_after"].native.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
|
||||
sha256sum=hashlib.sha256(pem_buf).hexdigest(),
|
||||
attributes=None,
|
||||
lease=None,
|
||||
extensions=dict([
|
||||
(e["extn_id"].native, e["extn_value"].native)
|
||||
for e in cert["tbs_certificate"]["extensions"]
|
||||
if e["extn_id"].native in ("extended_key_usage",)])
|
||||
|
||||
))
|
||||
logger.debug("Served certificate %s to %s as application/json",
|
||||
cn, req.context["remote"]["addr"])
|
||||
else:
|
||||
logger.debug("Client did not accept application/json or application/x-pem-file")
|
||||
raise falcon.HTTPUnsupportedMediaType(
|
||||
"Client did not accept application/json or application/x-pem-file")
|
||||
|
||||
@csrf_protection
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_delete(self, req, resp, id):
|
||||
authority.revoke(id,
|
||||
reason=req.get_param("reason", default="key_compromise"),
|
||||
user=req.context.get("user")
|
||||
)
|
||||
|
64
pinecrypt/server/api/tag.py
Normal file
64
pinecrypt/server/api/tag.py
Normal file
@ -0,0 +1,64 @@
|
||||
from pinecrypt.server import db
|
||||
from pinecrypt.server.decorators import serialize, csrf_protection
|
||||
from .utils.firewall import login_required, authorize_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TagResource(object):
|
||||
@serialize
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp, id):
|
||||
tags = db.certificates.find_one({"_id": db.ObjectId(id), "status": "signed"}).get("tags")
|
||||
return tags
|
||||
|
||||
@csrf_protection
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_post(self, req, resp, id):
|
||||
# TODO: Sanitize input
|
||||
key, value = req.get_param("key", required=True), req.get_param("value", required=True)
|
||||
db.certificates.update_one({
|
||||
"_id": db.ObjectId(id),
|
||||
"status": "signed"
|
||||
}, {
|
||||
"$addToSet": {"tags": "%s=%s" % (key, value)}
|
||||
})
|
||||
|
||||
|
||||
class TagDetailResource(object):
|
||||
@csrf_protection
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_put(self, req, resp, id, tag):
|
||||
key = tag
|
||||
if "=" in tag:
|
||||
key, prev_value = tag.split("=")
|
||||
|
||||
value = req.get_param("value", required=True)
|
||||
# TODO: Make atomic https://docs.mongodb.com/manual/reference/operator/update-array/
|
||||
db.certificates.find_one_and_update({
|
||||
"_id": db.ObjectId(id),
|
||||
"status": "signed"
|
||||
}, {
|
||||
"$pull": {"tags": tag}
|
||||
})
|
||||
|
||||
db.certificates.find_one_and_update({
|
||||
"_id": db.ObjectId(id),
|
||||
"status": "signed"
|
||||
}, {
|
||||
"$addToSet": {"tags": "%s=%s" % (key, value)}
|
||||
})
|
||||
|
||||
|
||||
@csrf_protection
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_delete(self, req, resp, id, tag):
|
||||
db.certificates.find_one_and_update({
|
||||
"_id": db.ObjectId(id),
|
||||
"status": "signed"
|
||||
}, {
|
||||
"$pull": {"tags": tag}
|
||||
})
|
66
pinecrypt/server/api/token.py
Normal file
66
pinecrypt/server/api/token.py
Normal file
@ -0,0 +1,66 @@
|
||||
import falcon
|
||||
import logging
|
||||
import re
|
||||
from asn1crypto import pem
|
||||
from asn1crypto.csr import CertificationRequest
|
||||
from pinecrypt.server import const, errors, authority
|
||||
from pinecrypt.server.decorators import serialize
|
||||
from pinecrypt.server.user import User
|
||||
from .utils.firewall import login_required, authorize_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TokenResource(object):
|
||||
def __init__(self, manager):
|
||||
self.manager = manager
|
||||
|
||||
def on_put(self, req, resp):
|
||||
try:
|
||||
username, mail, created, expires, profile = self.manager.consume(req.get_param("token", required=True))
|
||||
except errors.TokenDoesNotExist:
|
||||
raise falcon.HTTPForbidden("Forbidden", "No such token or token expired")
|
||||
body = req.stream.read(req.content_length)
|
||||
header, _, der_bytes = pem.unarmor(body)
|
||||
csr = CertificationRequest.load(der_bytes)
|
||||
|
||||
try:
|
||||
common_name = csr["certification_request_info"]["subject"].native["common_name"]
|
||||
except KeyError:
|
||||
logger.info("Malformed certificate signing request without common name token submitted from %s" % req.context["remote"]["addr"])
|
||||
raise falcon.HTTPBadRequest(title="Bad request",description="Common name missing from certificate signing request token")
|
||||
|
||||
if not re.match(const.RE_COMMON_NAME, common_name):
|
||||
raise falcon.HTTPBadRequest("Bad request", "Invalid common name %s" % common_name)
|
||||
|
||||
try:
|
||||
mongo_doc = authority.store_request(body, overwrite=const.TOKEN_OVERWRITE_PERMITTED,
|
||||
namespace="%s.%s" % (username, const.USER_NAMESPACE), address=str(req.context["remote"]["addr"]))
|
||||
_, resp.text = authority.sign(mongo_id=str(mongo_doc["_id"]), profile=profile,
|
||||
overwrite=const.TOKEN_OVERWRITE_PERMITTED,
|
||||
namespace="%s.%s" % (username, const.USER_NAMESPACE))
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
logger.info("Autosigned %s as proven by token ownership", common_name)
|
||||
except errors.DuplicateCommonNameError:
|
||||
logger.info("Another request with same common name already exists", common_name)
|
||||
raise falcon.HTTPConflict(
|
||||
title="CSR with such common name (CN) already exists",
|
||||
description="Will not overwrite existing certificate signing request, explicitly delete existing one and try again")
|
||||
except FileExistsError:
|
||||
logger.info("Won't autosign duplicate %s", common_name)
|
||||
raise falcon. |