2015-07-12 19:22:10 +00:00
|
|
|
import re
|
|
|
|
import falcon
|
2015-08-16 15:09:06 +00:00
|
|
|
import ipaddress
|
2015-07-12 19:22:10 +00:00
|
|
|
import os
|
|
|
|
import json
|
|
|
|
import types
|
2015-07-26 20:34:46 +00:00
|
|
|
import urllib.request
|
|
|
|
import click
|
|
|
|
from time import sleep
|
|
|
|
from certidude.wrappers import Request, Certificate
|
2015-08-16 14:21:42 +00:00
|
|
|
from certidude.auth import login_required
|
2015-07-26 20:34:46 +00:00
|
|
|
from certidude.mailer import Mailer
|
|
|
|
from pyasn1.codec.der import decoder
|
2015-07-12 19:22:10 +00:00
|
|
|
from datetime import datetime, date
|
|
|
|
from OpenSSL import crypto
|
2015-07-26 20:34:46 +00:00
|
|
|
from jinja2 import Environment, PackageLoader, Template
|
|
|
|
|
|
|
|
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
|
|
|
|
2015-08-16 14:21:42 +00:00
|
|
|
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
|
2015-08-16 14:21:42 +00:00
|
|
|
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?
|
2015-08-16 14:21:42 +00:00
|
|
|
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):
|
|
|
|
def default(self, obj):
|
|
|
|
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)
|
|
|
|
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 not resp.body:
|
|
|
|
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):
|
2015-08-16 14:21:42 +00:00
|
|
|
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"
|
2015-08-16 14:21:42 +00:00
|
|
|
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")
|
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
|
|
|
|
2015-08-16 14:21:42 +00:00
|
|
|
@login_required
|
2015-07-12 19:22:10 +00:00
|
|
|
@pop_certificate_authority
|
2015-08-16 14:21:42 +00:00
|
|
|
@authorize_admin
|
2015-07-12 19:22:10 +00:00
|
|
|
@validate_common_name
|
2015-08-16 14:21:42 +00:00
|
|
|
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-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)
|
|
|
|
|
2015-08-16 14:21:42 +00:00
|
|
|
@login_required
|
2015-07-12 19:22:10 +00:00
|
|
|
@pop_certificate_authority
|
2015-08-16 14:21:42 +00:00
|
|
|
@authorize_admin
|
2015-07-26 20:34:46 +00:00
|
|
|
@validate_common_name
|
2015-08-16 14:21:42 +00:00
|
|
|
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)
|
|
|
|
|
2015-08-16 14:21:42 +00:00
|
|
|
@login_required
|
2015-07-26 20:34:46 +00:00
|
|
|
@pop_certificate_authority
|
2015-08-16 14:21:42 +00:00
|
|
|
@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
|
2015-08-16 14:21:42 +00:00
|
|
|
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
|
2015-08-13 08:11:08 +00:00
|
|
|
if req.get_param("autosign").lower() in ("yes", "1", "true"):
|
2015-08-16 14:21:42 +00:00
|
|
|
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")
|
2015-08-16 14:21:42 +00:00
|
|
|
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")
|
|
|
|
|
|
|
|
# Wait the certificate to be signed if waiting is requested
|
|
|
|
if req.get_param("wait"):
|
2015-08-16 14:21:42 +00:00
|
|
|
url_template = os.getenv("PUSH_SUBSCRIBE")
|
2015-07-26 20:34:46 +00:00
|
|
|
if url_template:
|
|
|
|
# Redirect to nginx pub/sub
|
2015-07-27 15:49:50 +00:00
|
|
|
url = url_template % dict(channel=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
|
|
|
|
|
|
|
|
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):
|
2015-08-16 14:21:42 +00:00
|
|
|
@login_required
|
2015-07-26 20:34:46 +00:00
|
|
|
@pop_certificate_authority
|
2015-08-16 15:09:06 +00:00
|
|
|
@authorize_admin
|
2015-08-16 14:21:42 +00:00
|
|
|
@templatize("index.html")
|
|
|
|
def on_get(self, req, resp, ca, user):
|
|
|
|
return locals()
|
2015-07-26 20:34:46 +00:00
|
|
|
|
|
|
|
class ApplicationConfigurationResource(CertificateAuthorityBase):
|
|
|
|
@pop_certificate_authority
|
2015-08-16 14:21:42 +00:00
|
|
|
@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)
|
|
|
|
|
2015-08-16 14:21:42 +00:00
|
|
|
@login_required
|
2015-07-26 20:34:46 +00:00
|
|
|
@pop_certificate_authority
|
2015-08-16 14:21:42 +00:00
|
|
|
@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)
|
2015-07-12 19:22:10 +00:00
|
|
|
|