certidude/certidude/api.py

464 lines
18 KiB
Python
Raw Normal View History

2015-07-12 19:22:10 +00:00
import re
import falcon
2015-08-16 15:09:06 +00:00
import ipaddress
import mimetypes
2015-07-12 19:22:10 +00:00
import os
import json
import types
2015-07-26 20:34:46 +00:00
import click
from time import sleep
from certidude.wrappers import Request, Certificate, CertificateAuthority, \
CertificateAuthorityConfig
from certidude.auth import login_required
from OpenSSL import crypto
2015-07-26 20:34:46 +00:00
from pyasn1.codec.der import decoder
2015-07-12 19:22:10 +00:00
from datetime import datetime, date
2015-07-26 20:34:46 +00:00
from jinja2 import Environment, PackageLoader, Template
# TODO: Restrictive filesystem permissions result in TemplateNotFound exceptions
2015-07-26 20:34:46 +00:00
env = Environment(loader=PackageLoader("certidude", "templates"))
2015-07-12 19:22:10 +00:00
RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$"
def omit(**kwargs):
return dict([(key,value) for (key, value) in kwargs.items() if value])
2015-07-26 20:34:46 +00:00
def event_source(func):
def wrapped(self, req, resp, ca, *args, **kwargs):
if req.get_header("Accept") == "text/event-stream":
resp.status = falcon.HTTP_SEE_OTHER
resp.location = ca.push_server + "/ev/" + ca.uuid
resp.body = "Redirecting to:" + resp.location
print("Delegating EventSource handling to:", resp.location)
return func(self, req, resp, ca, *args, **kwargs)
return wrapped
def authorize_admin(func):
def wrapped(self, req, resp, *args, **kwargs):
2015-08-16 15:09:06 +00:00
authority = kwargs.get("ca")
# Parse remote IPv4/IPv6 address
remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"])
# Check for administration subnet whitelist
print("Comparing:", authority.admin_subnets, "To:", remote_addr)
for subnet in authority.admin_subnets:
if subnet.overlaps(remote_addr):
break
else:
raise falcon.HTTPForbidden("Forbidden", "Remote address %s not whitelisted" % remote_addr)
# Check for username whitelist
kerberos_username, kerberos_realm = kwargs.get("user")
2015-08-16 15:09:06 +00:00
if kerberos_username not in authority.admin_users:
raise falcon.HTTPForbidden("Forbidden", "User %s not whitelisted" % kerberos_username)
# Retain username, TODO: Better abstraction with username, e-mail, sn, gn?
kwargs["user"] = kerberos_username
return func(self, req, resp, *args, **kwargs)
return wrapped
2015-07-12 19:22:10 +00:00
def pop_certificate_authority(func):
def wrapped(self, req, resp, *args, **kwargs):
kwargs["ca"] = self.config.instantiate_authority(kwargs["ca"])
return func(self, req, resp, *args, **kwargs)
return wrapped
2015-07-26 20:34:46 +00:00
2015-07-12 19:22:10 +00:00
def validate_common_name(func):
def wrapped(*args, **kwargs):
if not re.match(RE_HOSTNAME, kwargs["cn"]):
raise falcon.HTTPBadRequest("Invalid CN", "Common name supplied with request didn't pass the validation regex")
return func(*args, **kwargs)
return wrapped
2015-07-26 20:34:46 +00:00
2015-07-12 19:22:10 +00:00
class MyEncoder(json.JSONEncoder):
REQUEST_ATTRIBUTES = "signable", "subject", "changed", "common_name", \
"organizational_unit", "given_name", "surname", "fqdn", "email_address", \
"key_type", "key_length", "md5sum", "sha1sum", "sha256sum", "key_usage"
CERTIFICATE_ATTRIBUTES = "revokable", "subject", "changed", "common_name", \
"organizational_unit", "given_name", "surname", "fqdn", "email_address", \
"key_type", "key_length", "sha256sum", "serial_number", "key_usage"
2015-07-12 19:22:10 +00:00
def default(self, obj):
if isinstance(obj, crypto.X509Name):
try:
return "".join(["/%s=%s" % (k.decode("ascii"),v.decode("utf-8")) for k, v in obj.get_components()])
except UnicodeDecodeError: # Work around old buggy pyopenssl
return "".join(["/%s=%s" % (k.decode("ascii"),v.decode("iso8859")) for k, v in obj.get_components()])
if isinstance(obj, ipaddress._IPAddressBase):
return str(obj)
if isinstance(obj, set):
return tuple(obj)
2015-07-12 19:22:10 +00:00
if isinstance(obj, datetime):
2015-07-26 20:34:46 +00:00
return obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
2015-07-12 19:22:10 +00:00
if isinstance(obj, date):
2015-07-26 20:34:46 +00:00
return obj.strftime("%Y-%m-%d")
2015-07-12 19:22:10 +00:00
if isinstance(obj, map):
return tuple(obj)
if isinstance(obj, types.GeneratorType):
return tuple(obj)
if isinstance(obj, Request):
return dict([(key, getattr(obj, key)) for key in self.REQUEST_ATTRIBUTES \
if hasattr(obj, key) and getattr(obj, key)])
if isinstance(obj, Certificate):
return dict([(key, getattr(obj, key)) for key in self.CERTIFICATE_ATTRIBUTES \
if hasattr(obj, key) and getattr(obj, key)])
if isinstance(obj, CertificateAuthority):
return dict(
slug = obj.slug,
certificate = obj.certificate,
admin_users = obj.admin_users,
autosign_subnets = obj.autosign_subnets,
request_subnets = obj.request_subnets,
admin_subnets=obj.admin_subnets,
requests=obj.get_requests(),
signed=obj.get_signed(),
revoked=obj.get_revoked()
)
if hasattr(obj, "serialize"):
return obj.serialize()
2015-07-12 19:22:10 +00:00
return json.JSONEncoder.default(self, obj)
2015-07-26 20:34:46 +00:00
2015-07-12 19:22:10 +00:00
def serialize(func):
"""
Falcon response serialization
"""
def wrapped(instance, req, resp, **kwargs):
assert not req.get_param("unicode") or req.get_param("unicode") == u"", "Unicode sanity check failed"
resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate");
resp.set_header("Pragma", "no-cache");
resp.set_header("Expires", "0");
r = func(instance, req, resp, **kwargs)
if resp.body is None:
2015-07-12 19:22:10 +00:00
if not req.client_accepts_json:
raise falcon.HTTPUnsupportedMediaType(
2015-07-26 20:34:46 +00:00
"This API only supports the JSON media type.",
href="http://docs.examples.com/api/json")
resp.set_header("Content-Type", "application/json")
2015-07-12 19:22:10 +00:00
resp.body = json.dumps(r, cls=MyEncoder)
return r
return wrapped
2015-07-26 20:34:46 +00:00
2015-07-12 19:22:10 +00:00
def templatize(path):
template = env.get_template(path)
def wrapper(func):
def wrapped(instance, req, resp, *args, **kwargs):
2015-07-12 19:22:10 +00:00
assert not req.get_param("unicode") or req.get_param("unicode") == u"", "Unicode sanity check failed"
r = func(instance, req, resp, *args, **kwargs)
r.pop("self")
2015-07-12 19:22:10 +00:00
if not resp.body:
if req.get_header("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");
2015-07-26 20:34:46 +00:00
resp.set_header("Content-Type", "application/json")
r.pop("req")
r.pop("resp")
r.pop("user")
2015-07-12 19:22:10 +00:00
resp.body = json.dumps(r, cls=MyEncoder)
return r
else:
2015-07-26 20:34:46 +00:00
resp.set_header("Content-Type", "text/html")
2015-07-12 19:22:10 +00:00
resp.body = template.render(request=req, **r)
return r
return wrapped
return wrapper
2015-07-26 20:34:46 +00:00
2015-07-12 19:22:10 +00:00
class CertificateAuthorityBase(object):
def __init__(self, config):
self.config = config
2015-07-26 20:34:46 +00:00
class RevocationListResource(CertificateAuthorityBase):
@pop_certificate_authority
def on_get(self, req, resp, ca):
resp.set_header("Content-Type", "application/x-pkcs7-crl")
resp.append_header("Content-Disposition", "attachment; filename=%s.crl" % ca.slug)
resp.body = ca.export_crl()
2015-07-12 19:22:10 +00:00
class SignedCertificateDetailResource(CertificateAuthorityBase):
@pop_certificate_authority
@validate_common_name
def on_get(self, req, resp, ca, cn):
path = os.path.join(ca.signed_dir, cn + ".pem")
if not os.path.exists(path):
raise falcon.HTTPNotFound()
resp.stream = open(path, "rb")
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % cn)
2015-07-26 20:34:46 +00:00
@login_required
2015-07-12 19:22:10 +00:00
@pop_certificate_authority
@authorize_admin
2015-07-12 19:22:10 +00:00
@validate_common_name
def on_delete(self, req, resp, ca, cn, user):
2015-07-12 19:22:10 +00:00
ca.revoke(cn)
class SignedCertificateListResource(CertificateAuthorityBase):
@serialize
@pop_certificate_authority
@validate_common_name
def on_get(self, req, resp, ca):
for j in authority.get_signed():
yield omit(
2015-07-26 20:34:46 +00:00
key_type=j.key_type,
key_length=j.key_length,
subject=j.distinguished_name,
cn=j.common_name,
c=j.country_code,
st=j.state_or_county,
l=j.city,
o=j.organization,
ou=j.organizational_unit,
fingerprint=j.fingerprint())
2015-07-26 20:34:46 +00:00
2015-07-12 19:22:10 +00:00
class RequestDetailResource(CertificateAuthorityBase):
@pop_certificate_authority
@validate_common_name
def on_get(self, req, resp, ca, cn):
"""
Fetch certificate signing request as PEM
"""
path = os.path.join(ca.request_dir, cn + ".pem")
if not os.path.exists(path):
raise falcon.HTTPNotFound()
resp.stream = open(path, "rb")
2015-07-26 20:34:46 +00:00
resp.append_header("Content-Type", "application/x-x509-user-cert")
2015-07-12 19:22:10 +00:00
resp.append_header("Content-Disposition", "attachment; filename=%s.csr" % cn)
@login_required
2015-07-12 19:22:10 +00:00
@pop_certificate_authority
@authorize_admin
2015-07-26 20:34:46 +00:00
@validate_common_name
def on_patch(self, req, resp, ca, cn, user):
2015-07-12 19:22:10 +00:00
"""
Sign a certificate signing request
"""
2015-07-26 20:34:46 +00:00
csr = ca.get_request(cn)
cert = ca.sign(csr, overwrite=True, delete=True)
os.unlink(csr.path)
2015-07-12 19:22:10 +00:00
resp.body = "Certificate successfully signed"
resp.status = falcon.HTTP_201
resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn)
@login_required
2015-07-26 20:34:46 +00:00
@pop_certificate_authority
@authorize_admin
def on_delete(self, req, resp, ca, cn, user):
2015-07-26 20:34:46 +00:00
ca.delete_request(cn)
2015-07-12 19:22:10 +00:00
class RequestListResource(CertificateAuthorityBase):
@serialize
@pop_certificate_authority
def on_get(self, req, resp, ca):
for j in ca.get_requests():
yield omit(
2015-07-26 20:34:46 +00:00
key_type=j.key_type,
key_length=j.key_length,
subject=j.distinguished_name,
cn=j.common_name,
c=j.country_code,
st=j.state_or_county,
l=j.city,
o=j.organization,
ou=j.organizational_unit,
fingerprint=j.fingerprint())
2015-07-12 19:22:10 +00:00
@pop_certificate_authority
def on_post(self, req, resp, ca):
2015-07-26 20:34:46 +00:00
"""
Submit certificate signing request (CSR) in PEM format
"""
2015-08-13 08:11:08 +00:00
# Parse remote IPv4/IPv6 address
2015-08-16 15:09:06 +00:00
remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"])
2015-08-13 08:11:08 +00:00
# Check for CSR submission whitelist
if ca.request_subnets:
for subnet in ca.request_subnets:
2015-08-13 08:11:08 +00:00
if subnet.overlaps(remote_addr):
break
else:
2015-08-16 15:09:06 +00:00
raise falcon.HTTPForbidden("Forbidden", "IP address %s not whitelisted" % remote_addr)
2015-07-26 20:34:46 +00:00
2015-07-12 19:22:10 +00:00
if req.get_header("Content-Type") != "application/pkcs10":
raise falcon.HTTPUnsupportedMediaType(
"This API call accepts only application/pkcs10 content type")
2015-07-26 20:34:46 +00:00
body = req.stream.read(req.content_length)
csr = Request(body)
# Check if this request has been already signed and return corresponding certificte if it has been signed
2015-07-12 19:22:10 +00:00
try:
2015-07-26 20:34:46 +00:00
cert_buf = ca.get_certificate(csr.common_name)
except FileNotFoundError:
pass
else:
cert = Certificate(cert_buf)
if cert.pubkey == csr.pubkey:
2015-08-13 08:11:08 +00:00
resp.status = falcon.HTTP_SEE_OTHER
2015-07-26 20:34:46 +00:00
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name)
return
# TODO: check for revoked certificates and return HTTP 410 Gone
# Process automatic signing if the IP address is whitelisted and autosigning was requested
if req.get_param("autosign") in ("yes", "1", "true"):
for subnet in ca.autosign_subnets:
2015-08-13 08:11:08 +00:00
if subnet.overlaps(remote_addr):
try:
resp.append_header("Content-Type", "application/x-x509-user-cert")
resp.body = ca.sign(csr).dump()
2015-08-13 08:11:08 +00:00
return
except FileExistsError: # Certificate already exists, try to save the request
pass
break
2015-07-26 20:34:46 +00:00
# Attempt to save the request otherwise
try:
request = ca.store_request(body)
except FileExistsError:
raise falcon.HTTPConflict(
"CSR with such CN already exists",
"Will not overwrite existing certificate signing request, explicitly delete CSR and try again")
ca.event_publish("request_submitted", request.fingerprint())
2015-07-26 20:34:46 +00:00
# Wait the certificate to be signed if waiting is requested
if req.get_param("wait"):
if ca.push_server:
2015-07-26 20:34:46 +00:00
# Redirect to nginx pub/sub
url = ca.push_server + "/lp/" + request.fingerprint()
2015-07-26 20:34:46 +00:00
click.echo("Redirecting to: %s" % url)
2015-08-13 08:11:08 +00:00
resp.status = falcon.HTTP_SEE_OTHER
2015-07-26 20:34:46 +00:00
resp.append_header("Location", url)
else:
click.echo("Using dummy streaming mode, please switch to nginx in production!", err=True)
# Dummy streaming mode
while True:
sleep(1)
if not ca.request_exists(csr.common_name):
resp.append_header("Content-Type", "application/x-x509-user-cert")
resp.status = falcon.HTTP_201 # Certificate was created
resp.body = ca.get_certificate(csr.common_name)
break
else:
# Request was accepted, but not processed
resp.status = falcon.HTTP_202
2015-07-26 20:34:46 +00:00
class CertificateStatusResource(CertificateAuthorityBase):
"""
openssl ocsp -issuer CAcert_class1.pem -serial 0x<serial no in hex> -url http://localhost -CAfile cacert_both.pem
"""
def on_post(self, req, resp, ca):
ocsp_request = req.stream.read(req.content_length)
for component in decoder.decode(ocsp_request):
click.echo(component)
resp.append_header("Content-Type", "application/ocsp-response")
resp.status = falcon.HTTP_200
raise NotImplementedError()
2015-07-12 19:22:10 +00:00
class CertificateAuthorityResource(CertificateAuthorityBase):
2015-07-26 20:34:46 +00:00
@pop_certificate_authority
def on_get(self, req, resp, ca):
path = os.path.join(ca.certificate.path)
resp.stream = open(path, "rb")
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % ca.slug)
class IndexResource(CertificateAuthorityBase):
@serialize
@login_required
2015-07-26 20:34:46 +00:00
@pop_certificate_authority
2015-08-16 15:09:06 +00:00
@authorize_admin
@event_source
def on_get(self, req, resp, ca, user):
return ca
class AuthorityListResource(CertificateAuthorityBase):
@serialize
@login_required
def on_get(self, req, resp, user):
return dict(
authorities=(self.config.ca_list), # TODO: Check if user is CA admin
username=user[0]
)
2015-07-26 20:34:46 +00:00
class ApplicationConfigurationResource(CertificateAuthorityBase):
@pop_certificate_authority
@validate_common_name
2015-07-26 20:34:46 +00:00
def on_get(self, req, resp, ca, cn):
ctx = dict(
cn = cn,
certificate = ca.get_certificate(cn),
ca_certificate = open(ca.certificate.path, "r").read())
resp.append_header("Content-Type", "application/ovpn")
resp.append_header("Content-Disposition", "attachment; filename=%s.ovpn" % cn)
resp.body = Template(open("/etc/openvpn/%s.template" % ca.slug).read()).render(ctx)
@login_required
2015-07-26 20:34:46 +00:00
@pop_certificate_authority
@authorize_admin
@validate_common_name
def on_put(self, req, resp, user, ca, cn=None):
2015-07-26 20:34:46 +00:00
pkey_buf, req_buf, cert_buf = ca.create_bundle(cn)
ctx = dict(
private_key = pkey_buf,
certificate = cert_buf,
ca_certificate = ca.certificate.dump())
resp.append_header("Content-Type", "application/ovpn")
resp.append_header("Content-Disposition", "attachment; filename=%s.ovpn" % cn)
resp.body = Template(open("/etc/openvpn/%s.template" % ca.slug).read()).render(ctx)
class StaticResource(object):
def __init__(self, root):
self.root = os.path.realpath(root)
def __call__(self, req, resp):
path = os.path.realpath(os.path.join(self.root, req.path[1:]))
if not path.startswith(self.root):
raise falcon.HTTPForbidden
if os.path.isdir(path):
path = os.path.join(path, "index.html")
print("Serving:", path)
if os.path.exists(path):
content_type, content_encoding = mimetypes.guess_type(path)
if content_type:
resp.append_header("Content-Type", content_type)
if content_encoding:
resp.append_header("Content-Encoding", content_encoding)
resp.stream = open(path, "rb")
else:
resp.status = falcon.HTTP_404
resp.body = "File '%s' not found" % req.path
def certidude_app():
config = CertificateAuthorityConfig()
app = falcon.API()
app.add_route("/api/ca/{ca}/ocsp/", CertificateStatusResource(config))
app.add_route("/api/ca/{ca}/signed/{cn}/openvpn", ApplicationConfigurationResource(config))
app.add_route("/api/ca/{ca}/certificate/", CertificateAuthorityResource(config))
app.add_route("/api/ca/{ca}/revoked/", RevocationListResource(config))
app.add_route("/api/ca/{ca}/signed/{cn}/", SignedCertificateDetailResource(config))
app.add_route("/api/ca/{ca}/signed/", SignedCertificateListResource(config))
app.add_route("/api/ca/{ca}/request/{cn}/", RequestDetailResource(config))
app.add_route("/api/ca/{ca}/request/", RequestListResource(config))
app.add_route("/api/ca/{ca}/", IndexResource(config))
app.add_route("/api/ca/", AuthorityListResource(config))
return app