265 lines
12 KiB
Python
265 lines
12 KiB
Python
import click
|
|
import falcon
|
|
import logging
|
|
import json
|
|
import os
|
|
import hashlib
|
|
from asn1crypto import pem, x509
|
|
from asn1crypto.csr import CertificationRequest
|
|
from base64 import b64decode
|
|
from certidude import config, push, errors
|
|
from certidude.decorators import csrf_protection, MyEncoder
|
|
from certidude.profile import SignatureProfile
|
|
from datetime import datetime
|
|
from oscrypto import asymmetric
|
|
from oscrypto.errors import SignatureError
|
|
from xattr import getxattr, setxattr
|
|
from .utils import AuthorityHandler
|
|
from .utils.firewall import whitelist_subnets, whitelist_content_types, \
|
|
login_required, login_optional, authorize_admin
|
|
|
|
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(AuthorityHandler):
|
|
@login_optional
|
|
@whitelist_subnets(config.REQUEST_SUBNETS)
|
|
@whitelist_content_types("application/pkcs10")
|
|
def on_post(self, req, resp):
|
|
"""
|
|
Validate and parse certificate signing request, the RESTful way
|
|
"""
|
|
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.get("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 self.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.get("remote_addr"),
|
|
self.authority.public_key.algorithm.upper()))
|
|
raise falcon.HTTPBadRequest(
|
|
"Bad request",
|
|
"Incompatible asymmetric key algorithms")
|
|
|
|
common_name = csr["certification_request_info"]["subject"].native["common_name"]
|
|
|
|
"""
|
|
Determine whether autosign is allowed to overwrite already issued
|
|
certificates automatically
|
|
"""
|
|
|
|
overwrite_allowed = False
|
|
for subnet in config.OVERWRITE_SUBNETS:
|
|
if req.context.get("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.get("remote_addr"))
|
|
for subnet in config.MACHINE_ENROLLMENT_SUBNETS:
|
|
if req.context.get("remote_addr") in subnet:
|
|
if common_name != machine:
|
|
raise falcon.HTTPBadRequest(
|
|
"Bad request",
|
|
"Common name %s differs from Kerberos credential %s!" % (common_name, machine))
|
|
|
|
# Automatic enroll with Kerberos machine cerdentials
|
|
resp.set_header("Content-Type", "application/x-pem-file")
|
|
cert, resp.body = self.authority._sign(csr, body,
|
|
profile=config.PROFILES["rw"], overwrite=overwrite_allowed)
|
|
logger.info("Automatically enrolled Kerberos authenticated machine %s from %s",
|
|
machine, req.context.get("remote_addr"))
|
|
return
|
|
|
|
|
|
"""
|
|
Attempt to renew certificate using currently valid key pair
|
|
"""
|
|
try:
|
|
path, buf, cert, signed, expires = self.authority.get_signed(common_name)
|
|
except EnvironmentError:
|
|
pass # No currently valid certificate for this common name
|
|
else:
|
|
cert_pk = cert["tbs_certificate"]["subject_public_key_info"].native
|
|
csr_pk = csr["certification_request_info"]["subject_pk_info"].native
|
|
|
|
# Same public key
|
|
if cert_pk == csr_pk:
|
|
buf = req.get_header("X-SSL-CERT")
|
|
if buf:
|
|
# Used mutually authenticated TLS handshake, assume renewal
|
|
header, _, der_bytes = pem.unarmor(buf.replace("\t", "\n").replace("\n\n", "\n").encode("ascii"))
|
|
handshake_cert = x509.Certificate.load(der_bytes)
|
|
if handshake_cert.native == cert.native:
|
|
for subnet in config.RENEWAL_SUBNETS:
|
|
if req.context.get("remote_addr") in subnet:
|
|
resp.set_header("Content-Type", "application/x-x509-user-cert")
|
|
setxattr(path, "user.revocation.reason", "superseded")
|
|
_, resp.body = self.authority._sign(csr, body, overwrite=True,
|
|
profile=SignatureProfile.from_cert(cert))
|
|
logger.info("Renewing certificate for %s as %s is whitelisted", common_name, req.context.get("remote_addr"))
|
|
return
|
|
reasons.append("renewal failed")
|
|
else:
|
|
# No renewal requested, 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)
|
|
return
|
|
|
|
|
|
"""
|
|
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 config.AUTOSIGN_SUBNETS:
|
|
if req.context.get("remote_addr") in subnet:
|
|
try:
|
|
resp.set_header("Content-Type", "application/x-pem-file")
|
|
_, resp.body = self.authority._sign(csr, body,
|
|
overwrite=overwrite_allowed, profile=config.PROFILES["rw"])
|
|
logger.info("Signed %s as %s is whitelisted for autosign", common_name, req.context.get("remote_addr"))
|
|
return
|
|
except EnvironmentError:
|
|
logger.info("Autosign for %s from %s failed, signed certificate already exists",
|
|
common_name, req.context.get("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:
|
|
request_path, _, _ = self.authority.store_request(body,
|
|
address=str(req.context.get("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:
|
|
# TODO: Certificate renewal
|
|
logger.warning("rejected signing request with overlapping common name from %s",
|
|
req.context.get("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:
|
|
push.publish("request-submitted", common_name)
|
|
|
|
# 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.get("remote_addr"), ", ".join(reasons))
|
|
|
|
if req.get_param("wait"):
|
|
# Redirect to nginx pub/sub
|
|
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)
|
|
else:
|
|
# Request was accepted, but not processed
|
|
resp.status = falcon.HTTP_202
|
|
resp.body = ". ".join(reasons)
|
|
if req.client_accepts("application/json"):
|
|
resp.body = json.dumps({"title":"Accepted", "description":resp.body},
|
|
cls=MyEncoder)
|
|
|
|
|
|
class RequestDetailResource(AuthorityHandler):
|
|
def on_get(self, req, resp, cn):
|
|
"""
|
|
Fetch certificate signing request as PEM
|
|
"""
|
|
|
|
try:
|
|
path, buf, _, submitted = self.authority.get_request(cn)
|
|
except errors.RequestDoesNotExist:
|
|
logger.warning("Failed to serve non-existant request %s to %s",
|
|
cn, req.context.get("remote_addr"))
|
|
raise falcon.HTTPNotFound()
|
|
|
|
resp.set_header("Content-Type", "application/pkcs10")
|
|
logger.debug("Signing request %s was downloaded by %s",
|
|
cn, req.context.get("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" % cn))
|
|
resp.body = 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" % cn))
|
|
resp.body = json.dumps(dict(
|
|
submitted = submitted,
|
|
common_name = cn,
|
|
address = getxattr(path, "user.request.address").decode("ascii"), # TODO: move to authority.py
|
|
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, cn):
|
|
"""
|
|
Sign a certificate signing request
|
|
"""
|
|
try:
|
|
cert, buf = self.authority.sign(cn,
|
|
profile=config.PROFILES[req.get_param("profile", default="rw")],
|
|
overwrite=True,
|
|
signer=req.context.get("user").name)
|
|
# Mailing and long poll publishing implemented in the function above
|
|
except EnvironmentError: # no such CSR
|
|
raise falcon.HTTPNotFound()
|
|
|
|
resp.body = "Certificate successfully signed"
|
|
resp.status = falcon.HTTP_201
|
|
resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn)
|
|
logger.info("Signing request %s signed by %s from %s", cn,
|
|
req.context.get("user"), req.context.get("remote_addr"))
|
|
|
|
@csrf_protection
|
|
@login_required
|
|
@authorize_admin
|
|
def on_delete(self, req, resp, cn):
|
|
try:
|
|
self.authority.delete_request(cn, user=req.context.get("user"))
|
|
# Logging implemented in the function above
|
|
except errors.RequestDoesNotExist as e:
|
|
resp.body = "No certificate signing request for %s found" % cn
|
|
logger.warning("User %s failed to delete signing request %s from %s, reason: %s",
|
|
req.context["user"], cn, req.context.get("remote_addr"), e)
|
|
raise falcon.HTTPNotFound()
|