Refactor wrappers

Completely remove wrapper class for CA,
use certidude.authority module instead.
This commit is contained in:
Lauri Võsandi 2015-12-12 22:34:08 +00:00
parent 5876f61e15
commit b788d701eb
23 changed files with 1165 additions and 1439 deletions

View File

@ -67,7 +67,7 @@ To install Certidude:
.. code:: bash
apt-get install -y python3 python3-pip python3-dev cython3 build-essential libffi-dev libssl-dev libkrb5-dev
apt-get install -y python3 python3-pip python3-dev python3-mysql.connector cython3 build-essential libffi-dev libssl-dev libkrb5-dev
pip3 install certidude
Make sure you're running PyOpenSSL 0.15+ and netifaces 0.10.4+ from PyPI,
@ -87,6 +87,8 @@ First make sure the machine used for CA has fully qualified
domain name set up properly.
You can check it with:
.. code:: bash
hostname -f
The command should return ca.example.co

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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"]

View File

@ -1,6 +1,7 @@
import click
import falcon
import ipaddress
import kerberos
import os
import re
@ -70,3 +71,28 @@ def login_required(func):
raise falcon.HTTPForbidden("Forbidden", "Tried GSSAPI")
return wrapped
def authorize_admin(func):
def wrapped(self, req, resp, *args, **kwargs):
from certidude import config
# Parse remote IPv4/IPv6 address
remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"])
# Check for administration subnet whitelist
print("Comparing:", config.ADMIN_SUBNETS, "To:", remote_addr)
for subnet in config.ADMIN_SUBNETS:
if subnet.overlaps(remote_addr):
break
else:
raise falcon.HTTPForbidden("Forbidden", "Remote address %s not whitelisted" % remote_addr)
# Check for username whitelist
kerberos_username, kerberos_realm = req.context.get("user")
if kerberos_username not in config.ADMIN_USERS:
raise falcon.HTTPForbidden("Forbidden", "User %s not whitelisted" % kerberos_username)
# Retain username, TODO: Better abstraction with username, e-mail, sn, gn?
return func(self, req, resp, *args, **kwargs)
return wrapped

223
certidude/authority.py Normal file
View 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))

View File

@ -13,10 +13,9 @@ import signal
import socket
import subprocess
import sys
from certidude.helpers import expand_paths, \
certidude_request_certificate
from certidude import authority
from certidude.signer import SignServer
from certidude.wrappers import CertificateAuthorityConfig, subject2dn
from certidude.common import expand_paths
from datetime import datetime
from humanize import naturaltime
from ipaddress import ip_network, ip_address
@ -62,20 +61,15 @@ if os.getuid() >= 1000:
FIRST_NAME = gecos
def load_config():
path = os.getenv('CERTIDUDE_CONF')
if path and os.path.isfile(path):
return CertificateAuthorityConfig(path)
return CertificateAuthorityConfig()
@click.command("spawn", help="Run privilege isolated signer processes")
@click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instances")
@click.command("spawn", help="Run privilege isolated signer process")
@click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instance")
@click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys")
def certidude_spawn(kill, no_interaction):
"""
Spawn processes for signers
"""
from certidude import config
# Check whether we have privileges
os.umask(0o027)
uid = os.getuid()
@ -84,13 +78,12 @@ def certidude_spawn(kill, no_interaction):
# Process directories
run_dir = "/run/certidude"
signer_dir = os.path.join(run_dir, "signer")
chroot_dir = os.path.join(signer_dir, "jail")
chroot_dir = os.path.join(run_dir, "jail")
# Prepare signer PID-s directory
if not os.path.exists(signer_dir):
click.echo("Creating: %s" % signer_dir)
os.makedirs(signer_dir)
if not os.path.exists(run_dir):
click.echo("Creating: %s" % run_dir)
os.makedirs(run_dir)
# Preload charmap encoding for byte_string() function of pyOpenSSL
# in order to enable chrooting
@ -104,54 +97,49 @@ def certidude_spawn(kill, no_interaction):
os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom"))
ca_loaded = False
config = load_config()
for ca in config.all_authorities():
socket_path = os.path.join(signer_dir, ca.common_name + ".sock")
pidfile_path = os.path.join(signer_dir, ca.common_name + ".pid")
try:
with open(pidfile_path) as fh:
pid = int(fh.readline())
os.kill(pid, 0)
click.echo("Found process with PID %d for %s" % (pid, ca.common_name))
except (ValueError, ProcessLookupError, FileNotFoundError):
pid = 0
if pid > 0:
if kill:
try:
click.echo("Killing %d" % pid)
os.kill(pid, signal.SIGTERM)
sleep(1)
os.kill(pid, signal.SIGKILL)
sleep(1)
except ProcessLookupError:
pass
ca_loaded = True
else:
ca_loaded = True
continue
try:
with open(config.SIGNER_PID_PATH) as fh:
pid = int(fh.readline())
os.kill(pid, 0)
click.echo("Found process with PID %d" % pid)
except (ValueError, ProcessLookupError, FileNotFoundError):
pid = 0
child_pid = os.fork()
if pid > 0:
if kill:
try:
click.echo("Killing %d" % pid)
os.kill(pid, signal.SIGTERM)
sleep(1)
os.kill(pid, signal.SIGKILL)
sleep(1)
except ProcessLookupError:
pass
if child_pid == 0:
with open(pidfile_path, "w") as fh:
fh.write("%d\n" % os.getpid())
child_pid = os.fork()
setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name))
logging.basicConfig(
filename="/var/log/certidude-%s.log" % ca.common_name,
level=logging.INFO)
server = SignServer(socket_path, ca.private_key, ca.certificate.path,
ca.certificate_lifetime, ca.basic_constraints, ca.key_usage,
ca.extended_key_usage, ca.revocation_list_lifetime)
asyncore.loop()
else:
click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, socket_path))
ca_loaded = True
if child_pid == 0:
with open(config.SIGNER_PID_PATH, "w") as fh:
fh.write("%d\n" % os.getpid())
if not ca_loaded:
raise click.ClickException("No CA sections defined in configuration: {}".format(config.path))
# setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name))
logging.basicConfig(
filename="/var/log/signer.log",
level=logging.INFO)
server = SignServer(
config.SIGNER_SOCKET_PATH,
config.AUTHORITY_PRIVATE_KEY_PATH,
config.AUTHORITY_CERTIFICATE_PATH,
config.CERTIFICATE_LIFETIME,
config.CERTIFICATE_BASIC_CONSTRAINTS,
config.CERTIFICATE_KEY_USAGE_FLAGS,
config.CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS,
config.REVOCATION_LIST_LIFETIME)
asyncore.loop()
else:
click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH))
@click.command("client", help="Setup X.509 certificates for application")
@ -171,6 +159,7 @@ def certidude_spawn(kill, no_interaction):
@click.option("--certificate-path", "-c", default=HOSTNAME + ".crt", help="Certificate path, %s.crt by default" % HOSTNAME)
@click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt by default")
def certidude_setup_client(quiet, **kwargs):
from certidude.helpers import certidude_request_certificate
return certidude_request_certificate(**kwargs)
@ -197,6 +186,7 @@ def certidude_setup_client(quiet, **kwargs):
@expand_paths()
def certidude_setup_openvpn_server(url, config, subnet, route, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, dhparam_path, local, proto, port):
# TODO: Intelligent way of getting last IP address in the subnet
from certidude.helpers import certidude_request_certificate
subnet_first = None
subnet_last = None
subnet_second = None
@ -213,7 +203,6 @@ def certidude_setup_openvpn_server(url, config, subnet, route, email_address, co
click.echo("use following command to sign on Certidude server instead of web interface:")
click.echo()
click.echo(" certidude sign %s" % common_name)
retval = certidude_request_certificate(
url,
key_path,
@ -536,19 +525,14 @@ def certidude_setup_production(username, hostname, push_server, nginx_config, uw
@click.option("--pkcs11", default=False, is_flag=True, help="Use PKCS#11 token instead of files")
@click.option("--crl-distribution-url", default=None, help="CRL distribution URL")
@click.option("--ocsp-responder-url", default=None, help="OCSP responder URL")
@click.option("--email-address", default=EMAIL, help="CA e-mail address")
@click.option("--inbox", default="imap://user:pass@host:port/INBOX", help="Inbound e-mail server")
@click.option("--outbox", default="smtp://localhost", help="Outbound e-mail server")
@click.option("--push-server", default="", help="Streaming nginx push server")
@click.option("--directory", default=None, help="Directory for authority files, /var/lib/certidude/<common-name>/ by default")
def certidude_setup_authority(parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, pkcs11, crl_distribution_url, ocsp_responder_url, email_address, inbox, outbox, push_server):
if not directory:
directory = os.path.join("/var/lib/certidude", common_name)
@click.option("--email-address", default="certidude@" + FQDN, help="E-mail address of the CA")
@click.option("--directory", default=os.path.join("/var/lib/certidude", FQDN), help="Directory for authority files, /var/lib/certidude/ by default")
def certidude_setup_authority(parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, pkcs11, crl_distribution_url, ocsp_responder_url, push_server, email_address):
# Make sure common_name is valid
if not re.match(r"^[\._a-zA-Z0-9]+$", common_name):
raise click.ClickException("CA name can contain only alphanumeric and '_' characters")
if not re.match(r"^[\.\-_a-zA-Z0-9]+$", common_name):
raise click.ClickException("CA name can contain only alphanumeric, '_' and '-' characters")
if os.path.lexists(directory):
raise click.ClickException("Output directory {} already exists.".format(directory))
@ -612,14 +596,13 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
crl_distribution_points.encode("ascii"))
])
if email_address:
subject_alt_name = "email:%s" % email_address
ca.add_extensions([
crypto.X509Extension(
b"subjectAltName",
False,
subject_alt_name.encode("ascii"))
])
subject_alt_name = "email:%s" % email_address
ca.add_extensions([
crypto.X509Extension(
b"subjectAltName",
False,
subject_alt_name.encode("ascii"))
])
if ocsp_responder_url:
raise NotImplementedError()
@ -635,7 +618,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
])
"""
click.echo("Signing %s..." % subject2dn(ca.get_subject()))
click.echo("Signing %s..." % ca.get_subject())
# openssl x509 -in ca_crt.pem -outform DER | sha256sum
# openssl x509 -fingerprint -in ca_crt.pem
@ -665,13 +648,9 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
with open(ca_key, "wb") as fh:
fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key))
ssl_cnf_example = os.path.join(directory, "openssl.cnf.example")
with open(ssl_cnf_example, "w") as fh:
fh.write(env.get_template("openssl.cnf").render(locals()))
click.echo("You need to copy the contents of the '%s'" % ssl_cnf_example)
click.echo("to system-wide OpenSSL configuration file, usually located")
click.echo("at /etc/ssl/openssl.cnf")
certidude_conf = os.path.join("/etc/certidude.conf")
with open(certidude_conf, "w") as fh:
fh.write(env.get_template("certidude.conf").render(locals()))
click.echo()
click.echo("Use following commands to inspect the newly created files:")
@ -691,7 +670,6 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
@click.command("list", help="List certificates")
@click.argument("ca", nargs=-1)
@click.option("--verbose", "-v", default=False, is_flag=True, help="Verbose output")
@click.option("--show-key-type", "-k", default=False, is_flag=True, help="Show key type and length")
@click.option("--show-path", "-p", default=False, is_flag=True, help="Show filesystem paths")
@ -699,7 +677,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
@click.option("--hide-requests", "-h", default=False, is_flag=True, help="Hide signing requests")
@click.option("--show-signed", "-s", default=False, is_flag=True, help="Show signed certificates")
@click.option("--show-revoked", "-r", default=False, is_flag=True, help="Show revoked certificates")
def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_signed, show_revoked, hide_requests):
def certidude_list(verbose, show_key_type, show_extensions, show_path, show_signed, show_revoked, hide_requests):
# Statuses:
# s - submitted
# v - valid
@ -738,147 +716,99 @@ def certidude_list(ca, verbose, show_key_type, show_extensions, show_path, show_
if j.fqdn:
click.echo("Associated hostname: " + j.fqdn)
config = load_config()
wanted_list = None
if ca:
missing = list(set(ca) - set(config.ca_list))
if missing:
raise click.NoSuchOption(option_name='', message="Unable to find certificate authority.", possibilities=config.ca_list)
wanted_list = ca
if not hide_requests:
for j in authority.list_requests():
for ca in config.all_authorities(wanted_list):
if not hide_requests:
for j in ca.get_requests():
if not verbose:
click.echo("s " + j.path + " " + j.identity)
continue
click.echo(click.style(j.common_name, fg="blue"))
click.echo("=" * len(j.common_name))
click.echo("State: ? " + click.style("submitted", fg="yellow") + " " + naturaltime(j.created) + click.style(", %s" %j.created, fg="white"))
if not verbose:
click.echo("s " + j.path + " " + j.identity)
continue
click.echo(click.style(j.common_name, fg="blue"))
click.echo("=" * len(j.common_name))
click.echo("State: ? " + click.style("submitted", fg="yellow") + " " + naturaltime(j.created) + click.style(", %s" %j.created, fg="white"))
dump_common(j)
dump_common(j)
# Calculate checksums for cross-checking
import hashlib
md5sum = hashlib.md5()
sha1sum = hashlib.sha1()
sha256sum = hashlib.sha256()
with open(j.path, "rb") as fh:
buf = fh.read()
md5sum.update(buf)
sha1sum.update(buf)
sha256sum.update(buf)
click.echo("MD5 checksum: %s" % md5sum.hexdigest())
click.echo("SHA-1 checksum: %s" % sha1sum.hexdigest())
click.echo("SHA-256 checksum: %s" % sha256sum.hexdigest())
# Calculate checksums for cross-checking
import hashlib
md5sum = hashlib.md5()
sha1sum = hashlib.sha1()
sha256sum = hashlib.sha256()
with open(j.path, "rb") as fh:
buf = fh.read()
md5sum.update(buf)
sha1sum.update(buf)
sha256sum.update(buf)
click.echo("MD5 checksum: %s" % md5sum.hexdigest())
click.echo("SHA-1 checksum: %s" % sha1sum.hexdigest())
click.echo("SHA-256 checksum: %s" % sha256sum.hexdigest())
if show_path:
click.echo("Details: openssl req -in %s -text -noout" % j.path)
click.echo("Sign: certidude sign %s" % j.path)
click.echo()
if show_signed:
for j in ca.get_signed():
if not verbose:
if j.signed < NOW and j.expires > NOW:
click.echo("v " + j.path + " " + j.identity)
elif NOW > j.expires:
click.echo("e " + j.path + " " + j.identity)
else:
click.echo("y " + j.path + " " + j.identity)
continue
click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white"))
click.echo("="*(len(j.common_name)+60))
if show_path:
click.echo("Details: openssl req -in %s -text -noout" % j.path)
click.echo("Sign: certidude sign %s" % j.path)
click.echo()
if show_signed:
for j in authority.list_signed():
if not verbose:
if j.signed < NOW and j.expires > NOW:
click.echo("Status: \u2713 " + click.style("valid", fg="green") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white"))
click.echo("v " + j.path + " " + j.identity)
elif NOW > j.expires:
click.echo("Status: \u2717 " + click.style("expired", fg="red") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white"))
click.echo("e " + j.path + " " + j.identity)
else:
click.echo("Status: \u2717 " + click.style("not valid yet", fg="red") + click.style(", %s" %j.expires, fg="white"))
dump_common(j)
click.echo("y " + j.path + " " + j.identity)
continue
if show_path:
click.echo("Details: openssl x509 -in %s -text -noout" % j.path)
click.echo("Revoke: certidude revoke %s" % j.path)
click.echo()
click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white"))
click.echo("="*(len(j.common_name)+60))
if show_revoked:
for j in ca.get_revoked():
if not verbose:
click.echo("r " + j.path + " " + j.identity)
continue
click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white"))
click.echo("="*(len(j.common_name)+60))
click.echo("Status: \u2717 " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-j.changed), click.style(", %s" % j.changed, fg="white")))
dump_common(j)
if show_path:
click.echo("Details: openssl x509 -in %s -text -noout" % j.path)
click.echo()
if j.signed < NOW and j.expires > NOW:
click.echo("Status: \u2713 " + click.style("valid", fg="green") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white"))
elif NOW > j.expires:
click.echo("Status: \u2717 " + click.style("expired", fg="red") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white"))
else:
click.echo("Status: \u2717 " + click.style("not valid yet", fg="red") + click.style(", %s" %j.expires, fg="white"))
dump_common(j)
click.echo()
if show_path:
click.echo("Details: openssl x509 -in %s -text -noout" % j.path)
click.echo("Revoke: certidude revoke %s" % j.path)
click.echo()
@click.command("list", help="List Certificate Authorities")
@click.argument("ca")
#@config.pop_certificate_authority()
def cert_list(ca):
if show_revoked:
for j in authority.list_revoked():
if not verbose:
click.echo("r " + j.path + " " + j.identity)
continue
click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white"))
click.echo("="*(len(j.common_name)+60))
click.echo("Status: \u2717 " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-j.changed), click.style(", %s" % j.changed, fg="white")))
dump_common(j)
if show_path:
click.echo("Details: openssl x509 -in %s -text -noout" % j.path)
click.echo()
mapping = {}
click.echo()
config = load_config()
click.echo("Listing certificates for: %s" % ca.certificate.subject.CN)
for serial, reason, timestamp in ca.get_revoked():
mapping[serial] = None, reason
for certificate in ca.get_signed():
mapping[certificate.serial] = certificate, None
for serial, (certificate, reason) in sorted(mapping.items(), key=lambda j:j[0]):
if not reason:
click.echo(" %03d. %s %s" % (serial, certificate.subject.CN, (certificate.not_after-NOW)))
else:
click.echo(" %03d. Revoked due to: %s" % (serial, reason))
for request in ca.get_requests():
click.echo("%s" % request.subject.CN)
@click.command("sign", help="Sign certificates")
@click.argument("common_name")
@click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN")
@click.option("--lifetime", "-l", help="Lifetime")
def certidude_sign(common_name, overwrite, lifetime):
config = load_config()
def iterate():
for ca in config.all_authorities():
for request in ca.get_requests():
if request.common_name != common_name:
continue
print(request.fingerprint(), request.common_name, request.path, request.key_usage)
yield ca, request
request = authority.get_request(common_name)
if request.signable:
# Sign via signer process
cert = authority.sign(request)
else:
# Sign directly using private key
cert = authority.sign2(request, overwrite, True, lifetime)
results = tuple(iterate())
click.echo("Signed %s" % cert.identity)
for key, value, data in cert.extensions:
click.echo("Added extension %s: %s" % (key, value))
click.echo()
click.echo("Press Ctrl-C to cancel singing these requests...")
sys.stdin.readline()
for ca, request in results:
if request.signable:
# Sign via signer process
cert = ca.sign(request)
else:
# Sign directly using private key
cert = ca.sign2(request, overwrite, True, lifetime)
click.echo("Signed %s" % cert.identity)
for key, value, data in cert.extensions:
click.echo("Added extension %s: %s" % (key, value))
click.echo()
@click.command("serve", help="Run built-in HTTP server")
@click.option("-u", "--user", default="certidude", help="Run as user")
@click.option("-p", "--port", default=80, help="Listen port")

27
certidude/common.py Normal file
View 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
View 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
View 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

View File

@ -2,34 +2,9 @@
import click
import os
import urllib.request
from certidude.wrappers import Certificate, Request
from certidude import config
from OpenSSL import crypto
def expand_paths():
"""
Prefix '..._path' keyword arguments of target function with 'directory' keyword argument
and create the directory if necessary
TODO: Move to separate file
"""
def wrapper(func):
def wrapped(**arguments):
d = arguments.get("directory")
for key, value in arguments.items():
if key.endswith("_path"):
if d:
value = os.path.join(d, value)
value = os.path.realpath(value)
parent = os.path.dirname(value)
if not os.path.exists(parent):
click.echo("Making directory %s for %s" % (repr(parent), repr(key)))
os.makedirs(parent)
elif not os.path.isdir(parent):
raise Exception("Path %s is not directory!" % parent)
arguments[key] = value
return func(**arguments)
return wrapped
return wrapper
def certidude_request_certificate(url, key_path, request_path, certificate_path, authority_path, common_name, org_unit, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None, ip_address=None, dns=None):

28
certidude/push.py Normal file
View 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)

View File

@ -1,59 +1,62 @@
<h1>{{authority.common_name}} management</h1>
<p>Hi {{session.username}},</p>
<p>Request submission is allowed from: {% if authority.request_subnets %}{% for i in authority.request_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}</p>
<p>Autosign is allowed from: {% if authority.autosign_subnets %}{% for i in authority.autosign_subnets %}{{ i }} {% endfor %}{% else %}nowhere{% endif %}</p>
<p>Authority administration is allowed from: {% if authority.admin_subnets %}{% for i in authority.admin_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}
<p>Authority administration allowed for: {% for i in authority.admin_users %}{{ i }} {% endfor %}</p>
<p>Request submission is allowed from: {% if session.request_subnets %}{% for i in session.request_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}</p>
<p>Autosign is allowed from: {% if session.autosign_subnets %}{% for i in session.autosign_subnets %}{{ i }} {% endfor %}{% else %}nowhere{% endif %}</p>
<p>Authority administration is allowed from: {% if session.admin_subnets %}{% for i in session.admin_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}
<p>Authority administration allowed for: {% for i in session.admin_users %}{{ i }} {% endfor %}</p>
{% set s = authority.certificate.identity %}
{% set s = session.certificate.identity %}
<input id="search" class="icon search" type="search" placeholder="hostname, IP-address, etc"/>
<h1>Pending requests</h1>
<ul id="pending_requests">
{% for request in authority.requests %}
{% include "request.html" %}
{% endfor %}
<li class="notify">
<p>No certificate signing requests to sign! You can submit a certificate signing request by:</p>
<pre>certidude setup client {{authority.common_name}}</pre>
</li>
</ul>
<div id="requests">
<h1>Pending requests</h1>
<h1>Signed certificates</h1>
<ul id="signed_certificates">
{% for certificate in authority.signed | sort | reverse %}
{% include "signed.html" %}
{% endfor %}
</ul>
<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>
<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 }}">
{{j.changed}}
{{j.serial_number}} <span class="monospace">{{j.identity}}</span>
<ul id="pending_requests">
{% for request in session.requests %}
{% include "request.html" %}
{% endfor %}
<li class="notify">
<p>No certificate signing requests to sign! You can submit a certificate signing request by:</p>
<pre>certidude setup client {{session.common_name}}</pre>
</li>
{% else %}
<li>Great job! No certificate signing requests to sign.</li>
{% endfor %}
</ul>
</ul>
</div>
<div id="signed">
<h1>Signed certificates</h1>
<ul id="signed_certificates">
{% for certificate in session.signed | sort | reverse %}
{% include "signed.html" %}
{% endfor %}
</ul>
</div>
<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>
<pre>
curl {{request.url}}/certificate/ > session.pem
openssl ocsp -issuer session.pem -CAfile session.pem -url {{request.url}}/ocsp/ -serial 0x
</pre>
-->
<ul>
{% for j in session.revoked %}
<li id="certificate_{{ j.sha256sum }}">
{{j.changed}}
{{j.serial_number}} <span class="monospace">{{j.identity}}</span>
</li>
{% else %}
<li>Great job! No certificate signing requests to sign.</li>
{% endfor %}
</ul>
</div>

View File

@ -94,9 +94,7 @@ html,body {
}
body {
background: #222;
background-image: url('../img/free_hexa_pattern_cc0_by_black_light_studio.png');
background-position: center;
background: #fff;
}
.comment {
@ -142,24 +140,31 @@ pre {
margin: 0 0;
}
#container {
max-width: 60em;
margin: 1em auto;
background: #fff;
padding: 1em;
border-style: solid;
border-width: 2px;
border-color: #aaa;
border-radius: 10px;
.container {
max-width: 960px;
margin: 0 auto;
}
li {
#container li {
margin: 4px 0;
padding: 4px 0;
clear: both;
border-top: 1px dashed #ccc;
}
#menu {
background-color: #444;
}
#menu li {
color: #fff;
border: none;
display: inline;
margin: 1mm 5mm 1mm 0;
line-height: 200%;
}
.icon{
background-size: 24px;
padding-left: 36px;

View File

@ -11,7 +11,15 @@
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon">
</head>
<body>
<div id="container">
<div id="menu">
<ul class="container">
<li>Requests</li>
<li>Signed</li>
<li>Revoked</li>
<li>Log</li>
</ul>
</div>
<div id="container" class="container">
Loading certificate authority...
</div>
</body>

View File

@ -1,9 +1,8 @@
$(document).ready(function() {
console.info("Loading CA, to debug: curl " + window.location.href + " --negotiate -u : -H 'Accept: application/json'");
$.ajax({
method: "GET",
url: "/api/session/",
url: "/api/",
dataType: "json",
error: function(response) {
if (response.responseJSON) {
@ -14,130 +13,116 @@ $(document).ready(function() {
$("#container").html(nunjucks.render('error.html', { message: msg }));
},
success: function(session, status, xhr) {
console.info("Loaded CA list:", session);
console.info("Got:", session);
if (!session.authorities) {
alert("No certificate authorities to manage! Have you created one yet?");
return;
console.info("Opening EventSource from:", session.event_channel);
var source = new EventSource(session.event_channel);
source.onmessage = function(event) {
console.log("Received server-sent event:", event);
}
source.addEventListener("up-client", function(e) {
console.log("Adding security association:" + e.data);
var lease = JSON.parse(e.data);
var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status");
$status.html(nunjucks.render('status.html', {
lease: {
address: lease.address,
identity: lease.identity,
acquired: new Date(),
released: null
}}));
});
source.addEventListener("down-client", function(e) {
console.log("Removing security association:" + e.data);
var lease = JSON.parse(e.data);
var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status");
$status.html(nunjucks.render('status.html', {
lease: {
address: lease.address,
identity: lease.identity,
acquired: null,
released: new Date()
}}));
});
source.addEventListener("request_deleted", function(e) {
console.log("Removing deleted request #" + e.data);
$("#request_" + e.data).remove();
});
source.addEventListener("request_submitted", function(e) {
console.log("Request submitted:", e.data);
$.ajax({
method: "GET",
url: "/api/request/" + e.data + "/",
dataType: "json",
success: function(request, status, xhr) {
console.info(request);
$("#pending_requests").prepend(
nunjucks.render('request.html', { request: request }));
}
});
});
source.addEventListener("request_signed", function(e) {
console.log("Request signed:", e.data);
$("#request_" + e.data).slideUp("normal", function() { $(this).remove(); });
$.ajax({
method: "GET",
url: "/api/signed/" + e.data + "/",
dataType: "json",
success: function(certificate, status, xhr) {
console.info(certificate);
$("#signed_certificates").prepend(
nunjucks.render('signed.html', { certificate: certificate }));
}
});
});
source.addEventListener("certificate_revoked", function(e) {
console.log("Removing revoked certificate #" + e.data);
$("#certificate_" + e.data).slideUp("normal", function() { $(this).remove(); });
});
$("#container").html(nunjucks.render('authority.html', { session: session, window: window }));
$.ajax({
method: "GET",
url: "/api/",
url: "/api/lease/",
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) {
console.log("Received server-sent event:", event);
success: function(leases, status, xhr) {
console.info("Got leases:", leases);
for (var j = 0; j < leases.length; j++) {
var $status = $("#signed_certificates [data-dn='" + leases[j].identity + "'] .status");
if (!$status.length) {
console.info("Detected rogue client:", leases[j]);
continue;
}
$status.html(nunjucks.render('status.html', {
lease: {
address: leases[j].address,
identity: leases[j].identity,
acquired: new Date(leases[j].acquired).toLocaleString(),
released: leases[j].released ? new Date(leases[j].released).toLocaleString() : null
}}));
}
source.addEventListener("up-client", function(e) {
console.log("Adding security association:" + e.data);
var lease = JSON.parse(e.data);
var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status");
$status.html(nunjucks.render('status.html', {
lease: {
address: lease.address,
identity: lease.identity,
acquired: new Date(),
released: null
}}));
});
source.addEventListener("down-client", function(e) {
console.log("Removing security association:" + e.data);
var lease = JSON.parse(e.data);
var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status");
$status.html(nunjucks.render('status.html', {
lease: {
address: lease.address,
identity: lease.identity,
acquired: null,
released: new Date()
}}));
});
source.addEventListener("request_deleted", function(e) {
console.log("Removing deleted request #" + e.data);
$("#request_" + e.data).remove();
});
source.addEventListener("request_submitted", function(e) {
console.log("Request submitted:", e.data);
$.ajax({
method: "GET",
url: "/api/request/lauri-c720p/",
dataType: "json",
success: function(request, status, xhr) {
console.info(request);
$("#pending_requests").prepend(
nunjucks.render('request.html', { request: request }));
/* Set up search box */
$("#search").on("keyup", function() {
var q = $("#search").val().toLowerCase();
$(".filterable").each(function(i, e) {
if ($(e).attr("data-dn").toLowerCase().indexOf(q) >= 0) {
$(e).show();
} else {
$(e).hide();
}
});
});
source.addEventListener("request_signed", function(e) {
console.log("Request signed:", e.data);
$("#request_" + e.data).slideUp("normal", function() { $(this).remove(); });
$.ajax({
method: "GET",
url: "/api/signed/lauri-c720p/",
dataType: "json",
success: function(certificate, status, xhr) {
console.info(certificate);
$("#signed_certificates").prepend(
nunjucks.render('signed.html', { certificate: certificate }));
}
});
});
source.addEventListener("certificate_revoked", function(e) {
console.log("Removing revoked certificate #" + e.data);
$("#certificate_" + e.data).slideUp("normal", function() { $(this).remove(); });
});
$("#container").html(nunjucks.render('authority.html', { authority: authority, session: session, window: window }));
$.ajax({
method: "GET",
url: "/api/lease/",
dataType: "json",
success: function(leases, status, xhr) {
console.info("Got leases:", leases);
for (var j = 0; j < leases.length; j++) {
var $status = $("#signed_certificates [data-dn='" + leases[j].identity + "'] .status");
if (!$status.length) {
console.info("Detected rogue client:", leases[j]);
continue;
}
$status.html(nunjucks.render('status.html', {
lease: {
address: leases[j].address,
identity: leases[j].identity,
acquired: new Date(leases[j].acquired).toLocaleString(),
released: leases[j].released ? new Date(leases[j].released).toLocaleString() : null
}}));
}
/* Set up search box */
$("#search").on("keyup", function() {
var q = $("#search").val().toLowerCase();
$(".filterable").each(function(i, e) {
if ($(e).attr("data-dn").toLowerCase().indexOf(q) >= 0) {
$(e).show();
} else {
$(e).hide();
}
});
});
}
});
}
});

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

View File

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

View File

@ -1,60 +1,14 @@
import os
import hashlib
import logging
import re
import itertools
import click
import socket
import io
import urllib.request
import ipaddress
from configparser import RawConfigParser
from certidude import push
from Crypto.Util import asn1
from OpenSSL import crypto
from datetime import datetime
from jinja2 import Environment, PackageLoader, Template
from certidude.mailer import Mailer
from certidude.signer import raw_sign, EXTENSION_WHITELIST
env = Environment(loader=PackageLoader("certidude", "email_templates"))
# https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/
# https://jamielinux.com/docs/openssl-certificate-authority/
# http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py
def publish_certificate(func):
# TODO: Implement e-mail and nginx notifications using hooks
def wrapped(instance, csr, *args, **kwargs):
cert = func(instance, csr, *args, **kwargs)
assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert))
if instance.push_server:
url = instance.push_server + "/pub/?id=" + csr.fingerprint()
notification = urllib.request.Request(url, cert.dump().encode("ascii"))
notification.add_header("User-Agent", "Certidude API")
notification.add_header("Content-Type", "application/x-x509-user-cert")
click.echo("Publishing certificate at %s, waiting for response..." % url)
response = urllib.request.urlopen(notification)
response.read()
instance.event_publish("request_signed", csr.fingerprint())
return cert
# TODO: Implement e-mailing
# self.mailer.send(
# self.certificate.email_address,
# (self.certificate.email_address, cert.email_address),
# "Certificate %s signed" % cert.distinguished_name,
# "certificate-signed",
# old_cert=old_cert,
# cert=cert,
# ca=self.certificate)
return wrapped
def subject2dn(subject):
bits = []
for j in "CN", "GN", "SN", "C", "S", "L", "O", "OU":
@ -62,79 +16,6 @@ def subject2dn(subject):
bits.append("%s=%s" % (j, getattr(subject, j)))
return ", ".join(bits)
class CertificateAuthorityConfig(object):
"""
Certificate Authority configuration
:param path: Absolute path to configuration file.
Defaults to /etc/ssl/openssl.cnf
"""
def __init__(self, path='/etc/ssl/openssl.cnf', *args):
#: Path to file where current configuration is loaded from.
self.path = path
self._config = RawConfigParser()
self._config.readfp(itertools.chain(["[global]"], open(self.path)))
def get(self, section, key, default=""):
if self._config.has_option(section, key):
return self._config.get(section, key)
else:
return default
def instantiate_authority(self, common_name):
section = "CA_" + common_name
dirs = dict([(key, self.get(section, key))
for key in ("dir", "certificate", "crl", "certs", "new_certs_dir", "private_key", "revoked_certs_dir", "request_subnets", "autosign_subnets", "admin_subnets", "admin_users", "push_server", "database", "inbox", "outbox")])
# Variable expansion, eg $dir
for key, value in dirs.items():
if "$" in value:
dirs[key] = re.sub(r'\$([a-z]+)', lambda m:dirs[m.groups()[0]], value)
dirs.pop("dir")
dirs["email_address"] = self.get(section, "emailAddress")
dirs["certificate_lifetime"] = int(self.get(section, "default_days", "1825"))
dirs["revocation_list_lifetime"] = int(self.get(section, "default_crl_days", "1"))
extensions_section = self.get(section, "x509_extensions")
if extensions_section:
dirs["basic_constraints"] = self.get(extensions_section, "basicConstraints")
dirs["key_usage"] = self.get(extensions_section, "keyUsage")
dirs["extended_key_usage"] = self.get(extensions_section, "extendedKeyUsage")
authority = CertificateAuthority(common_name, **dirs)
return authority
def all_authorities(self, wanted=None):
for ca in self.ca_list:
if wanted and ca not in wanted:
continue
try:
yield self.instantiate_authority(ca)
except FileNotFoundError:
pass
@property
def ca_list(self):
"""
Returns sorted list of CA-s defined in the configuration file.
"""
return sorted([s[3:] for s in self._config if s.startswith("CA_")])
def pop_certificate_authority(self):
def wrapper(func):
def wrapped(*args, **kwargs):
common_name = kwargs.pop("ca")
kwargs["ca"] = self.instantiate_authority(common_name)
return func(*args, **kwargs)
return wrapped
return wrapper
class CertificateBase:
def __repr__(self):
return self.buf
@ -351,9 +232,6 @@ class Request(CertificateBase):
def dump(self):
return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self._obj).decode("ascii")
def __repr__(self):
return "Request(%s)" % repr(self.path)
def create(self):
# Generate 4096-bit RSA key
key = crypto.PKey()
@ -364,6 +242,7 @@ class Request(CertificateBase):
req.set_pubkey(key)
return Request(req)
class Certificate(CertificateBase):
def __init__(self, mixed):
self.buf = NotImplemented
@ -387,7 +266,7 @@ class Certificate(CertificateBase):
if isinstance(mixed, crypto.X509):
self._obj = mixed
else:
raise ValueError("Can't parse %s as X.509 certificate!" % mixed)
raise ValueError("Can't parse %s (%s) as X.509 certificate!" % (mixed, type(mixed)))
assert not self.buf or self.buf == self.dump(), "%s is not %s" % (self.buf, self.dump())
@ -435,275 +314,3 @@ class Certificate(CertificateBase):
def __lte__(self, other):
return self.signed <= other.signed
class CertificateAuthority(object):
def __init__(self, common_name, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, private_key=None, autosign_subnets=None, request_subnets=None, admin_subnets=None, admin_users=None, email_address=None, inbox=None, outbox=None, basic_constraints="CA:FALSE", key_usage="digitalSignature,keyEncipherment", extended_key_usage="clientAuth", certificate_lifetime=5*365, revocation_list_lifetime=1, push_server=None, database=None):
import hashlib
m = hashlib.sha512()
m.update(common_name.encode("ascii"))
m.update(b"TODO:server-secret-goes-here")
self.uuid = m.hexdigest()
self.revocation_list = crl
self.signed_dir = certs
self.request_dir = new_certs_dir
self.revoked_dir = revoked_certs_dir
self.private_key = private_key
self.admin_subnets = set([ipaddress.ip_network(j) for j in admin_subnets.split(" ") if j])
self.autosign_subnets = set([ipaddress.ip_network(j) for j in autosign_subnets.split(" ") if j])
self.request_subnets = set([ipaddress.ip_network(j) for j in request_subnets.split(" ") if j]).union(self.autosign_subnets)
self.certificate = Certificate(open(certificate))
self.mailer = Mailer(outbox) if outbox else None
self.push_server = push_server
self.database_url = database
self._database_pool = None
self.certificate_lifetime = certificate_lifetime
self.revocation_list_lifetime = revocation_list_lifetime
self.basic_constraints = basic_constraints
self.key_usage = key_usage
self.extended_key_usage = extended_key_usage
self.admin_emails = dict()
self.admin_users = set()
if admin_users:
if admin_users.startswith("/"):
for user in open(admin_users):
if ":" in user:
user, email, first_name, last_name = user.split(":")
self.admin_emails[user] = email
self.admin_users.add(user)
else:
self.admin_users = set([j for j in admin_users.split(" ") if j])
@property
def common_name(self):
return self.certificate.common_name
@property
def database(self):
from urllib.parse import urlparse
if not self._database_pool:
o = urlparse(self.database_url)
if o.scheme == "mysql":
import mysql.connector
self._database_pool = mysql.connector.pooling.MySQLConnectionPool(
pool_size = 3,
user=o.username,
password=o.password,
host=o.hostname,
database=o.path[1:])
else:
raise NotImplementedError("Unsupported database scheme %s, currently only mysql://user:pass@host/database is supported" % o.scheme)
return self._database_pool
def event_publish(self, event_type, event_data):
"""
Publish event on push server
"""
url = self.push_server + "/pub?id=" + self.uuid # Derive CA's push channel URL
notification = urllib.request.Request(
url,
event_data.encode("utf-8"),
{"Event-ID": b"TODO", "Event-Type":event_type.encode("ascii")})
notification.add_header("User-Agent", "Certidude API")
click.echo("Posting event %s %s at %s, waiting for response..." % (repr(event_type), repr(event_data), repr(url)))
try:
response = urllib.request.urlopen(notification)
body = response.read()
except urllib.error.HTTPError as err:
if err.code == 404:
print("No subscribers on the channel")
else:
raise
else:
print("Push server returned:", response.code, body)
def _signer_exec(self, cmd, *bits):
sock = self.connect_signer()
sock.send(cmd.encode("ascii"))
sock.send(b"\n")
for bit in bits:
sock.send(bit.encode("ascii"))
sock.sendall(b"\n\n")
buf = sock.recv(8192)
if not buf:
raise
return buf
def __repr__(self):
return "CertificateAuthority(common_name=%s)" % repr(self.common_name)
def get_certificate(self, cn):
return open(os.path.join(self.signed_dir, cn + ".pem")).read()
def connect_signer(self):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect("/run/certidude/signer/%s.sock" % self.common_name)
return sock
def revoke(self, cn):
cert = Certificate(open(os.path.join(self.signed_dir, cn + ".pem")))
revoked_filename = os.path.join(self.revoked_dir, "%s.pem" % cert.serial_number)
os.rename(cert.path, revoked_filename)
self.event_publish("certificate_revoked", cert.fingerprint())
def get_revoked(self):
for root, dirs, files in os.walk(self.revoked_dir):
for filename in files:
if filename.endswith(".pem"):
yield Certificate(open(os.path.join(root, filename)))
break
def get_signed(self):
for root, dirs, files in os.walk(self.signed_dir):
for filename in files:
if filename.endswith(".pem"):
yield Certificate(open(os.path.join(root, filename)))
break
def get_requests(self):
for root, dirs, files in os.walk(self.request_dir):
for filename in files:
if filename.endswith(".pem"):
yield Request(open(os.path.join(root, filename)))
break
def get_request(self, cn):
return Request(open(os.path.join(self.request_dir, cn + ".pem")))
def store_request(self, buf, overwrite=False):
request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf)
common_name = request.get_subject().CN
request_path = os.path.join(self.request_dir, common_name + ".pem")
# If there is cert, check if it's the same
if os.path.exists(request_path):
if open(request_path, "rb").read() != buf:
print("Request already exists, not creating new request")
raise FileExistsError("Request already exists")
else:
with open(request_path + ".part", "wb") as fh:
fh.write(buf)
os.rename(request_path + ".part", request_path)
return Request(open(request_path))
def request_exists(self, cn):
return os.path.exists(os.path.join(self.request_dir, cn + ".pem"))
def delete_request(self, cn):
path = os.path.join(self.request_dir, cn + ".pem")
request_sha1sum = Request(open(path)).fingerprint()
os.unlink(path)
# Publish event at CA channel
self.event_publish("request_deleted", request_sha1sum)
# Write empty certificate to long-polling URL
url = self.push_server + "/pub/?id=" + request_sha1sum
publisher = urllib.request.Request(url, b"-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n")
publisher.add_header("User-Agent", "Certidude API")
click.echo("POST-ing empty certificate at %s, waiting for response..." % url)
try:
response = urllib.request.urlopen(publisher)
body = response.read()
except urllib.error.HTTPError as err:
if err.code == 404:
print("No subscribers on the channel")
else:
raise
else:
print("Push server returned:", response.code, body)
def create_bundle(self, common_name, organizational_unit=None, email_address=None, overwrite=True):
req = Request.create()
req.country = self.certificate.country
req.state_or_county = self.certificate.state_or_county
req.city = self.certificate.city
req.organization = self.certificate.organization
req.organizational_unit = organizational_unit or self.certificate.organizational_unit
req.common_name = common_name
req.email_address = email_address
cert_buf = self.sign(req, overwrite)
return crypto.dump_privatekey(crypto.FILETYPE_PEM, key).decode("ascii"), \
req_buf, cert_buf
@publish_certificate
def sign(self, req, overwrite=False, delete=True):
"""
Sign certificate signing request via signer process
"""
cert_path = os.path.join(self.signed_dir, req.common_name + ".pem")
# Move existing certificate if necessary
if os.path.exists(cert_path):
old_cert = Certificate(open(cert_path))
if overwrite:
self.revoke(req.common_name)
elif req.pubkey == old_cert.pubkey:
return old_cert
else:
raise FileExistsError("Will not overwrite existing certificate")
# Sign via signer process
cert_buf = self._signer_exec("sign-request", req.dump())
with open(cert_path + ".part", "wb") as fh:
fh.write(cert_buf)
os.rename(cert_path + ".part", cert_path)
return Certificate(open(cert_path))
@publish_certificate
def sign2(self, request, overwrite=False, delete=True, lifetime=None):
"""
Sign directly using private key, this is usually done by root.
Basic constraints and certificate lifetime are copied from openssl.cnf,
lifetime may be overridden on the command line,
other extensions are copied as is.
"""
cert = raw_sign(
crypto.load_privatekey(crypto.FILETYPE_PEM, open(self.private_key).read()),
self.certificate._obj,
request._obj,
self.basic_constraints,
lifetime=lifetime or self.certificate_lifetime)
path = os.path.join(self.signed_dir, request.common_name + ".pem")
if os.path.exists(path):
if overwrite:
self.revoke(request.common_name)
else:
raise FileExistsError("File %s already exists!" % path)
buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
with open(path + ".part", "wb") as fh:
fh.write(buf)
os.rename(path + ".part", path)
click.echo("Wrote certificate to: %s" % path)
if delete:
os.unlink(request.path)
click.echo("Deleted request: %s" % request.path)
return Certificate(open(path))
def export_crl(self):
sock = self.connect_signer()
sock.send(b"export-crl\n")
for filename in os.listdir(self.revoked_dir):
if not filename.endswith(".pem"):
continue
serial_number = filename[:-4]
# TODO: Assert serial against regex
revoked_path = os.path.join(self.revoked_dir, filename)
# TODO: Skip expired certificates
s = os.stat(revoked_path)
sock.send(("%s:%d\n" % (serial_number, s.st_ctime)).encode("ascii"))
sock.sendall(b"\n")
return sock.recv(32*1024*1024)