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
|
.. 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
|
pip3 install certidude
|
||||||
|
|
||||||
Make sure you're running PyOpenSSL 0.15+ and netifaces 0.10.4+ from PyPI,
|
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.
|
domain name set up properly.
|
||||||
You can check it with:
|
You can check it with:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
hostname -f
|
hostname -f
|
||||||
|
|
||||||
The command should return ca.example.co
|
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 click
|
||||||
import falcon
|
import falcon
|
||||||
|
import ipaddress
|
||||||
import kerberos
|
import kerberos
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -70,3 +71,28 @@ def login_required(func):
|
|||||||
raise falcon.HTTPForbidden("Forbidden", "Tried GSSAPI")
|
raise falcon.HTTPForbidden("Forbidden", "Tried GSSAPI")
|
||||||
|
|
||||||
return wrapped
|
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 socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from certidude.helpers import expand_paths, \
|
from certidude import authority
|
||||||
certidude_request_certificate
|
|
||||||
from certidude.signer import SignServer
|
from certidude.signer import SignServer
|
||||||
from certidude.wrappers import CertificateAuthorityConfig, subject2dn
|
from certidude.common import expand_paths
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from humanize import naturaltime
|
from humanize import naturaltime
|
||||||
from ipaddress import ip_network, ip_address
|
from ipaddress import ip_network, ip_address
|
||||||
@ -62,20 +61,15 @@ if os.getuid() >= 1000:
|
|||||||
FIRST_NAME = gecos
|
FIRST_NAME = gecos
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
@click.command("spawn", help="Run privilege isolated signer process")
|
||||||
path = os.getenv('CERTIDUDE_CONF')
|
@click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instance")
|
||||||
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.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys")
|
@click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys")
|
||||||
def certidude_spawn(kill, no_interaction):
|
def certidude_spawn(kill, no_interaction):
|
||||||
"""
|
"""
|
||||||
Spawn processes for signers
|
Spawn processes for signers
|
||||||
"""
|
"""
|
||||||
|
from certidude import config
|
||||||
|
|
||||||
# Check whether we have privileges
|
# Check whether we have privileges
|
||||||
os.umask(0o027)
|
os.umask(0o027)
|
||||||
uid = os.getuid()
|
uid = os.getuid()
|
||||||
@ -84,13 +78,12 @@ def certidude_spawn(kill, no_interaction):
|
|||||||
|
|
||||||
# Process directories
|
# Process directories
|
||||||
run_dir = "/run/certidude"
|
run_dir = "/run/certidude"
|
||||||
signer_dir = os.path.join(run_dir, "signer")
|
chroot_dir = os.path.join(run_dir, "jail")
|
||||||
chroot_dir = os.path.join(signer_dir, "jail")
|
|
||||||
|
|
||||||
# Prepare signer PID-s directory
|
# Prepare signer PID-s directory
|
||||||
if not os.path.exists(signer_dir):
|
if not os.path.exists(run_dir):
|
||||||
click.echo("Creating: %s" % signer_dir)
|
click.echo("Creating: %s" % run_dir)
|
||||||
os.makedirs(signer_dir)
|
os.makedirs(run_dir)
|
||||||
|
|
||||||
# Preload charmap encoding for byte_string() function of pyOpenSSL
|
# Preload charmap encoding for byte_string() function of pyOpenSSL
|
||||||
# in order to enable chrooting
|
# 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"))
|
os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom"))
|
||||||
|
|
||||||
ca_loaded = False
|
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:
|
try:
|
||||||
with open(pidfile_path) as fh:
|
with open(config.SIGNER_PID_PATH) as fh:
|
||||||
pid = int(fh.readline())
|
pid = int(fh.readline())
|
||||||
os.kill(pid, 0)
|
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):
|
except (ValueError, ProcessLookupError, FileNotFoundError):
|
||||||
pid = 0
|
pid = 0
|
||||||
|
|
||||||
@ -127,31 +117,29 @@ def certidude_spawn(kill, no_interaction):
|
|||||||
sleep(1)
|
sleep(1)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
pass
|
||||||
ca_loaded = True
|
|
||||||
else:
|
|
||||||
ca_loaded = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
child_pid = os.fork()
|
child_pid = os.fork()
|
||||||
|
|
||||||
if child_pid == 0:
|
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())
|
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(
|
logging.basicConfig(
|
||||||
filename="/var/log/certidude-%s.log" % ca.common_name,
|
filename="/var/log/signer.log",
|
||||||
level=logging.INFO)
|
level=logging.INFO)
|
||||||
server = SignServer(socket_path, ca.private_key, ca.certificate.path,
|
server = SignServer(
|
||||||
ca.certificate_lifetime, ca.basic_constraints, ca.key_usage,
|
config.SIGNER_SOCKET_PATH,
|
||||||
ca.extended_key_usage, ca.revocation_list_lifetime)
|
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()
|
asyncore.loop()
|
||||||
else:
|
else:
|
||||||
click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, socket_path))
|
click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH))
|
||||||
ca_loaded = True
|
|
||||||
|
|
||||||
if not ca_loaded:
|
|
||||||
raise click.ClickException("No CA sections defined in configuration: {}".format(config.path))
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("client", help="Setup X.509 certificates for application")
|
@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("--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")
|
@click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt by default")
|
||||||
def certidude_setup_client(quiet, **kwargs):
|
def certidude_setup_client(quiet, **kwargs):
|
||||||
|
from certidude.helpers import certidude_request_certificate
|
||||||
return certidude_request_certificate(**kwargs)
|
return certidude_request_certificate(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
@ -197,6 +186,7 @@ def certidude_setup_client(quiet, **kwargs):
|
|||||||
@expand_paths()
|
@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):
|
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
|
# TODO: Intelligent way of getting last IP address in the subnet
|
||||||
|
from certidude.helpers import certidude_request_certificate
|
||||||
subnet_first = None
|
subnet_first = None
|
||||||
subnet_last = None
|
subnet_last = None
|
||||||
subnet_second = 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("use following command to sign on Certidude server instead of web interface:")
|
||||||
click.echo()
|
click.echo()
|
||||||
click.echo(" certidude sign %s" % common_name)
|
click.echo(" certidude sign %s" % common_name)
|
||||||
|
|
||||||
retval = certidude_request_certificate(
|
retval = certidude_request_certificate(
|
||||||
url,
|
url,
|
||||||
key_path,
|
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("--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("--crl-distribution-url", default=None, help="CRL distribution URL")
|
||||||
@click.option("--ocsp-responder-url", default=None, help="OCSP responder 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("--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")
|
@click.option("--email-address", default="certidude@" + FQDN, help="E-mail address of the CA")
|
||||||
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):
|
@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):
|
||||||
if not directory:
|
|
||||||
directory = os.path.join("/var/lib/certidude", common_name)
|
|
||||||
|
|
||||||
# Make sure common_name is valid
|
# Make sure common_name is valid
|
||||||
if not re.match(r"^[\._a-zA-Z0-9]+$", common_name):
|
if not re.match(r"^[\.\-_a-zA-Z0-9]+$", common_name):
|
||||||
raise click.ClickException("CA name can contain only alphanumeric and '_' characters")
|
raise click.ClickException("CA name can contain only alphanumeric, '_' and '-' characters")
|
||||||
|
|
||||||
if os.path.lexists(directory):
|
if os.path.lexists(directory):
|
||||||
raise click.ClickException("Output directory {} already exists.".format(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"))
|
crl_distribution_points.encode("ascii"))
|
||||||
])
|
])
|
||||||
|
|
||||||
if email_address:
|
|
||||||
subject_alt_name = "email:%s" % email_address
|
subject_alt_name = "email:%s" % email_address
|
||||||
ca.add_extensions([
|
ca.add_extensions([
|
||||||
crypto.X509Extension(
|
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 -in ca_crt.pem -outform DER | sha256sum
|
||||||
# openssl x509 -fingerprint -in ca_crt.pem
|
# 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:
|
with open(ca_key, "wb") as fh:
|
||||||
fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key))
|
fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key))
|
||||||
|
|
||||||
ssl_cnf_example = os.path.join(directory, "openssl.cnf.example")
|
certidude_conf = os.path.join("/etc/certidude.conf")
|
||||||
with open(ssl_cnf_example, "w") as fh:
|
with open(certidude_conf, "w") as fh:
|
||||||
fh.write(env.get_template("openssl.cnf").render(locals()))
|
fh.write(env.get_template("certidude.conf").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")
|
|
||||||
|
|
||||||
click.echo()
|
click.echo()
|
||||||
click.echo("Use following commands to inspect the newly created files:")
|
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.command("list", help="List certificates")
|
||||||
@click.argument("ca", nargs=-1)
|
|
||||||
@click.option("--verbose", "-v", default=False, is_flag=True, help="Verbose output")
|
@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-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")
|
@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("--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-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")
|
@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:
|
# Statuses:
|
||||||
# s - submitted
|
# s - submitted
|
||||||
# v - valid
|
# v - valid
|
||||||
@ -738,18 +716,10 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_
|
|||||||
if j.fqdn:
|
if j.fqdn:
|
||||||
click.echo("Associated hostname: " + 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:
|
if not hide_requests:
|
||||||
for j in ca.get_requests():
|
for j in authority.list_requests():
|
||||||
|
|
||||||
if not verbose:
|
if not verbose:
|
||||||
click.echo("s " + j.path + " " + j.identity)
|
click.echo("s " + j.path + " " + j.identity)
|
||||||
continue
|
continue
|
||||||
@ -779,7 +749,7 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_
|
|||||||
click.echo()
|
click.echo()
|
||||||
|
|
||||||
if show_signed:
|
if show_signed:
|
||||||
for j in ca.get_signed():
|
for j in authority.list_signed():
|
||||||
if not verbose:
|
if not verbose:
|
||||||
if j.signed < NOW and j.expires > NOW:
|
if j.signed < NOW and j.expires > NOW:
|
||||||
click.echo("v " + j.path + " " + j.identity)
|
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()
|
click.echo()
|
||||||
|
|
||||||
if show_revoked:
|
if show_revoked:
|
||||||
for j in ca.get_revoked():
|
for j in authority.list_revoked():
|
||||||
if not verbose:
|
if not verbose:
|
||||||
click.echo("r " + j.path + " " + j.identity)
|
click.echo("r " + j.path + " " + j.identity)
|
||||||
continue
|
continue
|
||||||
@ -820,59 +790,19 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_
|
|||||||
|
|
||||||
click.echo()
|
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.command("sign", help="Sign certificates")
|
||||||
@click.argument("common_name")
|
@click.argument("common_name")
|
||||||
@click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN")
|
@click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN")
|
||||||
@click.option("--lifetime", "-l", help="Lifetime")
|
@click.option("--lifetime", "-l", help="Lifetime")
|
||||||
def certidude_sign(common_name, overwrite, lifetime):
|
def certidude_sign(common_name, overwrite, lifetime):
|
||||||
config = load_config()
|
request = authority.get_request(common_name)
|
||||||
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:
|
|
||||||
if request.signable:
|
if request.signable:
|
||||||
# Sign via signer process
|
# Sign via signer process
|
||||||
cert = ca.sign(request)
|
cert = authority.sign(request)
|
||||||
else:
|
else:
|
||||||
# Sign directly using private key
|
# 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)
|
click.echo("Signed %s" % cert.identity)
|
||||||
for key, value, data in cert.extensions:
|
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 click
|
||||||
import os
|
import os
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from certidude.wrappers import Certificate, Request
|
from certidude import config
|
||||||
from OpenSSL import crypto
|
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):
|
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>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>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 authority.autosign_subnets %}{% for i in authority.autosign_subnets %}{{ i }} {% endfor %}{% else %}nowhere{% 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 authority.admin_subnets %}{% for i in authority.admin_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}
|
<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 authority.admin_users %}{{ i }} {% endfor %}</p>
|
<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">
|
<div id="requests">
|
||||||
{% for request in authority.requests %}
|
<h1>Pending requests</h1>
|
||||||
|
|
||||||
|
<ul id="pending_requests">
|
||||||
|
{% for request in session.requests %}
|
||||||
{% include "request.html" %}
|
{% include "request.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<li class="notify">
|
<li class="notify">
|
||||||
<p>No certificate signing requests to sign! You can submit a certificate signing request by:</p>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h1>Signed certificates</h1>
|
|
||||||
|
|
||||||
<ul id="signed_certificates">
|
<div id="signed">
|
||||||
{% for certificate in authority.signed | sort | reverse %}
|
<h1>Signed certificates</h1>
|
||||||
|
<ul id="signed_certificates">
|
||||||
|
{% for certificate in session.signed | sort | reverse %}
|
||||||
{% include "signed.html" %}
|
{% include "signed.html" %}
|
||||||
{% endfor %}
|
{% 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>
|
||||||
<pre>
|
curl {{request.url}}/certificate/ > session.pem
|
||||||
curl {{window.location.href}}api/revoked/ | openssl crl -text -noout
|
openssl ocsp -issuer session.pem -CAfile session.pem -url {{request.url}}/ocsp/ -serial 0x
|
||||||
</pre>
|
</pre>
|
||||||
<!--
|
-->
|
||||||
<p>To perform online certificate status request</p>
|
<ul>
|
||||||
|
{% for j in session.revoked %}
|
||||||
<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 %}
|
|
||||||
<li id="certificate_{{ j.sha256sum }}">
|
<li id="certificate_{{ j.sha256sum }}">
|
||||||
{{j.changed}}
|
{{j.changed}}
|
||||||
{{j.serial_number}} <span class="monospace">{{j.identity}}</span>
|
{{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 %}
|
{% else %}
|
||||||
<li>Great job! No certificate signing requests to sign.</li>
|
<li>Great job! No certificate signing requests to sign.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
@ -94,9 +94,7 @@ html,body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #222;
|
background: #fff;
|
||||||
background-image: url('../img/free_hexa_pattern_cc0_by_black_light_studio.png');
|
|
||||||
background-position: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment {
|
.comment {
|
||||||
@ -142,24 +140,31 @@ pre {
|
|||||||
margin: 0 0;
|
margin: 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container {
|
|
||||||
max-width: 60em;
|
.container {
|
||||||
margin: 1em auto;
|
max-width: 960px;
|
||||||
background: #fff;
|
margin: 0 auto;
|
||||||
padding: 1em;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 2px;
|
|
||||||
border-color: #aaa;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
#container li {
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
clear: both;
|
clear: both;
|
||||||
border-top: 1px dashed #ccc;
|
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{
|
.icon{
|
||||||
background-size: 24px;
|
background-size: 24px;
|
||||||
padding-left: 36px;
|
padding-left: 36px;
|
||||||
|
@ -11,7 +11,15 @@
|
|||||||
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon">
|
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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...
|
Loading certificate authority...
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
console.info("Loading CA, to debug: curl " + window.location.href + " --negotiate -u : -H 'Accept: application/json'");
|
console.info("Loading CA, to debug: curl " + window.location.href + " --negotiate -u : -H 'Accept: application/json'");
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/api/session/",
|
url: "/api/",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
error: function(response) {
|
error: function(response) {
|
||||||
if (response.responseJSON) {
|
if (response.responseJSON) {
|
||||||
@ -14,23 +13,11 @@ $(document).ready(function() {
|
|||||||
$("#container").html(nunjucks.render('error.html', { message: msg }));
|
$("#container").html(nunjucks.render('error.html', { message: msg }));
|
||||||
},
|
},
|
||||||
success: function(session, status, xhr) {
|
success: function(session, status, xhr) {
|
||||||
console.info("Loaded CA list:", session);
|
console.info("Got:", session);
|
||||||
|
|
||||||
if (!session.authorities) {
|
console.info("Opening EventSource from:", session.event_channel);
|
||||||
alert("No certificate authorities to manage! Have you created one yet?");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.ajax({
|
var source = new EventSource(session.event_channel);
|
||||||
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);
|
|
||||||
|
|
||||||
source.onmessage = function(event) {
|
source.onmessage = function(event) {
|
||||||
console.log("Received server-sent event:", event);
|
console.log("Received server-sent event:", event);
|
||||||
@ -71,7 +58,7 @@ $(document).ready(function() {
|
|||||||
console.log("Request submitted:", e.data);
|
console.log("Request submitted:", e.data);
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/api/request/lauri-c720p/",
|
url: "/api/request/" + e.data + "/",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
success: function(request, status, xhr) {
|
success: function(request, status, xhr) {
|
||||||
console.info(request);
|
console.info(request);
|
||||||
@ -88,7 +75,7 @@ $(document).ready(function() {
|
|||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/api/signed/lauri-c720p/",
|
url: "/api/signed/" + e.data + "/",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
success: function(certificate, status, xhr) {
|
success: function(certificate, status, xhr) {
|
||||||
console.info(certificate);
|
console.info(certificate);
|
||||||
@ -103,7 +90,7 @@ $(document).ready(function() {
|
|||||||
$("#certificate_" + e.data).slideUp("normal", function() { $(this).remove(); });
|
$("#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({
|
$.ajax({
|
||||||
method: "GET",
|
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 os
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
import itertools
|
|
||||||
import click
|
import click
|
||||||
import socket
|
|
||||||
import io
|
import io
|
||||||
import urllib.request
|
from certidude import push
|
||||||
import ipaddress
|
|
||||||
from configparser import RawConfigParser
|
|
||||||
from Crypto.Util import asn1
|
from Crypto.Util import asn1
|
||||||
from OpenSSL import crypto
|
from OpenSSL import crypto
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from jinja2 import Environment, PackageLoader, Template
|
|
||||||
from certidude.mailer import Mailer
|
|
||||||
from certidude.signer import raw_sign, EXTENSION_WHITELIST
|
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):
|
def subject2dn(subject):
|
||||||
bits = []
|
bits = []
|
||||||
for j in "CN", "GN", "SN", "C", "S", "L", "O", "OU":
|
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)))
|
bits.append("%s=%s" % (j, getattr(subject, j)))
|
||||||
return ", ".join(bits)
|
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:
|
class CertificateBase:
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.buf
|
return self.buf
|
||||||
@ -351,9 +232,6 @@ class Request(CertificateBase):
|
|||||||
def dump(self):
|
def dump(self):
|
||||||
return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self._obj).decode("ascii")
|
return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self._obj).decode("ascii")
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "Request(%s)" % repr(self.path)
|
|
||||||
|
|
||||||
def create(self):
|
def create(self):
|
||||||
# Generate 4096-bit RSA key
|
# Generate 4096-bit RSA key
|
||||||
key = crypto.PKey()
|
key = crypto.PKey()
|
||||||
@ -364,6 +242,7 @@ class Request(CertificateBase):
|
|||||||
req.set_pubkey(key)
|
req.set_pubkey(key)
|
||||||
return Request(req)
|
return Request(req)
|
||||||
|
|
||||||
|
|
||||||
class Certificate(CertificateBase):
|
class Certificate(CertificateBase):
|
||||||
def __init__(self, mixed):
|
def __init__(self, mixed):
|
||||||
self.buf = NotImplemented
|
self.buf = NotImplemented
|
||||||
@ -387,7 +266,7 @@ class Certificate(CertificateBase):
|
|||||||
if isinstance(mixed, crypto.X509):
|
if isinstance(mixed, crypto.X509):
|
||||||
self._obj = mixed
|
self._obj = mixed
|
||||||
else:
|
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())
|
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):
|
def __lte__(self, other):
|
||||||
return self.signed <= other.signed
|
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