1
0
mirror of https://github.com/laurivosandi/certidude synced 2025-10-31 01:19:11 +00:00
* 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:
2017-03-13 11:42:58 +00:00
parent d1aa2f2073
commit 06010ceaf3
30 changed files with 757 additions and 952 deletions

View File

@@ -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":

View File

@@ -1,5 +1,3 @@
import logging
import hashlib
from certidude import config, authority

View File

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

View File

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

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