mirror of
https://github.com/laurivosandi/certidude
synced 2026-01-12 17:06:59 +00:00
Refactor
* Remove PyOpenSSL based wrapper classes * Remove unused API calls * Add certificate renewal via X-Renewal-Signature header * Remove (extended) key usage handling * Clean up OpenVPN and nginx server setup code * Use UDP port 51900 for OpenVPN by default * Add basic auth fallback for iOS in addition to Android * Reduce complexity
This commit is contained in:
@@ -5,13 +5,14 @@ import mimetypes
|
||||
import logging
|
||||
import os
|
||||
import click
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from time import sleep
|
||||
from certidude import authority, mailer
|
||||
from certidude.auth import login_required, authorize_admin
|
||||
from certidude.user import User
|
||||
from certidude.decorators import serialize, event_source, csrf_protection
|
||||
from certidude.wrappers import Request, Certificate
|
||||
from cryptography.x509.oid import NameOID
|
||||
from certidude import const, config
|
||||
|
||||
logger = logging.getLogger("api")
|
||||
@@ -44,6 +45,33 @@ class SessionResource(object):
|
||||
@login_required
|
||||
@event_source
|
||||
def on_get(self, req, resp):
|
||||
def serialize_requests(g):
|
||||
for common_name, path, buf, obj, server in g():
|
||||
yield dict(
|
||||
common_name = common_name,
|
||||
server = server,
|
||||
md5sum = hashlib.md5(buf).hexdigest(),
|
||||
sha1sum = hashlib.sha1(buf).hexdigest(),
|
||||
sha256sum = hashlib.sha256(buf).hexdigest(),
|
||||
sha512sum = hashlib.sha512(buf).hexdigest()
|
||||
)
|
||||
|
||||
def serialize_certificates(g):
|
||||
for common_name, path, buf, obj, server in g():
|
||||
yield dict(
|
||||
serial_number = "%x" % obj.serial_number,
|
||||
common_name = common_name,
|
||||
server = server,
|
||||
# TODO: key type, key length, key exponent, key modulo
|
||||
signed = obj.not_valid_before,
|
||||
expires = obj.not_valid_after,
|
||||
sha256sum = hashlib.sha256(buf).hexdigest()
|
||||
)
|
||||
|
||||
if req.context.get("user").is_admin():
|
||||
logger.info("Logged in authority administrator %s" % req.context.get("user"))
|
||||
else:
|
||||
logger.info("Logged in authority user %s" % req.context.get("user"))
|
||||
return dict(
|
||||
user = dict(
|
||||
name=req.context.get("user").name,
|
||||
@@ -51,29 +79,31 @@ class SessionResource(object):
|
||||
sn=req.context.get("user").surname,
|
||||
mail=req.context.get("user").mail
|
||||
),
|
||||
request_submission_allowed = sum( # Dirty hack!
|
||||
[req.context.get("remote_addr") in j
|
||||
for j in config.REQUEST_SUBNETS]),
|
||||
request_submission_allowed = config.REQUEST_SUBMISSION_ALLOWED,
|
||||
authority = dict(
|
||||
common_name = authority.ca_cert.subject.get_attributes_for_oid(
|
||||
NameOID.COMMON_NAME)[0].value,
|
||||
outbox = dict(
|
||||
server = config.OUTBOX,
|
||||
name = config.OUTBOX_NAME,
|
||||
mail = config.OUTBOX_MAIL
|
||||
),
|
||||
user_certificate_enrollment=config.USER_CERTIFICATE_ENROLLMENT,
|
||||
user_mutliple_certificates=config.USER_MULTIPLE_CERTIFICATES,
|
||||
certificate = authority.certificate,
|
||||
machine_enrollment_allowed=config.MACHINE_ENROLLMENT_ALLOWED,
|
||||
user_enrollment_allowed=config.USER_ENROLLMENT_ALLOWED,
|
||||
user_multiple_certificates=config.USER_MULTIPLE_CERTIFICATES,
|
||||
events = config.EVENT_SOURCE_SUBSCRIBE % config.EVENT_SOURCE_TOKEN,
|
||||
requests=authority.list_requests(),
|
||||
signed=authority.list_signed(),
|
||||
revoked=authority.list_revoked(),
|
||||
requests=serialize_requests(authority.list_requests),
|
||||
signed=serialize_certificates(authority.list_signed),
|
||||
revoked=serialize_certificates(authority.list_revoked),
|
||||
users=User.objects.all(),
|
||||
admin_users = User.objects.filter_admins(),
|
||||
user_subnets = config.USER_SUBNETS,
|
||||
autosign_subnets = config.AUTOSIGN_SUBNETS,
|
||||
request_subnets = config.REQUEST_SUBNETS,
|
||||
admin_subnets=config.ADMIN_SUBNETS,
|
||||
signature = dict(
|
||||
certificate_lifetime=config.CERTIFICATE_LIFETIME,
|
||||
server_certificate_lifetime=config.SERVER_CERTIFICATE_LIFETIME,
|
||||
client_certificate_lifetime=config.CLIENT_CERTIFICATE_LIFETIME,
|
||||
revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME
|
||||
)
|
||||
) if req.context.get("user").is_admin() else None,
|
||||
@@ -88,7 +118,6 @@ class StaticResource(object):
|
||||
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
|
||||
@@ -124,7 +153,7 @@ def certidude_app():
|
||||
from certidude import config
|
||||
from .bundle import BundleResource
|
||||
from .revoked import RevocationListResource
|
||||
from .signed import SignedCertificateListResource, SignedCertificateDetailResource
|
||||
from .signed import SignedCertificateDetailResource
|
||||
from .request import RequestListResource, RequestDetailResource
|
||||
from .lease import LeaseResource, StatusFileLeaseResource
|
||||
from .whois import WhoisResource
|
||||
@@ -138,7 +167,6 @@ def certidude_app():
|
||||
app.add_route("/api/certificate/", CertificateAuthorityResource())
|
||||
app.add_route("/api/revoked/", RevocationListResource())
|
||||
app.add_route("/api/signed/{cn}/", SignedCertificateDetailResource())
|
||||
app.add_route("/api/signed/", SignedCertificateListResource())
|
||||
app.add_route("/api/request/{cn}/", RequestDetailResource())
|
||||
app.add_route("/api/request/", RequestListResource())
|
||||
app.add_route("/api/", SessionResource())
|
||||
@@ -151,7 +179,7 @@ def certidude_app():
|
||||
app.add_route("/api/whois/", WhoisResource())
|
||||
|
||||
# Optional user enrollment API call
|
||||
if config.USER_CERTIFICATE_ENROLLMENT:
|
||||
if config.USER_ENROLLMENT_ALLOWED:
|
||||
app.add_route("/api/bundle/", BundleResource())
|
||||
|
||||
if config.TAGGING_BACKEND == "sql":
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
import logging
|
||||
import hashlib
|
||||
from certidude import config, authority
|
||||
|
||||
@@ -4,91 +4,125 @@ import falcon
|
||||
import logging
|
||||
import ipaddress
|
||||
import os
|
||||
import hashlib
|
||||
from base64 import b64decode
|
||||
from certidude import config, authority, helpers, push, errors
|
||||
from certidude.auth import login_required, login_optional, authorize_admin
|
||||
from certidude.decorators import serialize, csrf_protection
|
||||
from certidude.wrappers import Request, Certificate
|
||||
from certidude.firewall import whitelist_subnets, whitelist_content_types
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.x509.oid import NameOID
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
class RequestListResource(object):
|
||||
@serialize
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
return authority.list_requests()
|
||||
|
||||
|
||||
@login_optional
|
||||
@whitelist_subnets(config.REQUEST_SUBNETS)
|
||||
@whitelist_content_types("application/pkcs10")
|
||||
def on_post(self, req, resp):
|
||||
"""
|
||||
Submit certificate signing request (CSR) in PEM format
|
||||
Validate and parse certificate signing request
|
||||
"""
|
||||
|
||||
body = req.stream.read(req.content_length)
|
||||
|
||||
# Normalize body, TODO: newlines
|
||||
if not body.endswith("\n"):
|
||||
body += "\n"
|
||||
|
||||
csr = Request(body)
|
||||
|
||||
if not csr.common_name:
|
||||
csr = x509.load_pem_x509_csr(body, default_backend())
|
||||
try:
|
||||
common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
|
||||
except: # ValueError?
|
||||
logger.warning(u"Rejected signing request without common name from %s",
|
||||
req.context.get("remote_addr"))
|
||||
raise falcon.HTTPBadRequest(
|
||||
"Bad request",
|
||||
"No common name specified!")
|
||||
|
||||
"""
|
||||
Handle domain computer automatic enrollment
|
||||
"""
|
||||
machine = req.context.get("machine")
|
||||
if machine:
|
||||
if csr.common_name != machine:
|
||||
if config.MACHINE_ENROLLMENT_ALLOWED and machine:
|
||||
if common_name.value != machine:
|
||||
raise falcon.HTTPBadRequest(
|
||||
"Bad request",
|
||||
"Common name %s differs from Kerberos credential %s!" % (csr.common_name, machine))
|
||||
"Common name %s differs from Kerberos credential %s!" % (common_name.value, machine))
|
||||
|
||||
# Automatic enroll with Kerberos machine cerdentials
|
||||
resp.set_header("Content-Type", "application/x-x509-user-cert")
|
||||
resp.body = authority.sign(csr, overwrite=True).dump()
|
||||
cert, resp.body = authority._sign(csr, body, overwrite=True)
|
||||
logger.info(u"Automatically enrolled Kerberos authenticated machine %s from %s",
|
||||
machine, req.context.get("remote_addr"))
|
||||
return
|
||||
|
||||
|
||||
# Check if this request has been already signed and return corresponding certificte if it has been signed
|
||||
"""
|
||||
Attempt to renew certificate using currently valid key pair
|
||||
"""
|
||||
try:
|
||||
cert = authority.get_signed(csr.common_name)
|
||||
path, buf, cert = authority.get_signed(common_name.value)
|
||||
except EnvironmentError:
|
||||
pass
|
||||
else:
|
||||
if cert.pubkey == csr.pubkey:
|
||||
resp.status = falcon.HTTP_SEE_OTHER
|
||||
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name)
|
||||
return
|
||||
if cert.public_key().public_numbers() == csr.public_key().public_numbers():
|
||||
try:
|
||||
renewal_signature = b64decode(req.get_header("X-Renewal-Signature"))
|
||||
except TypeError, ValueError: # No header supplied, redirect to signed API call
|
||||
resp.status = falcon.HTTP_SEE_OTHER
|
||||
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", common_name.value)
|
||||
return
|
||||
else:
|
||||
try:
|
||||
verifier = cert.public_key().verifier(
|
||||
renewal_signature,
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(hashes.SHA512()),
|
||||
salt_length=padding.PSS.MAX_LENGTH
|
||||
),
|
||||
hashes.SHA512()
|
||||
)
|
||||
verifier.update(buf)
|
||||
verifier.update(body)
|
||||
verifier.verify()
|
||||
except InvalidSignature:
|
||||
logger.error("Renewal failed, invalid signature supplied for %s", common_name.value)
|
||||
else:
|
||||
# At this point renewal signature was valid but we need to perform some extra checks
|
||||
if datetime.utcnow() > cert.not_valid_after:
|
||||
logger.error("Renewal failed, current certificate for %s has expired", common_name.value)
|
||||
# Put on hold
|
||||
elif not config.CERTIFICATE_RENEWAL_ALLOWED:
|
||||
logger.error("Renewal requested for %s, but not allowed by authority settings", common_name.value)
|
||||
# Put on hold
|
||||
else:
|
||||
resp.set_header("Content-Type", "application/x-x509-user-cert")
|
||||
_, resp.body = authority._sign(csr, body, overwrite=True)
|
||||
logger.info("Renewed certificate for %s", common_name.value)
|
||||
return
|
||||
|
||||
# TODO: check for revoked certificates and return HTTP 410 Gone
|
||||
|
||||
# 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") and csr.is_client:
|
||||
"""
|
||||
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") and "." not in common_name.value:
|
||||
for subnet in config.AUTOSIGN_SUBNETS:
|
||||
if req.context.get("remote_addr") in subnet:
|
||||
try:
|
||||
resp.set_header("Content-Type", "application/x-x509-user-cert")
|
||||
resp.body = authority.sign(csr).dump()
|
||||
_, resp.body = authority._sign(csr, body)
|
||||
logger.info("Autosigned %s as %s is whitelisted", common_name.value, req.context.get("remote_addr"))
|
||||
return
|
||||
except EnvironmentError: # Certificate already exists, try to save the request
|
||||
pass
|
||||
except EnvironmentError:
|
||||
logger.info("Autosign for %s failed, signed certificate already exists",
|
||||
common_name.value, req.context.get("remote_addr"))
|
||||
break
|
||||
|
||||
# Attempt to save the request otherwise
|
||||
try:
|
||||
csr = authority.store_request(body)
|
||||
except errors.RequestExists:
|
||||
# We should stil redirect client to long poll URL below
|
||||
# We should still redirect client to long poll URL below
|
||||
pass
|
||||
except errors.DuplicateCommonNameError:
|
||||
# TODO: Certificate renewal
|
||||
@@ -98,12 +132,13 @@ class RequestListResource(object):
|
||||
"CSR with such CN already exists",
|
||||
"Will not overwrite existing certificate signing request, explicitly delete CSR and try again")
|
||||
else:
|
||||
push.publish("request-submitted", csr.common_name)
|
||||
push.publish("request-submitted", common_name.value)
|
||||
|
||||
# Wait the certificate to be signed if waiting is requested
|
||||
logger.info(u"Signing request %s from %s stored", common_name.value, req.context.get("remote_addr"))
|
||||
if req.get_param("wait"):
|
||||
# Redirect to nginx pub/sub
|
||||
url = config.LONG_POLL_SUBSCRIBE % csr.fingerprint()
|
||||
url = config.LONG_POLL_SUBSCRIBE % hashlib.sha256(body).hexdigest()
|
||||
click.echo("Redirecting to: %s" % url)
|
||||
resp.status = falcon.HTTP_SEE_OTHER
|
||||
resp.set_header("Location", url.encode("ascii"))
|
||||
@@ -111,20 +146,17 @@ class RequestListResource(object):
|
||||
else:
|
||||
# Request was accepted, but not processed
|
||||
resp.status = falcon.HTTP_202
|
||||
logger.info(u"Signing request from %s stored", req.context.get("remote_addr"))
|
||||
|
||||
|
||||
class RequestDetailResource(object):
|
||||
@serialize
|
||||
def on_get(self, req, resp, cn):
|
||||
"""
|
||||
Fetch certificate signing request as PEM
|
||||
"""
|
||||
csr = authority.get_request(cn)
|
||||
resp.set_header("Content-Type", "application/pkcs10")
|
||||
_, resp.body, _ = authority.get_request(cn)
|
||||
logger.debug(u"Signing request %s was downloaded by %s",
|
||||
csr.common_name, req.context.get("remote_addr"))
|
||||
return csr
|
||||
|
||||
cn, req.context.get("remote_addr"))
|
||||
|
||||
@csrf_protection
|
||||
@login_required
|
||||
@@ -133,16 +165,15 @@ class RequestDetailResource(object):
|
||||
"""
|
||||
Sign a certificate signing request
|
||||
"""
|
||||
csr = authority.get_request(cn)
|
||||
cert = authority.sign(csr, overwrite=True, delete=True)
|
||||
os.unlink(csr.path)
|
||||
cert, buf = authority.sign(cn, overwrite=True)
|
||||
# Mailing and long poll publishing implemented in the function above
|
||||
|
||||
resp.body = "Certificate successfully signed"
|
||||
resp.status = falcon.HTTP_201
|
||||
resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn)
|
||||
logger.info(u"Signing request %s signed by %s from %s", csr.common_name,
|
||||
logger.info(u"Signing request %s signed by %s from %s", cn,
|
||||
req.context.get("user"), req.context.get("remote_addr"))
|
||||
|
||||
|
||||
@csrf_protection
|
||||
@login_required
|
||||
@authorize_admin
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
import click
|
||||
import falcon
|
||||
import json
|
||||
import logging
|
||||
from certidude import const, config
|
||||
from certidude.authority import export_crl, list_revoked
|
||||
from certidude.decorators import MyEncoder
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.serialization import Encoding
|
||||
@@ -31,16 +31,13 @@ class RevocationListResource(object):
|
||||
resp.status = falcon.HTTP_SEE_OTHER
|
||||
resp.set_header("Location", url.encode("ascii"))
|
||||
logger.debug(u"Redirecting to CRL request to %s", url)
|
||||
resp.body = "Redirecting to %s" % url
|
||||
else:
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
resp.append_header(
|
||||
"Content-Disposition",
|
||||
("attachment; filename=%s-crl.pem" % const.HOSTNAME).encode("ascii"))
|
||||
resp.body = export_crl()
|
||||
elif req.accept.startswith("application/json"):
|
||||
resp.set_header("Content-Type", "application/json")
|
||||
resp.set_header("Content-Disposition", "inline")
|
||||
resp.body = json.dumps(list_revoked(), cls=MyEncoder)
|
||||
else:
|
||||
raise falcon.HTTPUnsupportedMediaType(
|
||||
"Client did not accept application/x-pkcs7-crl or application/x-pem-file")
|
||||
|
||||
@@ -1,38 +1,46 @@
|
||||
|
||||
import falcon
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
from certidude import authority
|
||||
from certidude.auth import login_required, authorize_admin
|
||||
from certidude.decorators import serialize, csrf_protection
|
||||
from certidude.decorators import csrf_protection
|
||||
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
class SignedCertificateListResource(object):
|
||||
@serialize
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
return {"signed":authority.list_signed()}
|
||||
|
||||
|
||||
class SignedCertificateDetailResource(object):
|
||||
@serialize
|
||||
def on_get(self, req, resp, cn):
|
||||
# Compensate for NTP lag
|
||||
# from time import sleep
|
||||
# sleep(5)
|
||||
|
||||
preferred_type = req.client_prefers(("application/json", "application/x-pem-file"))
|
||||
try:
|
||||
cert = authority.get_signed(cn)
|
||||
path, buf, cert = authority.get_signed(cn)
|
||||
except EnvironmentError:
|
||||
logger.warning(u"Failed to serve non-existant certificate %s to %s",
|
||||
cn, req.context.get("remote_addr"))
|
||||
resp.body = "No certificate CN=%s found" % cn
|
||||
raise falcon.HTTPNotFound()
|
||||
raise falcon.HTTPNotFound("No certificate CN=%s found" % cn)
|
||||
else:
|
||||
logger.debug(u"Served certificate %s to %s",
|
||||
cn, req.context.get("remote_addr"))
|
||||
return cert
|
||||
|
||||
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.body = buf
|
||||
logger.debug(u"Served certificate %s to %s as application/x-pem-file",
|
||||
cn, req.context.get("remote_addr"))
|
||||
elif preferred_type == "application/json":
|
||||
resp.set_header("Content-Type", "application/json")
|
||||
resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % cn))
|
||||
resp.body = json.dumps(dict(
|
||||
common_name = cn,
|
||||
serial_number = "%x" % cert.serial_number,
|
||||
signed = cert.not_valid_before.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
|
||||
expires = cert.not_valid_after.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
|
||||
sha256sum = hashlib.sha256(buf).hexdigest()))
|
||||
logger.debug(u"Served certificate %s to %s as application/json",
|
||||
cn, req.context.get("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
|
||||
@@ -40,5 +48,5 @@ class SignedCertificateDetailResource(object):
|
||||
def on_delete(self, req, resp, cn):
|
||||
logger.info(u"Revoked certificate %s by %s from %s",
|
||||
cn, req.context.get("user"), req.context.get("remote_addr"))
|
||||
authority.revoke_certificate(cn)
|
||||
authority.revoke(cn)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user