mirror of
https://github.com/laurivosandi/certidude
synced 2024-12-23 00:25:18 +00:00
Refactor wrappers
Completely remove wrapper class for CA, use certidude.authority module instead.
This commit is contained in:
parent
5876f61e15
commit
b788d701eb
@ -67,7 +67,7 @@ To install Certidude:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
apt-get install -y python3 python3-pip python3-dev cython3 build-essential libffi-dev libssl-dev libkrb5-dev
|
||||
apt-get install -y python3 python3-pip python3-dev python3-mysql.connector cython3 build-essential libffi-dev libssl-dev libkrb5-dev
|
||||
pip3 install certidude
|
||||
|
||||
Make sure you're running PyOpenSSL 0.15+ and netifaces 0.10.4+ from PyPI,
|
||||
@ -87,6 +87,8 @@ First make sure the machine used for CA has fully qualified
|
||||
domain name set up properly.
|
||||
You can check it with:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
hostname -f
|
||||
|
||||
The command should return ca.example.co
|
||||
|
588
certidude/api.py
588
certidude/api.py
@ -1,588 +0,0 @@
|
||||
import re
|
||||
import datetime
|
||||
import falcon
|
||||
import ipaddress
|
||||
import mimetypes
|
||||
import os
|
||||
import json
|
||||
import types
|
||||
import click
|
||||
from time import sleep
|
||||
from certidude.wrappers import Request, Certificate, CertificateAuthority, \
|
||||
CertificateAuthorityConfig
|
||||
from certidude.auth import login_required
|
||||
from OpenSSL import crypto
|
||||
from pyasn1.codec.der import decoder
|
||||
from datetime import datetime, date
|
||||
from jinja2 import Environment, PackageLoader, Template
|
||||
|
||||
# TODO: Restrictive filesystem permissions result in TemplateNotFound exceptions
|
||||
env = Environment(loader=PackageLoader("certidude", "templates"))
|
||||
|
||||
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])$"
|
||||
|
||||
|
||||
OIDS = {
|
||||
(2, 5, 4, 3) : 'CN', # common name
|
||||
(2, 5, 4, 6) : 'C', # country
|
||||
(2, 5, 4, 7) : 'L', # locality
|
||||
(2, 5, 4, 8) : 'ST', # stateOrProvince
|
||||
(2, 5, 4, 10) : 'O', # organization
|
||||
(2, 5, 4, 11) : 'OU', # organizationalUnit
|
||||
}
|
||||
|
||||
def parse_dn(data):
|
||||
chunks, remainder = decoder.decode(data)
|
||||
dn = ""
|
||||
if remainder:
|
||||
raise ValueError()
|
||||
# TODO: Check for duplicate entries?
|
||||
def generate():
|
||||
for chunk in chunks:
|
||||
for chunkette in chunk:
|
||||
key, value = chunkette
|
||||
yield str(OIDS[key] + "=" + value)
|
||||
return ", ".join(generate())
|
||||
|
||||
def omit(**kwargs):
|
||||
return dict([(key,value) for (key, value) in kwargs.items() if value])
|
||||
|
||||
def event_source(func):
|
||||
def wrapped(self, req, resp, *args, **kwargs):
|
||||
if req.get_header("Accept") == "text/event-stream":
|
||||
resp.status = falcon.HTTP_SEE_OTHER
|
||||
resp.location = req.context.get("ca").push_server + "/ev/" + req.context.get("ca").uuid
|
||||
resp.body = "Redirecting to:" + resp.location
|
||||
print("Delegating EventSource handling to:", resp.location)
|
||||
return func(self, req, resp, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
def authorize_admin(func):
|
||||
def wrapped(self, req, resp, *args, **kwargs):
|
||||
authority = req.context.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 = req.context.get("user")
|
||||
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?
|
||||
|
||||
return func(self, req, resp, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
def pop_certificate_authority(func):
|
||||
def wrapped(self, req, resp, *args, **kwargs):
|
||||
req.context["ca"] = self.config.instantiate_authority(req.env["HTTP_HOST"])
|
||||
return func(self, req, resp, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class MyEncoder(json.JSONEncoder):
|
||||
REQUEST_ATTRIBUTES = "signable", "identity", "changed", "common_name", \
|
||||
"organizational_unit", "given_name", "surname", "fqdn", "email_address", \
|
||||
"key_type", "key_length", "md5sum", "sha1sum", "sha256sum", "key_usage"
|
||||
|
||||
CERTIFICATE_ATTRIBUTES = "revokable", "identity", "changed", "common_name", \
|
||||
"organizational_unit", "given_name", "surname", "fqdn", "email_address", \
|
||||
"key_type", "key_length", "sha256sum", "serial_number", "key_usage"
|
||||
|
||||
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)
|
||||
if isinstance(obj, datetime):
|
||||
return obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
if isinstance(obj, date):
|
||||
return obj.strftime("%Y-%m-%d")
|
||||
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(
|
||||
event_channel = obj.push_server + "/ev/" + obj.uuid,
|
||||
common_name = obj.common_name,
|
||||
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()
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
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:
|
||||
if req.get_header("Accept").split(",")[0] == "application/json":
|
||||
resp.set_header("Content-Type", "application/json")
|
||||
resp.append_header("Content-Disposition", "inline")
|
||||
resp.body = json.dumps(r, cls=MyEncoder)
|
||||
else:
|
||||
resp.body = repr(r)
|
||||
return r
|
||||
return wrapped
|
||||
|
||||
|
||||
def templatize(path):
|
||||
template = env.get_template(path)
|
||||
def wrapper(func):
|
||||
def wrapped(instance, req, resp, *args, **kwargs):
|
||||
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")
|
||||
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");
|
||||
resp.set_header("Content-Type", "application/json")
|
||||
r.pop("req")
|
||||
r.pop("resp")
|
||||
resp.body = json.dumps(r, cls=MyEncoder)
|
||||
return r
|
||||
else:
|
||||
resp.set_header("Content-Type", "text/html")
|
||||
resp.body = template.render(request=req, **r)
|
||||
return r
|
||||
return wrapped
|
||||
return wrapper
|
||||
|
||||
|
||||
class CertificateAuthorityBase(object):
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
|
||||
class RevocationListResource(CertificateAuthorityBase):
|
||||
@pop_certificate_authority
|
||||
def on_get(self, req, resp):
|
||||
resp.set_header("Content-Type", "application/x-pkcs7-crl")
|
||||
resp.append_header("Content-Disposition", "attachment; filename=%s.crl" % req.context.get("ca").common_name)
|
||||
resp.body = req.context.get("ca").export_crl()
|
||||
|
||||
|
||||
class SignedCertificateDetailResource(CertificateAuthorityBase):
|
||||
@serialize
|
||||
@pop_certificate_authority
|
||||
@validate_common_name
|
||||
def on_get(self, req, resp, cn):
|
||||
path = os.path.join(req.context.get("ca").signed_dir, cn + ".pem")
|
||||
if not os.path.exists(path):
|
||||
raise falcon.HTTPNotFound()
|
||||
|
||||
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % cn)
|
||||
return Certificate(open(path))
|
||||
|
||||
@login_required
|
||||
@pop_certificate_authority
|
||||
@authorize_admin
|
||||
@validate_common_name
|
||||
def on_delete(self, req, resp, cn):
|
||||
req.context.get("ca").revoke(cn)
|
||||
|
||||
class LeaseResource(CertificateAuthorityBase):
|
||||
@serialize
|
||||
@login_required
|
||||
@pop_certificate_authority
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
from ipaddress import ip_address
|
||||
|
||||
# BUGBUG
|
||||
SQL_LEASES = """
|
||||
SELECT
|
||||
acquired,
|
||||
released,
|
||||
address,
|
||||
identities.data as identity
|
||||
FROM
|
||||
addresses
|
||||
RIGHT JOIN
|
||||
identities
|
||||
ON
|
||||
identities.id = addresses.identity
|
||||
WHERE
|
||||
addresses.released <> 1
|
||||
"""
|
||||
cnx = req.context.get("ca").database.get_connection()
|
||||
cursor = cnx.cursor()
|
||||
query = (SQL_LEASES)
|
||||
cursor.execute(query)
|
||||
|
||||
for acquired, released, address, identity in cursor:
|
||||
yield {
|
||||
"acquired": datetime.utcfromtimestamp(acquired),
|
||||
"released": datetime.utcfromtimestamp(released) if released else None,
|
||||
"address": ip_address(bytes(address)),
|
||||
"identity": parse_dn(bytes(identity))
|
||||
}
|
||||
|
||||
|
||||
class SignedCertificateListResource(CertificateAuthorityBase):
|
||||
@serialize
|
||||
@pop_certificate_authority
|
||||
@authorize_admin
|
||||
@validate_common_name
|
||||
def on_get(self, req, resp):
|
||||
for j in authority.get_signed():
|
||||
yield omit(
|
||||
key_type=j.key_type,
|
||||
key_length=j.key_length,
|
||||
identity=j.identity,
|
||||
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())
|
||||
|
||||
|
||||
class RequestDetailResource(CertificateAuthorityBase):
|
||||
@serialize
|
||||
@pop_certificate_authority
|
||||
@validate_common_name
|
||||
def on_get(self, req, resp, cn):
|
||||
"""
|
||||
Fetch certificate signing request as PEM
|
||||
"""
|
||||
path = os.path.join(req.context.get("ca").request_dir, cn + ".pem")
|
||||
if not os.path.exists(path):
|
||||
raise falcon.HTTPNotFound()
|
||||
|
||||
resp.append_header("Content-Type", "application/x-x509-user-cert")
|
||||
resp.append_header("Content-Disposition", "attachment; filename=%s.csr" % cn)
|
||||
return Request(open(path))
|
||||
|
||||
@login_required
|
||||
@pop_certificate_authority
|
||||
@authorize_admin
|
||||
@validate_common_name
|
||||
def on_patch(self, req, resp, cn):
|
||||
"""
|
||||
Sign a certificate signing request
|
||||
"""
|
||||
csr = req.context.get("ca").get_request(cn)
|
||||
cert = req.context.get("ca").sign(csr, overwrite=True, delete=True)
|
||||
os.unlink(csr.path)
|
||||
resp.body = "Certificate successfully signed"
|
||||
resp.status = falcon.HTTP_201
|
||||
resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn)
|
||||
|
||||
@login_required
|
||||
@pop_certificate_authority
|
||||
@authorize_admin
|
||||
def on_delete(self, req, resp, cn):
|
||||
req.context.get("ca").delete_request(cn)
|
||||
|
||||
|
||||
class RequestListResource(CertificateAuthorityBase):
|
||||
@serialize
|
||||
@pop_certificate_authority
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
for j in req.context.get("ca").get_requests():
|
||||
yield omit(
|
||||
key_type=j.key_type,
|
||||
key_length=j.key_length,
|
||||
identity=j.identity,
|
||||
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())
|
||||
|
||||
@pop_certificate_authority
|
||||
def on_post(self, req, resp):
|
||||
"""
|
||||
Submit certificate signing request (CSR) in PEM format
|
||||
"""
|
||||
# Parse remote IPv4/IPv6 address
|
||||
remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"])
|
||||
ca = req.context.get("ca")
|
||||
|
||||
# Check for CSR submission whitelist
|
||||
if ca.request_subnets:
|
||||
for subnet in ca.request_subnets:
|
||||
if subnet.overlaps(remote_addr):
|
||||
break
|
||||
else:
|
||||
raise falcon.HTTPForbidden("Forbidden", "IP address %s not whitelisted" % remote_addr)
|
||||
|
||||
if req.get_header("Content-Type") != "application/pkcs10":
|
||||
raise falcon.HTTPUnsupportedMediaType(
|
||||
"This API call accepts only application/pkcs10 content type")
|
||||
|
||||
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
|
||||
try:
|
||||
cert_buf = ca.get_certificate(csr.common_name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
cert = Certificate(cert_buf)
|
||||
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
|
||||
|
||||
# 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_as_bool("autosign"):
|
||||
for subnet in ca.autosign_subnets:
|
||||
if subnet.overlaps(remote_addr):
|
||||
try:
|
||||
resp.append_header("Content-Type", "application/x-x509-user-cert")
|
||||
resp.body = ca.sign(csr).dump()
|
||||
return
|
||||
except FileExistsError: # Certificate already exists, try to save the request
|
||||
pass
|
||||
break
|
||||
|
||||
# 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())
|
||||
# Wait the certificate to be signed if waiting is requested
|
||||
if req.get_param("wait"):
|
||||
if ca.push_server:
|
||||
# Redirect to nginx pub/sub
|
||||
url = ca.push_server + "/lp/" + request.fingerprint()
|
||||
click.echo("Redirecting to: %s" % url)
|
||||
resp.status = falcon.HTTP_SEE_OTHER
|
||||
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):
|
||||
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()
|
||||
|
||||
class CertificateAuthorityResource(CertificateAuthorityBase):
|
||||
@pop_certificate_authority
|
||||
def on_get(self, req, resp):
|
||||
path = os.path.join(req.context.get("ca").certificate.path)
|
||||
resp.stream = open(path, "rb")
|
||||
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % req.context.get("ca").common_name)
|
||||
|
||||
class IndexResource(CertificateAuthorityBase):
|
||||
@serialize
|
||||
@login_required
|
||||
@pop_certificate_authority
|
||||
@authorize_admin
|
||||
@event_source
|
||||
def on_get(self, req, resp):
|
||||
return req.context.get("ca")
|
||||
|
||||
class SessionResource(CertificateAuthorityBase):
|
||||
@serialize
|
||||
@login_required
|
||||
def on_get(self, req, resp):
|
||||
return dict(
|
||||
authorities=(self.config.ca_list), # TODO: Check if user is CA admin
|
||||
username=req.context.get("user")[0]
|
||||
)
|
||||
|
||||
def address_to_identity(cnx, addr):
|
||||
"""
|
||||
Translate currently online client's IP-address to distinguished name
|
||||
"""
|
||||
|
||||
SQL_LEASES = """
|
||||
SELECT
|
||||
acquired,
|
||||
released,
|
||||
identities.data as identity
|
||||
FROM
|
||||
addresses
|
||||
RIGHT JOIN
|
||||
identities
|
||||
ON
|
||||
identities.id = addresses.identity
|
||||
WHERE
|
||||
address = %s AND
|
||||
released IS NOT NULL
|
||||
"""
|
||||
|
||||
cursor = cnx.cursor()
|
||||
query = (SQL_LEASES)
|
||||
import struct
|
||||
cursor.execute(query, (struct.pack("!L", int(addr)),))
|
||||
|
||||
for acquired, released, identity in cursor:
|
||||
return {
|
||||
"acquired": datetime.utcfromtimestamp(acquired),
|
||||
"identity": parse_dn(bytes(identity))
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class WhoisResource(CertificateAuthorityBase):
|
||||
@serialize
|
||||
@pop_certificate_authority
|
||||
def on_get(self, req, resp):
|
||||
identity = address_to_identity(
|
||||
req.context.get("ca").database.get_connection(),
|
||||
ipaddress.ip_address(req.get_param("address") or req.env["REMOTE_ADDR"])
|
||||
)
|
||||
|
||||
if identity:
|
||||
return identity
|
||||
else:
|
||||
resp.status = falcon.HTTP_403
|
||||
resp.body = "Failed to look up node %s" % req.env["REMOTE_ADDR"]
|
||||
|
||||
|
||||
class ApplicationConfigurationResource(CertificateAuthorityBase):
|
||||
@pop_certificate_authority
|
||||
@validate_common_name
|
||||
def on_get(self, req, resp, cn):
|
||||
ctx = dict(
|
||||
cn = cn,
|
||||
certificate = req.context.get("ca").get_certificate(cn),
|
||||
ca_certificate = open(req.context.get("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" % req.context.get("ca").common_name).read()).render(ctx)
|
||||
|
||||
@login_required
|
||||
@pop_certificate_authority
|
||||
@authorize_admin
|
||||
@validate_common_name
|
||||
def on_put(self, req, resp, cn=None):
|
||||
pkey_buf, req_buf, cert_buf = req.context.get("ca").create_bundle(cn)
|
||||
|
||||
ctx = dict(
|
||||
private_key = pkey_buf,
|
||||
certificate = cert_buf,
|
||||
ca_certificate = req.context.get("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" % req.context.get("ca").common_name).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()
|
||||
|
||||
# Certificate authority API calls
|
||||
app.add_route("/api/ocsp/", CertificateStatusResource(config))
|
||||
app.add_route("/api/signed/{cn}/openvpn", ApplicationConfigurationResource(config))
|
||||
app.add_route("/api/certificate/", CertificateAuthorityResource(config))
|
||||
app.add_route("/api/revoked/", RevocationListResource(config))
|
||||
app.add_route("/api/signed/{cn}/", SignedCertificateDetailResource(config))
|
||||
app.add_route("/api/signed/", SignedCertificateListResource(config))
|
||||
app.add_route("/api/request/{cn}/", RequestDetailResource(config))
|
||||
app.add_route("/api/request/", RequestListResource(config))
|
||||
app.add_route("/api/", IndexResource(config))
|
||||
app.add_route("/api/session/", SessionResource(config))
|
||||
|
||||
# Gateway API calls, should this be moved to separate project?
|
||||
app.add_route("/api/lease/", LeaseResource(config))
|
||||
app.add_route("/api/whois/", WhoisResource(config))
|
||||
return app
|
98
certidude/api/__init__.py
Normal file
98
certidude/api/__init__.py
Normal file
@ -0,0 +1,98 @@
|
||||
import falcon
|
||||
import mimetypes
|
||||
import os
|
||||
import click
|
||||
from time import sleep
|
||||
from certidude import authority
|
||||
from certidude.auth import login_required, authorize_admin
|
||||
from certidude.decorators import serialize, event_source
|
||||
from certidude.wrappers import Request, Certificate
|
||||
from certidude import config
|
||||
|
||||
class CertificateStatusResource(object):
|
||||
"""
|
||||
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):
|
||||
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()
|
||||
|
||||
|
||||
class CertificateAuthorityResource(object):
|
||||
def on_get(self, req, resp):
|
||||
resp.stream = open(config.AUTHORITY_CERTIFICATE_PATH, "rb")
|
||||
resp.append_header("Content-Disposition", "attachment; filename=ca.crt")
|
||||
|
||||
|
||||
class SessionResource(object):
|
||||
@serialize
|
||||
@login_required
|
||||
@authorize_admin
|
||||
@event_source
|
||||
def on_get(self, req, resp):
|
||||
return dict(
|
||||
username=req.context.get("user")[0],
|
||||
event_channel = config.PUSH_EVENT_SOURCE % config.PUSH_TOKEN,
|
||||
autosign_subnets = config.AUTOSIGN_SUBNETS,
|
||||
request_subnets = config.REQUEST_SUBNETS,
|
||||
admin_subnets=config.ADMIN_SUBNETS,
|
||||
admin_users=config.ADMIN_USERS,
|
||||
requests=authority.list_requests(),
|
||||
signed=authority.list_signed(),
|
||||
revoked=authority.list_revoked())
|
||||
|
||||
|
||||
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():
|
||||
from .revoked import RevocationListResource
|
||||
from .signed import SignedCertificateListResource, SignedCertificateDetailResource
|
||||
from .request import RequestListResource, RequestDetailResource
|
||||
from .lease import LeaseResource
|
||||
from .whois import WhoisResource
|
||||
|
||||
app = falcon.API()
|
||||
|
||||
# Certificate authority API calls
|
||||
app.add_route("/api/ocsp/", CertificateStatusResource())
|
||||
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())
|
||||
|
||||
# Gateway API calls, should this be moved to separate project?
|
||||
app.add_route("/api/lease/", LeaseResource())
|
||||
app.add_route("/api/whois/", WhoisResource())
|
||||
|
||||
return app
|
65
certidude/api/lease.py
Normal file
65
certidude/api/lease.py
Normal file
@ -0,0 +1,65 @@
|
||||
|
||||
from datetime import datetime
|
||||
from pyasn1.codec.der import decoder
|
||||
from certidude import config
|
||||
from certidude.auth import login_required, authorize_admin
|
||||
from certidude.decorators import serialize
|
||||
|
||||
OIDS = {
|
||||
(2, 5, 4, 3) : 'CN', # common name
|
||||
(2, 5, 4, 6) : 'C', # country
|
||||
(2, 5, 4, 7) : 'L', # locality
|
||||
(2, 5, 4, 8) : 'ST', # stateOrProvince
|
||||
(2, 5, 4, 10) : 'O', # organization
|
||||
(2, 5, 4, 11) : 'OU', # organizationalUnit
|
||||
}
|
||||
|
||||
def parse_dn(data):
|
||||
chunks, remainder = decoder.decode(data)
|
||||
dn = ""
|
||||
if remainder:
|
||||
raise ValueError()
|
||||
# TODO: Check for duplicate entries?
|
||||
def generate():
|
||||
for chunk in chunks:
|
||||
for chunkette in chunk:
|
||||
key, value = chunkette
|
||||
yield str(OIDS[key] + "=" + value)
|
||||
return ", ".join(generate())
|
||||
|
||||
|
||||
class LeaseResource(object):
|
||||
@serialize
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
from ipaddress import ip_address
|
||||
|
||||
# BUGBUG
|
||||
SQL_LEASES = """
|
||||
SELECT
|
||||
acquired,
|
||||
released,
|
||||
address,
|
||||
identities.data as identity
|
||||
FROM
|
||||
addresses
|
||||
RIGHT JOIN
|
||||
identities
|
||||
ON
|
||||
identities.id = addresses.identity
|
||||
WHERE
|
||||
addresses.released <> 1
|
||||
"""
|
||||
cnx = config.DATABASE_POOL.get_connection()
|
||||
cursor = cnx.cursor()
|
||||
cursor.execute(SQL_LEASES)
|
||||
|
||||
for acquired, released, address, identity in cursor:
|
||||
yield {
|
||||
"acquired": datetime.utcfromtimestamp(acquired),
|
||||
"released": datetime.utcfromtimestamp(released) if released else None,
|
||||
"address": ip_address(bytes(address)),
|
||||
"identity": parse_dn(bytes(identity))
|
||||
}
|
||||
|
119
certidude/api/request.py
Normal file
119
certidude/api/request.py
Normal file
@ -0,0 +1,119 @@
|
||||
|
||||
import click
|
||||
import falcon
|
||||
import ipaddress
|
||||
import os
|
||||
from certidude import config, authority, helpers, push
|
||||
from certidude.auth import login_required, authorize_admin
|
||||
from certidude.decorators import serialize
|
||||
from certidude.wrappers import Request, Certificate
|
||||
|
||||
class RequestListResource(object):
|
||||
@serialize
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
return helpers.list_requests()
|
||||
|
||||
def on_post(self, req, resp):
|
||||
"""
|
||||
Submit certificate signing request (CSR) in PEM format
|
||||
"""
|
||||
# Parse remote IPv4/IPv6 address
|
||||
remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"])
|
||||
|
||||
# Check for CSR submission whitelist
|
||||
if config.REQUEST_SUBNETS:
|
||||
for subnet in config.REQUEST_SUBNETS:
|
||||
if subnet.overlaps(remote_addr):
|
||||
break
|
||||
else:
|
||||
raise falcon.HTTPForbidden("Forbidden", "IP address %s not whitelisted" % remote_addr)
|
||||
|
||||
if req.get_header("Content-Type") != "application/pkcs10":
|
||||
raise falcon.HTTPUnsupportedMediaType(
|
||||
"This API call accepts only application/pkcs10 content type")
|
||||
|
||||
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
|
||||
try:
|
||||
cert = authority.get_signed(csr.common_name)
|
||||
except FileNotFoundError:
|
||||
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
|
||||
|
||||
# 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_as_bool("autosign"):
|
||||
for subnet in config.AUTOSIGN_SUBNETS:
|
||||
if subnet.overlaps(remote_addr):
|
||||
try:
|
||||
resp.set_header("Content-Type", "application/x-x509-user-cert")
|
||||
resp.body = authority.sign(csr).dump()
|
||||
return
|
||||
except FileExistsError: # Certificate already exists, try to save the request
|
||||
pass
|
||||
break
|
||||
|
||||
# Attempt to save the request otherwise
|
||||
try:
|
||||
csr = authority.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")
|
||||
push.publish("request_submitted", csr.common_name)
|
||||
|
||||
# Wait the certificate to be signed if waiting is requested
|
||||
if req.get_param("wait"):
|
||||
# Redirect to nginx pub/sub
|
||||
url = config.PUSH_LONG_POLL % csr.fingerprint()
|
||||
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
|
||||
|
||||
|
||||
class RequestDetailResource(object):
|
||||
@serialize
|
||||
def on_get(self, req, resp, cn):
|
||||
"""
|
||||
Fetch certificate signing request as PEM
|
||||
"""
|
||||
csr = authority.get_request(cn)
|
||||
# if not os.path.exists(path):
|
||||
# raise falcon.HTTPNotFound()
|
||||
|
||||
resp.set_header("Content-Type", "application/pkcs10")
|
||||
resp.set_header("Content-Disposition", "attachment; filename=%s.csr" % csr.common_name)
|
||||
return csr
|
||||
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_patch(self, req, resp, cn):
|
||||
"""
|
||||
Sign a certificate signing request
|
||||
"""
|
||||
csr = authority.get_request(cn)
|
||||
cert = authority.sign(csr, overwrite=True, delete=True)
|
||||
os.unlink(csr.path)
|
||||
resp.body = "Certificate successfully signed"
|
||||
resp.status = falcon.HTTP_201
|
||||
resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn)
|
||||
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_delete(self, req, resp, cn):
|
||||
try:
|
||||
authority.delete_request(cn)
|
||||
except FileNotFoundError:
|
||||
resp.body = "No certificate CN=%s found" % cn
|
||||
raise falcon.HTTPNotFound()
|
9
certidude/api/revoked.py
Normal file
9
certidude/api/revoked.py
Normal file
@ -0,0 +1,9 @@
|
||||
|
||||
from certidude.authority import export_crl
|
||||
|
||||
class RevocationListResource(object):
|
||||
def on_get(self, req, resp):
|
||||
resp.set_header("Content-Type", "application/x-pkcs7-crl")
|
||||
resp.append_header("Content-Disposition", "attachment; filename=ca.crl")
|
||||
resp.body = export_crl()
|
||||
|
38
certidude/api/signed.py
Normal file
38
certidude/api/signed.py
Normal file
@ -0,0 +1,38 @@
|
||||
|
||||
import falcon
|
||||
from certidude import authority
|
||||
from certidude.auth import login_required, authorize_admin
|
||||
from certidude.decorators import serialize
|
||||
|
||||
class SignedCertificateListResource(object):
|
||||
@serialize
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
for j in authority.list_signed():
|
||||
yield omit(
|
||||
key_type=j.key_type,
|
||||
key_length=j.key_length,
|
||||
identity=j.identity,
|
||||
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())
|
||||
|
||||
|
||||
class SignedCertificateDetailResource(object):
|
||||
@serialize
|
||||
def on_get(self, req, resp, cn):
|
||||
try:
|
||||
return authority.get_signed(cn)
|
||||
except FileNotFoundError:
|
||||
resp.body = "No certificate CN=%s found" % cn
|
||||
raise falcon.HTTPNotFound()
|
||||
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_delete(self, req, resp, cn):
|
||||
authority.revoke_certificate(cn)
|
||||
|
52
certidude/api/whois.py
Normal file
52
certidude/api/whois.py
Normal file
@ -0,0 +1,52 @@
|
||||
|
||||
import falcon
|
||||
import ipaddress
|
||||
from certidude import config
|
||||
from certidude.decorators import serialize
|
||||
|
||||
def address_to_identity(cnx, addr):
|
||||
"""
|
||||
Translate currently online client's IP-address to distinguished name
|
||||
"""
|
||||
|
||||
SQL_LEASES = """
|
||||
SELECT
|
||||
acquired,
|
||||
released,
|
||||
identities.data as identity
|
||||
FROM
|
||||
addresses
|
||||
RIGHT JOIN
|
||||
identities
|
||||
ON
|
||||
identities.id = addresses.identity
|
||||
WHERE
|
||||
address = %s AND
|
||||
released IS NOT NULL
|
||||
"""
|
||||
|
||||
cursor = cnx.cursor()
|
||||
import struct
|
||||
cursor.execute(SQL_LEASES, (struct.pack("!L", int(addr)),))
|
||||
|
||||
for acquired, released, identity in cursor:
|
||||
return {
|
||||
"acquired": datetime.utcfromtimestamp(acquired),
|
||||
"identity": parse_dn(bytes(identity))
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class WhoisResource(object):
|
||||
@serialize
|
||||
def on_get(self, req, resp):
|
||||
identity = address_to_identity(
|
||||
config.DATABASE_POOL.get_connection(),
|
||||
ipaddress.ip_address(req.get_param("address") or req.env["REMOTE_ADDR"])
|
||||
)
|
||||
|
||||
if identity:
|
||||
return identity
|
||||
else:
|
||||
resp.status = falcon.HTTP_403
|
||||
resp.body = "Failed to look up node %s" % req.env["REMOTE_ADDR"]
|
@ -1,6 +1,7 @@
|
||||
|
||||
import click
|
||||
import falcon
|
||||
import ipaddress
|
||||
import kerberos
|
||||
import os
|
||||
import re
|
||||
@ -70,3 +71,28 @@ def login_required(func):
|
||||
raise falcon.HTTPForbidden("Forbidden", "Tried GSSAPI")
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def authorize_admin(func):
|
||||
def wrapped(self, req, resp, *args, **kwargs):
|
||||
from certidude import config
|
||||
# Parse remote IPv4/IPv6 address
|
||||
remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"])
|
||||
|
||||
# Check for administration subnet whitelist
|
||||
print("Comparing:", config.ADMIN_SUBNETS, "To:", remote_addr)
|
||||
for subnet in config.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 = req.context.get("user")
|
||||
if kerberos_username not in config.ADMIN_USERS:
|
||||
raise falcon.HTTPForbidden("Forbidden", "User %s not whitelisted" % kerberos_username)
|
||||
|
||||
# Retain username, TODO: Better abstraction with username, e-mail, sn, gn?
|
||||
|
||||
return func(self, req, resp, *args, **kwargs)
|
||||
return wrapped
|
||||
|
223
certidude/authority.py
Normal file
223
certidude/authority.py
Normal file
@ -0,0 +1,223 @@
|
||||
|
||||
import click
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import urllib.request
|
||||
from OpenSSL import crypto
|
||||
from certidude import config, push
|
||||
from certidude.wrappers import Certificate, Request
|
||||
from certidude.signer import raw_sign
|
||||
|
||||
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])$"
|
||||
|
||||
# https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/
|
||||
# https://jamielinux.com/docs/openssl-certificate-authority/
|
||||
# http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py
|
||||
|
||||
def publish_certificate(func):
|
||||
# TODO: Implement e-mail and nginx notifications using hooks
|
||||
def wrapped(csr, *args, **kwargs):
|
||||
cert = func(csr, *args, **kwargs)
|
||||
assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert))
|
||||
|
||||
if config.PUSH_PUBLISH:
|
||||
url = config.PUSH_PUBLISH % csr.fingerprint()
|
||||
notification = urllib.request.Request(url, cert.dump().encode("ascii"))
|
||||
notification.add_header("User-Agent", "Certidude API")
|
||||
notification.add_header("Content-Type", "application/x-x509-user-cert")
|
||||
click.echo("Publishing certificate at %s, waiting for response..." % url)
|
||||
response = urllib.request.urlopen(notification)
|
||||
response.read()
|
||||
push.publish("request_signed", csr.common_name)
|
||||
return cert
|
||||
return wrapped
|
||||
|
||||
def get_request(common_name):
|
||||
if not re.match(RE_HOSTNAME, common_name):
|
||||
raise ValueError("Invalid common name")
|
||||
return Request(open(os.path.join(config.REQUESTS_DIR, common_name + ".pem")))
|
||||
|
||||
def get_signed(common_name):
|
||||
if not re.match(RE_HOSTNAME, common_name):
|
||||
raise ValueError("Invalid common name")
|
||||
return Certificate(open(os.path.join(config.SIGNED_DIR, common_name + ".pem")))
|
||||
|
||||
def get_revoked(common_name):
|
||||
if not re.match(RE_HOSTNAME, common_name):
|
||||
raise ValueError("Invalid common name")
|
||||
return Certificate(open(os.path.join(config.SIGNED_DIR, common_name + ".pem")))
|
||||
|
||||
def store_request(buf, overwrite=False):
|
||||
"""
|
||||
Store CSR for later processing
|
||||
"""
|
||||
request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf)
|
||||
common_name = request.get_subject().CN
|
||||
request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
|
||||
|
||||
if not re.match(RE_HOSTNAME, common_name):
|
||||
raise ValueError("Invalid common name")
|
||||
|
||||
# If there is cert, check if it's the same
|
||||
if os.path.exists(request_path):
|
||||
if open(request_path, "rb").read() != buf:
|
||||
print("Request already exists, not creating new request")
|
||||
raise FileExistsError("Request already exists")
|
||||
else:
|
||||
with open(request_path + ".part", "wb") as fh:
|
||||
fh.write(buf)
|
||||
os.rename(request_path + ".part", request_path)
|
||||
|
||||
return Request(open(request_path))
|
||||
|
||||
|
||||
def signer_exec(cmd, *bits):
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(config.SIGNER_SOCKET_PATH)
|
||||
sock.send(cmd.encode("ascii"))
|
||||
sock.send(b"\n")
|
||||
for bit in bits:
|
||||
sock.send(bit.encode("ascii"))
|
||||
sock.sendall(b"\n\n")
|
||||
buf = sock.recv(8192)
|
||||
if not buf:
|
||||
raise
|
||||
return buf
|
||||
|
||||
|
||||
def revoke_certificate(common_name):
|
||||
"""
|
||||
Revoke valid certificate
|
||||
"""
|
||||
cert = get_signed(common_name)
|
||||
revoked_filename = os.path.join(config.REVOKED_DIR, "%s.pem" % cert.serial_number)
|
||||
os.rename(cert.path, revoked_filename)
|
||||
push.publish("certificate_revoked", cert.fingerprint())
|
||||
|
||||
|
||||
def list_requests(directory=config.REQUESTS_DIR):
|
||||
for filename in os.listdir(directory):
|
||||
if filename.endswith(".pem"):
|
||||
yield Request(open(os.path.join(directory, filename)))
|
||||
|
||||
|
||||
def list_signed(directory=config.SIGNED_DIR):
|
||||
for filename in os.listdir(directory):
|
||||
if filename.endswith(".pem"):
|
||||
yield Certificate(open(os.path.join(directory, filename)))
|
||||
|
||||
|
||||
def list_revoked(directory=config.REVOKED_DIR):
|
||||
for filename in os.listdir(directory):
|
||||
if filename.endswith(".pem"):
|
||||
yield Certificate(open(os.path.join(directory, filename)))
|
||||
|
||||
|
||||
def export_crl(self):
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(config.SIGNER_SOCKET_PATH)
|
||||
sock.send(b"export-crl\n")
|
||||
for filename in os.listdir(self.revoked_dir):
|
||||
if not filename.endswith(".pem"):
|
||||
continue
|
||||
serial_number = filename[:-4]
|
||||
# TODO: Assert serial against regex
|
||||
revoked_path = os.path.join(self.revoked_dir, filename)
|
||||
# TODO: Skip expired certificates
|
||||
s = os.stat(revoked_path)
|
||||
sock.send(("%s:%d\n" % (serial_number, s.st_ctime)).encode("ascii"))
|
||||
sock.sendall(b"\n")
|
||||
return sock.recv(32*1024*1024)
|
||||
|
||||
|
||||
def delete_request(common_name):
|
||||
# Validate CN
|
||||
if not re.match(RE_HOSTNAME, common_name):
|
||||
raise ValueError("Invalid common name")
|
||||
|
||||
path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
|
||||
request_sha1sum = Request(open(path)).fingerprint()
|
||||
os.unlink(path)
|
||||
|
||||
# Publish event at CA channel
|
||||
push.publish("request_deleted", request_sha1sum)
|
||||
|
||||
# Write empty certificate to long-polling URL
|
||||
url = config.PUSH_PUBLISH % request_sha1sum
|
||||
click.echo("POST-ing empty certificate at %s, waiting for response..." % url)
|
||||
publisher = urllib.request.Request(url, b"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n")
|
||||
publisher.add_header("User-Agent", "Certidude API")
|
||||
|
||||
try:
|
||||
response = urllib.request.urlopen(publisher)
|
||||
body = response.read()
|
||||
except urllib.error.HTTPError as err:
|
||||
if err.code == 404:
|
||||
print("No subscribers on the channel")
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
print("Push server returned:", response.code, body)
|
||||
|
||||
|
||||
@publish_certificate
|
||||
def sign(req, overwrite=False, delete=True):
|
||||
"""
|
||||
Sign certificate signing request via signer process
|
||||
"""
|
||||
|
||||
cert_path = os.path.join(config.SIGNED_DIR, req.common_name + ".pem")
|
||||
|
||||
# Move existing certificate if necessary
|
||||
if os.path.exists(cert_path):
|
||||
old_cert = Certificate(open(cert_path))
|
||||
if overwrite:
|
||||
revoke_certificate(req.common_name)
|
||||
elif req.pubkey == old_cert.pubkey:
|
||||
return old_cert
|
||||
else:
|
||||
raise FileExistsError("Will not overwrite existing certificate")
|
||||
|
||||
# Sign via signer process
|
||||
cert_buf = signer_exec("sign-request", req.dump())
|
||||
with open(cert_path + ".part", "wb") as fh:
|
||||
fh.write(cert_buf)
|
||||
os.rename(cert_path + ".part", cert_path)
|
||||
|
||||
return Certificate(open(cert_path))
|
||||
|
||||
|
||||
@publish_certificate
|
||||
def sign2(request, overwrite=False, delete=True, lifetime=None):
|
||||
"""
|
||||
Sign directly using private key, this is usually done by root.
|
||||
Basic constraints and certificate lifetime are copied from config,
|
||||
lifetime may be overridden on the command line,
|
||||
other extensions are copied as is.
|
||||
"""
|
||||
cert = raw_sign(
|
||||
crypto.load_privatekey(crypto.FILETYPE_PEM, open(config.AUTHORITY_PRIVATE_KEY_PATH).read()),
|
||||
crypto.load_certificate(crypto.FILETYPE_PEM, open(config.AUTHORITY_CERTIFICATE_PATH).read()),
|
||||
request._obj,
|
||||
config.CERTIFICATE_BASIC_CONSTRAINTS,
|
||||
lifetime=lifetime or config.CERTIFICATE_LIFETIME)
|
||||
|
||||
path = os.path.join(config.SIGNED_DIR, request.common_name + ".pem")
|
||||
if os.path.exists(path):
|
||||
if overwrite:
|
||||
revoke(request.common_name)
|
||||
else:
|
||||
raise FileExistsError("File %s already exists!" % path)
|
||||
|
||||
buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
|
||||
with open(path + ".part", "wb") as fh:
|
||||
fh.write(buf)
|
||||
os.rename(path + ".part", path)
|
||||
click.echo("Wrote certificate to: %s" % path)
|
||||
if delete:
|
||||
os.unlink(request.path)
|
||||
click.echo("Deleted request: %s" % request.path)
|
||||
|
||||
return Certificate(open(path))
|
||||
|
160
certidude/cli.py
160
certidude/cli.py
@ -13,10 +13,9 @@ import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from certidude.helpers import expand_paths, \
|
||||
certidude_request_certificate
|
||||
from certidude import authority
|
||||
from certidude.signer import SignServer
|
||||
from certidude.wrappers import CertificateAuthorityConfig, subject2dn
|
||||
from certidude.common import expand_paths
|
||||
from datetime import datetime
|
||||
from humanize import naturaltime
|
||||
from ipaddress import ip_network, ip_address
|
||||
@ -62,20 +61,15 @@ if os.getuid() >= 1000:
|
||||
FIRST_NAME = gecos
|
||||
|
||||
|
||||
def load_config():
|
||||
path = os.getenv('CERTIDUDE_CONF')
|
||||
if path and os.path.isfile(path):
|
||||
return CertificateAuthorityConfig(path)
|
||||
return CertificateAuthorityConfig()
|
||||
|
||||
|
||||
@click.command("spawn", help="Run privilege isolated signer processes")
|
||||
@click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instances")
|
||||
@click.command("spawn", help="Run privilege isolated signer process")
|
||||
@click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instance")
|
||||
@click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys")
|
||||
def certidude_spawn(kill, no_interaction):
|
||||
"""
|
||||
Spawn processes for signers
|
||||
"""
|
||||
from certidude import config
|
||||
|
||||
# Check whether we have privileges
|
||||
os.umask(0o027)
|
||||
uid = os.getuid()
|
||||
@ -84,13 +78,12 @@ def certidude_spawn(kill, no_interaction):
|
||||
|
||||
# Process directories
|
||||
run_dir = "/run/certidude"
|
||||
signer_dir = os.path.join(run_dir, "signer")
|
||||
chroot_dir = os.path.join(signer_dir, "jail")
|
||||
chroot_dir = os.path.join(run_dir, "jail")
|
||||
|
||||
# Prepare signer PID-s directory
|
||||
if not os.path.exists(signer_dir):
|
||||
click.echo("Creating: %s" % signer_dir)
|
||||
os.makedirs(signer_dir)
|
||||
if not os.path.exists(run_dir):
|
||||
click.echo("Creating: %s" % run_dir)
|
||||
os.makedirs(run_dir)
|
||||
|
||||
# Preload charmap encoding for byte_string() function of pyOpenSSL
|
||||
# in order to enable chrooting
|
||||
@ -104,16 +97,13 @@ def certidude_spawn(kill, no_interaction):
|
||||
os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom"))
|
||||
|
||||
ca_loaded = False
|
||||
config = load_config()
|
||||
for ca in config.all_authorities():
|
||||
socket_path = os.path.join(signer_dir, ca.common_name + ".sock")
|
||||
pidfile_path = os.path.join(signer_dir, ca.common_name + ".pid")
|
||||
|
||||
|
||||
try:
|
||||
with open(pidfile_path) as fh:
|
||||
with open(config.SIGNER_PID_PATH) as fh:
|
||||
pid = int(fh.readline())
|
||||
os.kill(pid, 0)
|
||||
click.echo("Found process with PID %d for %s" % (pid, ca.common_name))
|
||||
click.echo("Found process with PID %d" % pid)
|
||||
except (ValueError, ProcessLookupError, FileNotFoundError):
|
||||
pid = 0
|
||||
|
||||
@ -127,31 +117,29 @@ def certidude_spawn(kill, no_interaction):
|
||||
sleep(1)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
ca_loaded = True
|
||||
else:
|
||||
ca_loaded = True
|
||||
continue
|
||||
|
||||
child_pid = os.fork()
|
||||
|
||||
if child_pid == 0:
|
||||
with open(pidfile_path, "w") as fh:
|
||||
with open(config.SIGNER_PID_PATH, "w") as fh:
|
||||
fh.write("%d\n" % os.getpid())
|
||||
|
||||
setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name))
|
||||
# setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name))
|
||||
logging.basicConfig(
|
||||
filename="/var/log/certidude-%s.log" % ca.common_name,
|
||||
filename="/var/log/signer.log",
|
||||
level=logging.INFO)
|
||||
server = SignServer(socket_path, ca.private_key, ca.certificate.path,
|
||||
ca.certificate_lifetime, ca.basic_constraints, ca.key_usage,
|
||||
ca.extended_key_usage, ca.revocation_list_lifetime)
|
||||
server = SignServer(
|
||||
config.SIGNER_SOCKET_PATH,
|
||||
config.AUTHORITY_PRIVATE_KEY_PATH,
|
||||
config.AUTHORITY_CERTIFICATE_PATH,
|
||||
config.CERTIFICATE_LIFETIME,
|
||||
config.CERTIFICATE_BASIC_CONSTRAINTS,
|
||||
config.CERTIFICATE_KEY_USAGE_FLAGS,
|
||||
config.CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS,
|
||||
config.REVOCATION_LIST_LIFETIME)
|
||||
asyncore.loop()
|
||||
else:
|
||||
click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, socket_path))
|
||||
ca_loaded = True
|
||||
|
||||
if not ca_loaded:
|
||||
raise click.ClickException("No CA sections defined in configuration: {}".format(config.path))
|
||||
click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH))
|
||||
|
||||
|
||||
@click.command("client", help="Setup X.509 certificates for application")
|
||||
@ -171,6 +159,7 @@ def certidude_spawn(kill, no_interaction):
|
||||
@click.option("--certificate-path", "-c", default=HOSTNAME + ".crt", help="Certificate path, %s.crt by default" % HOSTNAME)
|
||||
@click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt by default")
|
||||
def certidude_setup_client(quiet, **kwargs):
|
||||
from certidude.helpers import certidude_request_certificate
|
||||
return certidude_request_certificate(**kwargs)
|
||||
|
||||
|
||||
@ -197,6 +186,7 @@ def certidude_setup_client(quiet, **kwargs):
|
||||
@expand_paths()
|
||||
def certidude_setup_openvpn_server(url, config, subnet, route, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, dhparam_path, local, proto, port):
|
||||
# TODO: Intelligent way of getting last IP address in the subnet
|
||||
from certidude.helpers import certidude_request_certificate
|
||||
subnet_first = None
|
||||
subnet_last = None
|
||||
subnet_second = None
|
||||
@ -213,7 +203,6 @@ def certidude_setup_openvpn_server(url, config, subnet, route, email_address, co
|
||||
click.echo("use following command to sign on Certidude server instead of web interface:")
|
||||
click.echo()
|
||||
click.echo(" certidude sign %s" % common_name)
|
||||
|
||||
retval = certidude_request_certificate(
|
||||
url,
|
||||
key_path,
|
||||
@ -536,19 +525,14 @@ def certidude_setup_production(username, hostname, push_server, nginx_config, uw
|
||||
@click.option("--pkcs11", default=False, is_flag=True, help="Use PKCS#11 token instead of files")
|
||||
@click.option("--crl-distribution-url", default=None, help="CRL distribution URL")
|
||||
@click.option("--ocsp-responder-url", default=None, help="OCSP responder URL")
|
||||
@click.option("--email-address", default=EMAIL, help="CA e-mail address")
|
||||
@click.option("--inbox", default="imap://user:pass@host:port/INBOX", help="Inbound e-mail server")
|
||||
@click.option("--outbox", default="smtp://localhost", help="Outbound e-mail server")
|
||||
@click.option("--push-server", default="", help="Streaming nginx push server")
|
||||
@click.option("--directory", default=None, help="Directory for authority files, /var/lib/certidude/<common-name>/ by default")
|
||||
def certidude_setup_authority(parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, pkcs11, crl_distribution_url, ocsp_responder_url, email_address, inbox, outbox, push_server):
|
||||
|
||||
if not directory:
|
||||
directory = os.path.join("/var/lib/certidude", common_name)
|
||||
@click.option("--email-address", default="certidude@" + FQDN, help="E-mail address of the CA")
|
||||
@click.option("--directory", default=os.path.join("/var/lib/certidude", FQDN), help="Directory for authority files, /var/lib/certidude/ by default")
|
||||
def certidude_setup_authority(parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, pkcs11, crl_distribution_url, ocsp_responder_url, push_server, email_address):
|
||||
|
||||
# Make sure common_name is valid
|
||||
if not re.match(r"^[\._a-zA-Z0-9]+$", common_name):
|
||||
raise click.ClickException("CA name can contain only alphanumeric and '_' characters")
|
||||
if not re.match(r"^[\.\-_a-zA-Z0-9]+$", common_name):
|
||||
raise click.ClickException("CA name can contain only alphanumeric, '_' and '-' characters")
|
||||
|
||||
if os.path.lexists(directory):
|
||||
raise click.ClickException("Output directory {} already exists.".format(directory))
|
||||
@ -612,7 +596,6 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
|
||||
crl_distribution_points.encode("ascii"))
|
||||
])
|
||||
|
||||
if email_address:
|
||||
subject_alt_name = "email:%s" % email_address
|
||||
ca.add_extensions([
|
||||
crypto.X509Extension(
|
||||
@ -635,7 +618,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
|
||||
])
|
||||
"""
|
||||
|
||||
click.echo("Signing %s..." % subject2dn(ca.get_subject()))
|
||||
click.echo("Signing %s..." % ca.get_subject())
|
||||
|
||||
# openssl x509 -in ca_crt.pem -outform DER | sha256sum
|
||||
# openssl x509 -fingerprint -in ca_crt.pem
|
||||
@ -665,13 +648,9 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
|
||||
with open(ca_key, "wb") as fh:
|
||||
fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key))
|
||||
|
||||
ssl_cnf_example = os.path.join(directory, "openssl.cnf.example")
|
||||
with open(ssl_cnf_example, "w") as fh:
|
||||
fh.write(env.get_template("openssl.cnf").render(locals()))
|
||||
|
||||
click.echo("You need to copy the contents of the '%s'" % ssl_cnf_example)
|
||||
click.echo("to system-wide OpenSSL configuration file, usually located")
|
||||
click.echo("at /etc/ssl/openssl.cnf")
|
||||
certidude_conf = os.path.join("/etc/certidude.conf")
|
||||
with open(certidude_conf, "w") as fh:
|
||||
fh.write(env.get_template("certidude.conf").render(locals()))
|
||||
|
||||
click.echo()
|
||||
click.echo("Use following commands to inspect the newly created files:")
|
||||
@ -691,7 +670,6 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
|
||||
|
||||
|
||||
@click.command("list", help="List certificates")
|
||||
@click.argument("ca", nargs=-1)
|
||||
@click.option("--verbose", "-v", default=False, is_flag=True, help="Verbose output")
|
||||
@click.option("--show-key-type", "-k", default=False, is_flag=True, help="Show key type and length")
|
||||
@click.option("--show-path", "-p", default=False, is_flag=True, help="Show filesystem paths")
|
||||
@ -699,7 +677,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
|
||||
@click.option("--hide-requests", "-h", default=False, is_flag=True, help="Hide signing requests")
|
||||
@click.option("--show-signed", "-s", default=False, is_flag=True, help="Show signed certificates")
|
||||
@click.option("--show-revoked", "-r", default=False, is_flag=True, help="Show revoked certificates")
|
||||
def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_signed, show_revoked, hide_requests):
|
||||
def certidude_list(verbose, show_key_type, show_extensions, show_path, show_signed, show_revoked, hide_requests):
|
||||
# Statuses:
|
||||
# s - submitted
|
||||
# v - valid
|
||||
@ -738,18 +716,10 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_
|
||||
if j.fqdn:
|
||||
click.echo("Associated hostname: " + j.fqdn)
|
||||
|
||||
config = load_config()
|
||||
|
||||
wanted_list = None
|
||||
if ca:
|
||||
missing = list(set(ca) - set(config.ca_list))
|
||||
if missing:
|
||||
raise click.NoSuchOption(option_name='', message="Unable to find certificate authority.", possibilities=config.ca_list)
|
||||
wanted_list = ca
|
||||
|
||||
for ca in config.all_authorities(wanted_list):
|
||||
if not hide_requests:
|
||||
for j in ca.get_requests():
|
||||
for j in authority.list_requests():
|
||||
|
||||
if not verbose:
|
||||
click.echo("s " + j.path + " " + j.identity)
|
||||
continue
|
||||
@ -779,7 +749,7 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_
|
||||
click.echo()
|
||||
|
||||
if show_signed:
|
||||
for j in ca.get_signed():
|
||||
for j in authority.list_signed():
|
||||
if not verbose:
|
||||
if j.signed < NOW and j.expires > NOW:
|
||||
click.echo("v " + j.path + " " + j.identity)
|
||||
@ -806,7 +776,7 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_
|
||||
click.echo()
|
||||
|
||||
if show_revoked:
|
||||
for j in ca.get_revoked():
|
||||
for j in authority.list_revoked():
|
||||
if not verbose:
|
||||
click.echo("r " + j.path + " " + j.identity)
|
||||
continue
|
||||
@ -820,59 +790,19 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_
|
||||
|
||||
click.echo()
|
||||
|
||||
@click.command("list", help="List Certificate Authorities")
|
||||
@click.argument("ca")
|
||||
#@config.pop_certificate_authority()
|
||||
def cert_list(ca):
|
||||
|
||||
mapping = {}
|
||||
|
||||
config = load_config()
|
||||
|
||||
click.echo("Listing certificates for: %s" % ca.certificate.subject.CN)
|
||||
|
||||
for serial, reason, timestamp in ca.get_revoked():
|
||||
mapping[serial] = None, reason
|
||||
|
||||
for certificate in ca.get_signed():
|
||||
mapping[certificate.serial] = certificate, None
|
||||
|
||||
for serial, (certificate, reason) in sorted(mapping.items(), key=lambda j:j[0]):
|
||||
if not reason:
|
||||
click.echo(" %03d. %s %s" % (serial, certificate.subject.CN, (certificate.not_after-NOW)))
|
||||
else:
|
||||
click.echo(" %03d. Revoked due to: %s" % (serial, reason))
|
||||
|
||||
for request in ca.get_requests():
|
||||
click.echo(" ⌛ %s" % request.subject.CN)
|
||||
|
||||
@click.command("sign", help="Sign certificates")
|
||||
@click.argument("common_name")
|
||||
@click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN")
|
||||
@click.option("--lifetime", "-l", help="Lifetime")
|
||||
def certidude_sign(common_name, overwrite, lifetime):
|
||||
config = load_config()
|
||||
def iterate():
|
||||
for ca in config.all_authorities():
|
||||
for request in ca.get_requests():
|
||||
if request.common_name != common_name:
|
||||
continue
|
||||
print(request.fingerprint(), request.common_name, request.path, request.key_usage)
|
||||
yield ca, request
|
||||
|
||||
results = tuple(iterate())
|
||||
click.echo()
|
||||
|
||||
click.echo("Press Ctrl-C to cancel singing these requests...")
|
||||
sys.stdin.readline()
|
||||
|
||||
for ca, request in results:
|
||||
request = authority.get_request(common_name)
|
||||
if request.signable:
|
||||
# Sign via signer process
|
||||
cert = ca.sign(request)
|
||||
cert = authority.sign(request)
|
||||
else:
|
||||
# Sign directly using private key
|
||||
cert = ca.sign2(request, overwrite, True, lifetime)
|
||||
cert = authority.sign2(request, overwrite, True, lifetime)
|
||||
|
||||
click.echo("Signed %s" % cert.identity)
|
||||
for key, value, data in cert.extensions:
|
||||
|
27
certidude/common.py
Normal file
27
certidude/common.py
Normal file
@ -0,0 +1,27 @@
|
||||
|
||||
def expand_paths():
|
||||
"""
|
||||
Prefix '..._path' keyword arguments of target function with 'directory' keyword argument
|
||||
and create the directory if necessary
|
||||
|
||||
TODO: Move to separate file
|
||||
"""
|
||||
def wrapper(func):
|
||||
def wrapped(**arguments):
|
||||
d = arguments.get("directory")
|
||||
for key, value in arguments.items():
|
||||
if key.endswith("_path"):
|
||||
if d:
|
||||
value = os.path.join(d, value)
|
||||
value = os.path.realpath(value)
|
||||
parent = os.path.dirname(value)
|
||||
if not os.path.exists(parent):
|
||||
click.echo("Making directory %s for %s" % (repr(parent), repr(key)))
|
||||
os.makedirs(parent)
|
||||
elif not os.path.isdir(parent):
|
||||
raise Exception("Path %s is not directory!" % parent)
|
||||
arguments[key] = value
|
||||
return func(**arguments)
|
||||
return wrapped
|
||||
return wrapper
|
||||
|
61
certidude/config.py
Normal file
61
certidude/config.py
Normal file
@ -0,0 +1,61 @@
|
||||
|
||||
import click
|
||||
import configparser
|
||||
import ipaddress
|
||||
import os
|
||||
import string
|
||||
from random import choice
|
||||
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read("/etc/certidude.conf")
|
||||
|
||||
ADMIN_USERS = set([j for j in cp.get("authorization", "admin_users").split(" ") if j])
|
||||
ADMIN_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "admin_subnets").split(" ") if j])
|
||||
AUTOSIGN_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "autosign_subnets").split(" ") if j])
|
||||
REQUEST_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "request_subnets").split(" ") if j]).union(AUTOSIGN_SUBNETS)
|
||||
|
||||
SIGNER_SOCKET_PATH = "/run/certidude/signer.sock"
|
||||
SIGNER_PID_PATH = "/run/certidude/signer.pid"
|
||||
|
||||
AUTHORITY_DIR = "/var/lib/certidude"
|
||||
AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private_key_path")
|
||||
AUTHORITY_CERTIFICATE_PATH = cp.get("authority", "certificate_path")
|
||||
REQUESTS_DIR = cp.get("authority", "requests_dir")
|
||||
SIGNED_DIR = cp.get("authority", "signed_dir")
|
||||
REVOKED_DIR = cp.get("authority", "revoked_dir")
|
||||
|
||||
CERTIFICATE_BASIC_CONSTRAINTS = "CA:FALSE"
|
||||
CERTIFICATE_KEY_USAGE_FLAGS = "nonRepudiation,digitalSignature,keyEncipherment"
|
||||
CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS = "clientAuth"
|
||||
CERTIFICATE_LIFETIME = int(cp.get("signature", "certificate_lifetime"))
|
||||
|
||||
REVOCATION_LIST_LIFETIME = int(cp.get("signature", "revocation_list_lifetime"))
|
||||
|
||||
PUSH_TOKEN = "".join([choice(string.ascii_letters + string.digits) for j in range(0,32)])
|
||||
|
||||
PUSH_TOKEN = "ca"
|
||||
|
||||
try:
|
||||
PUSH_EVENT_SOURCE = cp.get("push", "event_source")
|
||||
PUSH_LONG_POLL = cp.get("push", "long_poll")
|
||||
PUSH_PUBLISH = cp.get("push", "publish")
|
||||
except configparser.NoOptionError:
|
||||
PUSH_SERVER = cp.get("push", "server")
|
||||
PUSH_EVENT_SOURCE = PUSH_SERVER + "/ev/%s"
|
||||
PUSH_LONG_POLL = PUSH_SERVER + "/lp/%s"
|
||||
PUSH_PUBLISH = PUSH_SERVER + "/pub?id=%s"
|
||||
|
||||
|
||||
from urllib.parse import urlparse
|
||||
o = urlparse(cp.get("authority", "database"))
|
||||
if o.scheme == "mysql":
|
||||
import mysql.connector
|
||||
DATABASE_POOL = mysql.connector.pooling.MySQLConnectionPool(
|
||||
pool_size = 3,
|
||||
user=o.username,
|
||||
password=o.password,
|
||||
host=o.hostname,
|
||||
database=o.path[1:])
|
||||
else:
|
||||
raise NotImplementedError("Unsupported database scheme %s, currently only mysql://user:pass@host/database is supported" % o.scheme)
|
||||
|
78
certidude/decorators.py
Normal file
78
certidude/decorators.py
Normal file
@ -0,0 +1,78 @@
|
||||
|
||||
import falcon
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import types
|
||||
from datetime import date, time, datetime
|
||||
from OpenSSL import crypto
|
||||
from certidude.wrappers import Request, Certificate
|
||||
|
||||
def event_source(func):
|
||||
def wrapped(self, req, resp, *args, **kwargs):
|
||||
if req.get_header("Accept") == "text/event-stream":
|
||||
resp.status = falcon.HTTP_SEE_OTHER
|
||||
resp.location = req.context.get("ca").push_server + "/ev/" + req.context.get("ca").uuid
|
||||
resp.body = "Redirecting to:" + resp.location
|
||||
print("Delegating EventSource handling to:", resp.location)
|
||||
return func(self, req, resp, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
class MyEncoder(json.JSONEncoder):
|
||||
REQUEST_ATTRIBUTES = "signable", "identity", "changed", "common_name", \
|
||||
"organizational_unit", "given_name", "surname", "fqdn", "email_address", \
|
||||
"key_type", "key_length", "md5sum", "sha1sum", "sha256sum", "key_usage"
|
||||
|
||||
CERTIFICATE_ATTRIBUTES = "revokable", "identity", "changed", "common_name", \
|
||||
"organizational_unit", "given_name", "surname", "fqdn", "email_address", \
|
||||
"key_type", "key_length", "sha256sum", "serial_number", "key_usage"
|
||||
|
||||
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)
|
||||
if isinstance(obj, datetime):
|
||||
return obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
if isinstance(obj, date):
|
||||
return obj.strftime("%Y-%m-%d")
|
||||
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 hasattr(obj, "serialize"):
|
||||
return obj.serialize()
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
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:
|
||||
if req.get_header("Accept").split(",")[0] == "application/json":
|
||||
resp.set_header("Content-Type", "application/json")
|
||||
resp.set_header("Content-Disposition", "inline")
|
||||
resp.body = json.dumps(r, cls=MyEncoder)
|
||||
else:
|
||||
resp.body = repr(r)
|
||||
return r
|
||||
return wrapped
|
||||
|
@ -2,34 +2,9 @@
|
||||
import click
|
||||
import os
|
||||
import urllib.request
|
||||
from certidude.wrappers import Certificate, Request
|
||||
from certidude import config
|
||||
from OpenSSL import crypto
|
||||
|
||||
def expand_paths():
|
||||
"""
|
||||
Prefix '..._path' keyword arguments of target function with 'directory' keyword argument
|
||||
and create the directory if necessary
|
||||
|
||||
TODO: Move to separate file
|
||||
"""
|
||||
def wrapper(func):
|
||||
def wrapped(**arguments):
|
||||
d = arguments.get("directory")
|
||||
for key, value in arguments.items():
|
||||
if key.endswith("_path"):
|
||||
if d:
|
||||
value = os.path.join(d, value)
|
||||
value = os.path.realpath(value)
|
||||
parent = os.path.dirname(value)
|
||||
if not os.path.exists(parent):
|
||||
click.echo("Making directory %s for %s" % (repr(parent), repr(key)))
|
||||
os.makedirs(parent)
|
||||
elif not os.path.isdir(parent):
|
||||
raise Exception("Path %s is not directory!" % parent)
|
||||
arguments[key] = value
|
||||
return func(**arguments)
|
||||
return wrapped
|
||||
return wrapper
|
||||
|
||||
|
||||
def certidude_request_certificate(url, key_path, request_path, certificate_path, authority_path, common_name, org_unit, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None, ip_address=None, dns=None):
|
||||
|
28
certidude/push.py
Normal file
28
certidude/push.py
Normal file
@ -0,0 +1,28 @@
|
||||
|
||||
import click
|
||||
import urllib.request
|
||||
from certidude import config
|
||||
|
||||
def publish(event_type, event_data):
|
||||
"""
|
||||
Publish event on push server
|
||||
"""
|
||||
url = config.PUSH_PUBLISH % config.PUSH_TOKEN
|
||||
click.echo("Posting event %s %s at %s, waiting for response..." % (repr(event_type), repr(event_data), repr(url)))
|
||||
notification = urllib.request.Request(
|
||||
url,
|
||||
event_data.encode("utf-8"),
|
||||
{"Event-ID": b"TODO", "Event-Type":event_type.encode("ascii")})
|
||||
notification.add_header("User-Agent", "Certidude API")
|
||||
|
||||
try:
|
||||
response = urllib.request.urlopen(notification)
|
||||
body = response.read()
|
||||
except urllib.error.HTTPError as err:
|
||||
if err.code == 404:
|
||||
print("No subscribers on the channel")
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
print("Push server returned:", response.code, body)
|
||||
|
@ -1,53 +1,56 @@
|
||||
<h1>{{authority.common_name}} management</h1>
|
||||
|
||||
<p>Hi {{session.username}},</p>
|
||||
|
||||
<p>Request submission is allowed from: {% if authority.request_subnets %}{% for i in authority.request_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}</p>
|
||||
<p>Autosign is allowed from: {% if authority.autosign_subnets %}{% for i in authority.autosign_subnets %}{{ i }} {% endfor %}{% else %}nowhere{% endif %}</p>
|
||||
<p>Authority administration is allowed from: {% if authority.admin_subnets %}{% for i in authority.admin_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}
|
||||
<p>Authority administration allowed for: {% for i in authority.admin_users %}{{ i }} {% endfor %}</p>
|
||||
<p>Request submission is allowed from: {% if session.request_subnets %}{% for i in session.request_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}</p>
|
||||
<p>Autosign is allowed from: {% if session.autosign_subnets %}{% for i in session.autosign_subnets %}{{ i }} {% endfor %}{% else %}nowhere{% endif %}</p>
|
||||
<p>Authority administration is allowed from: {% if session.admin_subnets %}{% for i in session.admin_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}
|
||||
<p>Authority administration allowed for: {% for i in session.admin_users %}{{ i }} {% endfor %}</p>
|
||||
|
||||
{% set s = authority.certificate.identity %}
|
||||
{% set s = session.certificate.identity %}
|
||||
|
||||
|
||||
<input id="search" class="icon search" type="search" placeholder="hostname, IP-address, etc"/>
|
||||
|
||||
<h1>Pending requests</h1>
|
||||
|
||||
<ul id="pending_requests">
|
||||
{% for request in authority.requests %}
|
||||
<div id="requests">
|
||||
<h1>Pending requests</h1>
|
||||
|
||||
<ul id="pending_requests">
|
||||
{% for request in session.requests %}
|
||||
{% include "request.html" %}
|
||||
{% endfor %}
|
||||
<li class="notify">
|
||||
<p>No certificate signing requests to sign! You can submit a certificate signing request by:</p>
|
||||
<pre>certidude setup client {{authority.common_name}}</pre>
|
||||
<pre>certidude setup client {{session.common_name}}</pre>
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h1>Signed certificates</h1>
|
||||
|
||||
<ul id="signed_certificates">
|
||||
{% for certificate in authority.signed | sort | reverse %}
|
||||
<div id="signed">
|
||||
<h1>Signed certificates</h1>
|
||||
<ul id="signed_certificates">
|
||||
{% for certificate in session.signed | sort | reverse %}
|
||||
{% include "signed.html" %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h1>Revoked certificates</h1>
|
||||
<div id="revoked">
|
||||
<h1>Revoked certificates</h1>
|
||||
<p>To fetch certificate revocation list:</p>
|
||||
<pre>
|
||||
curl {{window.location.href}}api/revoked/ | openssl crl -text -noout
|
||||
</pre>
|
||||
<!--
|
||||
<p>To perform online certificate status request</p>
|
||||
|
||||
<p>To fetch certificate revocation list:</p>
|
||||
<pre>
|
||||
curl {{window.location.href}}api/revoked/ | openssl crl -text -noout
|
||||
</pre>
|
||||
<!--
|
||||
<p>To perform online certificate status request</p>
|
||||
|
||||
<pre>
|
||||
curl {{request.url}}/certificate/ > authority.pem
|
||||
openssl ocsp -issuer authority.pem -CAfile authority.pem -url {{request.url}}/ocsp/ -serial 0x
|
||||
</pre>
|
||||
-->
|
||||
<ul>
|
||||
{% for j in authority.revoked %}
|
||||
<pre>
|
||||
curl {{request.url}}/certificate/ > session.pem
|
||||
openssl ocsp -issuer session.pem -CAfile session.pem -url {{request.url}}/ocsp/ -serial 0x
|
||||
</pre>
|
||||
-->
|
||||
<ul>
|
||||
{% for j in session.revoked %}
|
||||
<li id="certificate_{{ j.sha256sum }}">
|
||||
{{j.changed}}
|
||||
{{j.serial_number}} <span class="monospace">{{j.identity}}</span>
|
||||
@ -55,5 +58,5 @@ openssl ocsp -issuer authority.pem -CAfile authority.pem -url {{request.url}}/oc
|
||||
{% else %}
|
||||
<li>Great job! No certificate signing requests to sign.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -94,9 +94,7 @@ html,body {
|
||||
}
|
||||
|
||||
body {
|
||||
background: #222;
|
||||
background-image: url('../img/free_hexa_pattern_cc0_by_black_light_studio.png');
|
||||
background-position: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.comment {
|
||||
@ -142,24 +140,31 @@ pre {
|
||||
margin: 0 0;
|
||||
}
|
||||
|
||||
#container {
|
||||
max-width: 60em;
|
||||
margin: 1em auto;
|
||||
background: #fff;
|
||||
padding: 1em;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
border-color: #aaa;
|
||||
border-radius: 10px;
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
li {
|
||||
#container li {
|
||||
margin: 4px 0;
|
||||
padding: 4px 0;
|
||||
clear: both;
|
||||
border-top: 1px dashed #ccc;
|
||||
}
|
||||
|
||||
#menu {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#menu li {
|
||||
color: #fff;
|
||||
border: none;
|
||||
display: inline;
|
||||
margin: 1mm 5mm 1mm 0;
|
||||
line-height: 200%;
|
||||
}
|
||||
|
||||
.icon{
|
||||
background-size: 24px;
|
||||
padding-left: 36px;
|
||||
|
@ -11,7 +11,15 @@
|
||||
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon">
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="menu">
|
||||
<ul class="container">
|
||||
<li>Requests</li>
|
||||
<li>Signed</li>
|
||||
<li>Revoked</li>
|
||||
<li>Log</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="container" class="container">
|
||||
Loading certificate authority...
|
||||
</div>
|
||||
</body>
|
||||
|
@ -1,9 +1,8 @@
|
||||
$(document).ready(function() {
|
||||
console.info("Loading CA, to debug: curl " + window.location.href + " --negotiate -u : -H 'Accept: application/json'");
|
||||
|
||||
$.ajax({
|
||||
method: "GET",
|
||||
url: "/api/session/",
|
||||
url: "/api/",
|
||||
dataType: "json",
|
||||
error: function(response) {
|
||||
if (response.responseJSON) {
|
||||
@ -14,23 +13,11 @@ $(document).ready(function() {
|
||||
$("#container").html(nunjucks.render('error.html', { message: msg }));
|
||||
},
|
||||
success: function(session, status, xhr) {
|
||||
console.info("Loaded CA list:", session);
|
||||
console.info("Got:", session);
|
||||
|
||||
if (!session.authorities) {
|
||||
alert("No certificate authorities to manage! Have you created one yet?");
|
||||
return;
|
||||
}
|
||||
console.info("Opening EventSource from:", session.event_channel);
|
||||
|
||||
$.ajax({
|
||||
method: "GET",
|
||||
url: "/api/",
|
||||
dataType: "json",
|
||||
success: function(authority, status, xhr) {
|
||||
console.info("Got CA:", authority);
|
||||
|
||||
console.info("Opening EventSource from:", authority.event_channel);
|
||||
|
||||
var source = new EventSource(authority.event_channel);
|
||||
var source = new EventSource(session.event_channel);
|
||||
|
||||
source.onmessage = function(event) {
|
||||
console.log("Received server-sent event:", event);
|
||||
@ -71,7 +58,7 @@ $(document).ready(function() {
|
||||
console.log("Request submitted:", e.data);
|
||||
$.ajax({
|
||||
method: "GET",
|
||||
url: "/api/request/lauri-c720p/",
|
||||
url: "/api/request/" + e.data + "/",
|
||||
dataType: "json",
|
||||
success: function(request, status, xhr) {
|
||||
console.info(request);
|
||||
@ -88,7 +75,7 @@ $(document).ready(function() {
|
||||
|
||||
$.ajax({
|
||||
method: "GET",
|
||||
url: "/api/signed/lauri-c720p/",
|
||||
url: "/api/signed/" + e.data + "/",
|
||||
dataType: "json",
|
||||
success: function(certificate, status, xhr) {
|
||||
console.info(certificate);
|
||||
@ -103,7 +90,7 @@ $(document).ready(function() {
|
||||
$("#certificate_" + e.data).slideUp("normal", function() { $(this).remove(); });
|
||||
});
|
||||
|
||||
$("#container").html(nunjucks.render('authority.html', { authority: authority, session: session, window: window }));
|
||||
$("#container").html(nunjucks.render('authority.html', { session: session, window: window }));
|
||||
|
||||
$.ajax({
|
||||
method: "GET",
|
||||
@ -141,6 +128,4 @@ $(document).ready(function() {
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
20
certidude/templates/certidude.conf
Normal file
20
certidude/templates/certidude.conf
Normal file
@ -0,0 +1,20 @@
|
||||
[authorization]
|
||||
admin_users = administrator
|
||||
admin_subnets = 0.0.0.0/0
|
||||
request_subnets = 0.0.0.0/0
|
||||
autosign_subnets = 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||
|
||||
[signature]
|
||||
certificate_lifetime = 1825
|
||||
revocation_list_lifetime = 1
|
||||
|
||||
[push]
|
||||
server =
|
||||
|
||||
[authority]
|
||||
private_key_path = {{ ca_key }}
|
||||
certificate_path = {{ ca_crt }}
|
||||
requests_dir = {{ directory }}/requests/
|
||||
signed_dir = {{ directory }}/signed/
|
||||
revoked_dir = {{ directory }}/revoked/
|
||||
|
@ -1,45 +0,0 @@
|
||||
# You have to copy the settings to the system-wide
|
||||
# OpenSSL configuration (usually /etc/ssl/openssl.cnf
|
||||
|
||||
[CA_{{common_name}}]
|
||||
default_crl_days = {{revocation_list_lifetime}}
|
||||
default_days = {{certificate_lifetime}}
|
||||
dir = {{directory}}
|
||||
private_key = $dir/ca_key.pem
|
||||
certificate = $dir/ca_crt.pem
|
||||
new_certs_dir = $dir/requests/
|
||||
revoked_certs_dir = $dir/revoked/
|
||||
certs = $dir/signed/
|
||||
crl = $dir/ca_crl.pem
|
||||
serial = $dir/serial
|
||||
{% if crl_distribution_points %}
|
||||
crlDistributionPoints = {{crl_distribution_points}}
|
||||
{% endif %}
|
||||
{% if email_address %}
|
||||
emailAddress = {{email_address}}
|
||||
{% endif %}
|
||||
x509_extensions = {{common_name}}_cert
|
||||
policy = policy_{{common_name}}
|
||||
|
||||
# Certidude specific stuff, TODO: move to separate section?
|
||||
request_subnets = 10.0.0.0/8 192.168.0.0/16 172.168.0.0/16
|
||||
autosign_subnets = 127.0.0.0/8
|
||||
admin_subnets = 127.0.0.0/8
|
||||
admin_users =
|
||||
inbox = {{inbox}}
|
||||
outbox = {{outbox}}
|
||||
push_server = {{push_server}}
|
||||
|
||||
[policy_{{common_name}}]
|
||||
countryName = match
|
||||
stateOrProvinceName = match
|
||||
organizationName = match
|
||||
organizationalUnitName = optional
|
||||
commonName = supplied
|
||||
emailAddress = optional
|
||||
|
||||
[{{common_name}}_cert]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation,digitalSignature,keyEncipherment
|
||||
extendedKeyUsage = clientAuth
|
||||
|
@ -1,60 +1,14 @@
|
||||
import os
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import itertools
|
||||
import click
|
||||
import socket
|
||||
import io
|
||||
import urllib.request
|
||||
import ipaddress
|
||||
from configparser import RawConfigParser
|
||||
from certidude import push
|
||||
from Crypto.Util import asn1
|
||||
from OpenSSL import crypto
|
||||
from datetime import datetime
|
||||
from jinja2 import Environment, PackageLoader, Template
|
||||
from certidude.mailer import Mailer
|
||||
from certidude.signer import raw_sign, EXTENSION_WHITELIST
|
||||
|
||||
env = Environment(loader=PackageLoader("certidude", "email_templates"))
|
||||
|
||||
# https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/
|
||||
# https://jamielinux.com/docs/openssl-certificate-authority/
|
||||
# http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py
|
||||
|
||||
def publish_certificate(func):
|
||||
# TODO: Implement e-mail and nginx notifications using hooks
|
||||
def wrapped(instance, csr, *args, **kwargs):
|
||||
cert = func(instance, csr, *args, **kwargs)
|
||||
assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert))
|
||||
|
||||
if instance.push_server:
|
||||
url = instance.push_server + "/pub/?id=" + csr.fingerprint()
|
||||
notification = urllib.request.Request(url, cert.dump().encode("ascii"))
|
||||
notification.add_header("User-Agent", "Certidude API")
|
||||
notification.add_header("Content-Type", "application/x-x509-user-cert")
|
||||
click.echo("Publishing certificate at %s, waiting for response..." % url)
|
||||
response = urllib.request.urlopen(notification)
|
||||
response.read()
|
||||
|
||||
instance.event_publish("request_signed", csr.fingerprint())
|
||||
|
||||
return cert
|
||||
|
||||
# TODO: Implement e-mailing
|
||||
|
||||
# self.mailer.send(
|
||||
# self.certificate.email_address,
|
||||
# (self.certificate.email_address, cert.email_address),
|
||||
# "Certificate %s signed" % cert.distinguished_name,
|
||||
# "certificate-signed",
|
||||
# old_cert=old_cert,
|
||||
# cert=cert,
|
||||
# ca=self.certificate)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def subject2dn(subject):
|
||||
bits = []
|
||||
for j in "CN", "GN", "SN", "C", "S", "L", "O", "OU":
|
||||
@ -62,79 +16,6 @@ def subject2dn(subject):
|
||||
bits.append("%s=%s" % (j, getattr(subject, j)))
|
||||
return ", ".join(bits)
|
||||
|
||||
class CertificateAuthorityConfig(object):
|
||||
"""
|
||||
Certificate Authority configuration
|
||||
|
||||
:param path: Absolute path to configuration file.
|
||||
Defaults to /etc/ssl/openssl.cnf
|
||||
"""
|
||||
|
||||
def __init__(self, path='/etc/ssl/openssl.cnf', *args):
|
||||
|
||||
#: Path to file where current configuration is loaded from.
|
||||
self.path = path
|
||||
|
||||
self._config = RawConfigParser()
|
||||
self._config.readfp(itertools.chain(["[global]"], open(self.path)))
|
||||
|
||||
def get(self, section, key, default=""):
|
||||
if self._config.has_option(section, key):
|
||||
return self._config.get(section, key)
|
||||
else:
|
||||
return default
|
||||
|
||||
def instantiate_authority(self, common_name):
|
||||
section = "CA_" + common_name
|
||||
|
||||
dirs = dict([(key, self.get(section, key))
|
||||
for key in ("dir", "certificate", "crl", "certs", "new_certs_dir", "private_key", "revoked_certs_dir", "request_subnets", "autosign_subnets", "admin_subnets", "admin_users", "push_server", "database", "inbox", "outbox")])
|
||||
|
||||
# Variable expansion, eg $dir
|
||||
for key, value in dirs.items():
|
||||
if "$" in value:
|
||||
dirs[key] = re.sub(r'\$([a-z]+)', lambda m:dirs[m.groups()[0]], value)
|
||||
|
||||
dirs.pop("dir")
|
||||
dirs["email_address"] = self.get(section, "emailAddress")
|
||||
dirs["certificate_lifetime"] = int(self.get(section, "default_days", "1825"))
|
||||
dirs["revocation_list_lifetime"] = int(self.get(section, "default_crl_days", "1"))
|
||||
|
||||
extensions_section = self.get(section, "x509_extensions")
|
||||
if extensions_section:
|
||||
dirs["basic_constraints"] = self.get(extensions_section, "basicConstraints")
|
||||
dirs["key_usage"] = self.get(extensions_section, "keyUsage")
|
||||
dirs["extended_key_usage"] = self.get(extensions_section, "extendedKeyUsage")
|
||||
authority = CertificateAuthority(common_name, **dirs)
|
||||
return authority
|
||||
|
||||
|
||||
def all_authorities(self, wanted=None):
|
||||
for ca in self.ca_list:
|
||||
if wanted and ca not in wanted:
|
||||
continue
|
||||
try:
|
||||
yield self.instantiate_authority(ca)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
@property
|
||||
def ca_list(self):
|
||||
"""
|
||||
Returns sorted list of CA-s defined in the configuration file.
|
||||
"""
|
||||
return sorted([s[3:] for s in self._config if s.startswith("CA_")])
|
||||
|
||||
def pop_certificate_authority(self):
|
||||
def wrapper(func):
|
||||
def wrapped(*args, **kwargs):
|
||||
common_name = kwargs.pop("ca")
|
||||
kwargs["ca"] = self.instantiate_authority(common_name)
|
||||
return func(*args, **kwargs)
|
||||
return wrapped
|
||||
return wrapper
|
||||
|
||||
class CertificateBase:
|
||||
def __repr__(self):
|
||||
return self.buf
|
||||
@ -351,9 +232,6 @@ class Request(CertificateBase):
|
||||
def dump(self):
|
||||
return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self._obj).decode("ascii")
|
||||
|
||||
def __repr__(self):
|
||||
return "Request(%s)" % repr(self.path)
|
||||
|
||||
def create(self):
|
||||
# Generate 4096-bit RSA key
|
||||
key = crypto.PKey()
|
||||
@ -364,6 +242,7 @@ class Request(CertificateBase):
|
||||
req.set_pubkey(key)
|
||||
return Request(req)
|
||||
|
||||
|
||||
class Certificate(CertificateBase):
|
||||
def __init__(self, mixed):
|
||||
self.buf = NotImplemented
|
||||
@ -387,7 +266,7 @@ class Certificate(CertificateBase):
|
||||
if isinstance(mixed, crypto.X509):
|
||||
self._obj = mixed
|
||||
else:
|
||||
raise ValueError("Can't parse %s as X.509 certificate!" % mixed)
|
||||
raise ValueError("Can't parse %s (%s) as X.509 certificate!" % (mixed, type(mixed)))
|
||||
|
||||
assert not self.buf or self.buf == self.dump(), "%s is not %s" % (self.buf, self.dump())
|
||||
|
||||
@ -435,275 +314,3 @@ class Certificate(CertificateBase):
|
||||
def __lte__(self, other):
|
||||
return self.signed <= other.signed
|
||||
|
||||
class CertificateAuthority(object):
|
||||
def __init__(self, common_name, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, private_key=None, autosign_subnets=None, request_subnets=None, admin_subnets=None, admin_users=None, email_address=None, inbox=None, outbox=None, basic_constraints="CA:FALSE", key_usage="digitalSignature,keyEncipherment", extended_key_usage="clientAuth", certificate_lifetime=5*365, revocation_list_lifetime=1, push_server=None, database=None):
|
||||
|
||||
import hashlib
|
||||
m = hashlib.sha512()
|
||||
m.update(common_name.encode("ascii"))
|
||||
m.update(b"TODO:server-secret-goes-here")
|
||||
self.uuid = m.hexdigest()
|
||||
|
||||
self.revocation_list = crl
|
||||
self.signed_dir = certs
|
||||
self.request_dir = new_certs_dir
|
||||
self.revoked_dir = revoked_certs_dir
|
||||
self.private_key = private_key
|
||||
|
||||
self.admin_subnets = set([ipaddress.ip_network(j) for j in admin_subnets.split(" ") if j])
|
||||
self.autosign_subnets = set([ipaddress.ip_network(j) for j in autosign_subnets.split(" ") if j])
|
||||
self.request_subnets = set([ipaddress.ip_network(j) for j in request_subnets.split(" ") if j]).union(self.autosign_subnets)
|
||||
|
||||
self.certificate = Certificate(open(certificate))
|
||||
self.mailer = Mailer(outbox) if outbox else None
|
||||
self.push_server = push_server
|
||||
self.database_url = database
|
||||
self._database_pool = None
|
||||
|
||||
self.certificate_lifetime = certificate_lifetime
|
||||
self.revocation_list_lifetime = revocation_list_lifetime
|
||||
self.basic_constraints = basic_constraints
|
||||
self.key_usage = key_usage
|
||||
self.extended_key_usage = extended_key_usage
|
||||
|
||||
self.admin_emails = dict()
|
||||
self.admin_users = set()
|
||||
if admin_users:
|
||||
if admin_users.startswith("/"):
|
||||
for user in open(admin_users):
|
||||
if ":" in user:
|
||||
user, email, first_name, last_name = user.split(":")
|
||||
self.admin_emails[user] = email
|
||||
self.admin_users.add(user)
|
||||
else:
|
||||
self.admin_users = set([j for j in admin_users.split(" ") if j])
|
||||
|
||||
@property
|
||||
def common_name(self):
|
||||
return self.certificate.common_name
|
||||
|
||||
@property
|
||||
def database(self):
|
||||
from urllib.parse import urlparse
|
||||
if not self._database_pool:
|
||||
o = urlparse(self.database_url)
|
||||
if o.scheme == "mysql":
|
||||
import mysql.connector
|
||||
self._database_pool = mysql.connector.pooling.MySQLConnectionPool(
|
||||
pool_size = 3,
|
||||
user=o.username,
|
||||
password=o.password,
|
||||
host=o.hostname,
|
||||
database=o.path[1:])
|
||||
else:
|
||||
raise NotImplementedError("Unsupported database scheme %s, currently only mysql://user:pass@host/database is supported" % o.scheme)
|
||||
|
||||
return self._database_pool
|
||||
|
||||
def event_publish(self, event_type, event_data):
|
||||
"""
|
||||
Publish event on push server
|
||||
"""
|
||||
url = self.push_server + "/pub?id=" + self.uuid # Derive CA's push channel URL
|
||||
|
||||
notification = urllib.request.Request(
|
||||
url,
|
||||
event_data.encode("utf-8"),
|
||||
{"Event-ID": b"TODO", "Event-Type":event_type.encode("ascii")})
|
||||
notification.add_header("User-Agent", "Certidude API")
|
||||
click.echo("Posting event %s %s at %s, waiting for response..." % (repr(event_type), repr(event_data), repr(url)))
|
||||
try:
|
||||
response = urllib.request.urlopen(notification)
|
||||
body = response.read()
|
||||
except urllib.error.HTTPError as err:
|
||||
if err.code == 404:
|
||||
print("No subscribers on the channel")
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
print("Push server returned:", response.code, body)
|
||||
|
||||
def _signer_exec(self, cmd, *bits):
|
||||
sock = self.connect_signer()
|
||||
sock.send(cmd.encode("ascii"))
|
||||
sock.send(b"\n")
|
||||
for bit in bits:
|
||||
sock.send(bit.encode("ascii"))
|
||||
sock.sendall(b"\n\n")
|
||||
buf = sock.recv(8192)
|
||||
if not buf:
|
||||
raise
|
||||
return buf
|
||||
|
||||
def __repr__(self):
|
||||
return "CertificateAuthority(common_name=%s)" % repr(self.common_name)
|
||||
|
||||
def get_certificate(self, cn):
|
||||
return open(os.path.join(self.signed_dir, cn + ".pem")).read()
|
||||
|
||||
def connect_signer(self):
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect("/run/certidude/signer/%s.sock" % self.common_name)
|
||||
return sock
|
||||
|
||||
def revoke(self, cn):
|
||||
cert = Certificate(open(os.path.join(self.signed_dir, cn + ".pem")))
|
||||
revoked_filename = os.path.join(self.revoked_dir, "%s.pem" % cert.serial_number)
|
||||
os.rename(cert.path, revoked_filename)
|
||||
self.event_publish("certificate_revoked", cert.fingerprint())
|
||||
|
||||
def get_revoked(self):
|
||||
for root, dirs, files in os.walk(self.revoked_dir):
|
||||
for filename in files:
|
||||
if filename.endswith(".pem"):
|
||||
yield Certificate(open(os.path.join(root, filename)))
|
||||
break
|
||||
|
||||
def get_signed(self):
|
||||
for root, dirs, files in os.walk(self.signed_dir):
|
||||
for filename in files:
|
||||
if filename.endswith(".pem"):
|
||||
yield Certificate(open(os.path.join(root, filename)))
|
||||
break
|
||||
|
||||
def get_requests(self):
|
||||
for root, dirs, files in os.walk(self.request_dir):
|
||||
for filename in files:
|
||||
if filename.endswith(".pem"):
|
||||
yield Request(open(os.path.join(root, filename)))
|
||||
break
|
||||
|
||||
def get_request(self, cn):
|
||||
return Request(open(os.path.join(self.request_dir, cn + ".pem")))
|
||||
|
||||
def store_request(self, buf, overwrite=False):
|
||||
request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf)
|
||||
common_name = request.get_subject().CN
|
||||
request_path = os.path.join(self.request_dir, common_name + ".pem")
|
||||
|
||||
# If there is cert, check if it's the same
|
||||
if os.path.exists(request_path):
|
||||
if open(request_path, "rb").read() != buf:
|
||||
print("Request already exists, not creating new request")
|
||||
raise FileExistsError("Request already exists")
|
||||
else:
|
||||
with open(request_path + ".part", "wb") as fh:
|
||||
fh.write(buf)
|
||||
os.rename(request_path + ".part", request_path)
|
||||
|
||||
return Request(open(request_path))
|
||||
|
||||
def request_exists(self, cn):
|
||||
return os.path.exists(os.path.join(self.request_dir, cn + ".pem"))
|
||||
|
||||
def delete_request(self, cn):
|
||||
path = os.path.join(self.request_dir, cn + ".pem")
|
||||
request_sha1sum = Request(open(path)).fingerprint()
|
||||
os.unlink(path)
|
||||
|
||||
# Publish event at CA channel
|
||||
self.event_publish("request_deleted", request_sha1sum)
|
||||
|
||||
# Write empty certificate to long-polling URL
|
||||
url = self.push_server + "/pub/?id=" + request_sha1sum
|
||||
publisher = urllib.request.Request(url, b"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n")
|
||||
publisher.add_header("User-Agent", "Certidude API")
|
||||
click.echo("POST-ing empty certificate at %s, waiting for response..." % url)
|
||||
try:
|
||||
response = urllib.request.urlopen(publisher)
|
||||
body = response.read()
|
||||
except urllib.error.HTTPError as err:
|
||||
if err.code == 404:
|
||||
print("No subscribers on the channel")
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
print("Push server returned:", response.code, body)
|
||||
|
||||
def create_bundle(self, common_name, organizational_unit=None, email_address=None, overwrite=True):
|
||||
req = Request.create()
|
||||
req.country = self.certificate.country
|
||||
req.state_or_county = self.certificate.state_or_county
|
||||
req.city = self.certificate.city
|
||||
req.organization = self.certificate.organization
|
||||
req.organizational_unit = organizational_unit or self.certificate.organizational_unit
|
||||
req.common_name = common_name
|
||||
req.email_address = email_address
|
||||
cert_buf = self.sign(req, overwrite)
|
||||
return crypto.dump_privatekey(crypto.FILETYPE_PEM, key).decode("ascii"), \
|
||||
req_buf, cert_buf
|
||||
|
||||
@publish_certificate
|
||||
def sign(self, req, overwrite=False, delete=True):
|
||||
"""
|
||||
Sign certificate signing request via signer process
|
||||
"""
|
||||
|
||||
cert_path = os.path.join(self.signed_dir, req.common_name + ".pem")
|
||||
|
||||
# Move existing certificate if necessary
|
||||
if os.path.exists(cert_path):
|
||||
old_cert = Certificate(open(cert_path))
|
||||
if overwrite:
|
||||
self.revoke(req.common_name)
|
||||
elif req.pubkey == old_cert.pubkey:
|
||||
return old_cert
|
||||
else:
|
||||
raise FileExistsError("Will not overwrite existing certificate")
|
||||
|
||||
# Sign via signer process
|
||||
cert_buf = self._signer_exec("sign-request", req.dump())
|
||||
with open(cert_path + ".part", "wb") as fh:
|
||||
fh.write(cert_buf)
|
||||
os.rename(cert_path + ".part", cert_path)
|
||||
|
||||
return Certificate(open(cert_path))
|
||||
|
||||
@publish_certificate
|
||||
def sign2(self, request, overwrite=False, delete=True, lifetime=None):
|
||||
"""
|
||||
Sign directly using private key, this is usually done by root.
|
||||
Basic constraints and certificate lifetime are copied from openssl.cnf,
|
||||
lifetime may be overridden on the command line,
|
||||
other extensions are copied as is.
|
||||
"""
|
||||
cert = raw_sign(
|
||||
crypto.load_privatekey(crypto.FILETYPE_PEM, open(self.private_key).read()),
|
||||
self.certificate._obj,
|
||||
request._obj,
|
||||
self.basic_constraints,
|
||||
lifetime=lifetime or self.certificate_lifetime)
|
||||
|
||||
path = os.path.join(self.signed_dir, request.common_name + ".pem")
|
||||
if os.path.exists(path):
|
||||
if overwrite:
|
||||
self.revoke(request.common_name)
|
||||
else:
|
||||
raise FileExistsError("File %s already exists!" % path)
|
||||
|
||||
buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
|
||||
with open(path + ".part", "wb") as fh:
|
||||
fh.write(buf)
|
||||
os.rename(path + ".part", path)
|
||||
click.echo("Wrote certificate to: %s" % path)
|
||||
if delete:
|
||||
os.unlink(request.path)
|
||||
click.echo("Deleted request: %s" % request.path)
|
||||
|
||||
return Certificate(open(path))
|
||||
|
||||
def export_crl(self):
|
||||
sock = self.connect_signer()
|
||||
sock.send(b"export-crl\n")
|
||||
for filename in os.listdir(self.revoked_dir):
|
||||
if not filename.endswith(".pem"):
|
||||
continue
|
||||
serial_number = filename[:-4]
|
||||
# TODO: Assert serial against regex
|
||||
revoked_path = os.path.join(self.revoked_dir, filename)
|
||||
# TODO: Skip expired certificates
|
||||
s = os.stat(revoked_path)
|
||||
sock.send(("%s:%d\n" % (serial_number, s.st_ctime)).encode("ascii"))
|
||||
sock.sendall(b"\n")
|
||||
return sock.recv(32*1024*1024)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user