1
0
mirror of https://github.com/laurivosandi/certidude synced 2024-12-22 08:15:18 +00:00

Refactor codebase

* Replace PyOpenSSL with cryptography.io
* Rename constants to const
* Drop support for uwsgi
* Use systemd to launch certidude server
* Signer automatically spawned as part of server
* Update requirements.txt
* Clean up certidude client configuration handling
* Add automatic enroll with Kerberos machine cerdentials
This commit is contained in:
Lauri Võsandi 2016-09-18 00:00:14 +03:00
parent 15858083b3
commit b4d006227a
35 changed files with 1181 additions and 1057 deletions

View File

@ -1,4 +1,6 @@
include README.rst include README.rst
include certidude/templates/*.sh
include certidude/templates/*.service
include certidude/templates/*.ovpn include certidude/templates/*.ovpn
include certidude/templates/*.conf include certidude/templates/*.conf
include certidude/templates/*.ini include certidude/templates/*.ini

View File

@ -79,7 +79,8 @@ To install Certidude:
python-pysqlite2 python-mysql.connector python-ldap \ python-pysqlite2 python-mysql.connector python-ldap \
build-essential libffi-dev libssl-dev libkrb5-dev \ build-essential libffi-dev libssl-dev libkrb5-dev \
ldap-utils krb5-user \ ldap-utils krb5-user \
libsasl2-modules-gssapi-mit libsasl2-modules-gssapi-mit \
libsasl2-dev libldap2-dev
pip install certidude pip install certidude
@ -103,17 +104,18 @@ If necessary tweak machine's fully qualified hostname in ``/etc/hosts``:
127.0.0.1 localhost 127.0.0.1 localhost
127.0.1.1 ca.example.com ca 127.0.1.1 ca.example.com ca
Then proceed to install `nchan <https://nchan.slact.net/>`_ and ``uwsgi``: Then proceed to install `nchan <https://nchan.slact.net/>`_:
.. code:: bash .. code:: bash
wget https://nchan.slact.net/download/nginx-common.deb https://nchan.slact.net/download/nginx-extras.deb wget https://nchan.slact.net/download/nginx-common.deb \
https://nchan.slact.net/download/nginx-extras.deb
dpkg -i nginx-common.deb nginx-extras.deb dpkg -i nginx-common.deb nginx-extras.deb
apt-get install nginx uwsgi uwsgi-plugin-python apt-get -f install
Certidude can set up certificate authority relatively easily. Certidude can set up certificate authority relatively easily.
Following will set up certificate authority in ``/var/lib/certidude/hostname.domain.tld``, Following will set up certificate authority in ``/var/lib/certidude/hostname.domain.tld``,
configure uWSGI in ``/etc/uwsgi/apps-available/certidude.ini``, configure gunicorn service for your platform,
nginx in ``/etc/nginx/sites-available/certidude.conf``, nginx in ``/etc/nginx/sites-available/certidude.conf``,
cronjobs in ``/etc/cron.hourly/certidude`` and much more: cronjobs in ``/etc/cron.hourly/certidude`` and much more:
@ -170,7 +172,8 @@ Install dependencies:
apt-get install samba-common-bin krb5-user ldap-utils apt-get install samba-common-bin krb5-user ldap-utils
Reset Samba client configuration in ``/etc/samba/smb.conf``: Reset Samba client configuration in ``/etc/samba/smb.conf``, adjust
workgroup and realm accordingly:
.. code:: ini .. code:: ini
@ -190,6 +193,13 @@ Reset Kerberos configuration in ``/etc/krb5.conf``:
dns_lookup_realm = true dns_lookup_realm = true
dns_lookup_kdc = true dns_lookup_kdc = true
Reset LDAP configuration in /etc/ldap/ldap.conf:
.. code:: bash
BASE dc=example,dc=com
URI ldap://dc1.example.com
Initialize Kerberos credentials: Initialize Kerberos credentials:
.. code:: bash .. code:: bash
@ -230,6 +240,11 @@ Adjust admin filter according to your setup.
Also make sure there is cron.hourly job for creating GSSAPI credential cache - Also make sure there is cron.hourly job for creating GSSAPI credential cache -
that's necessary for querying LDAP using Certidude machine's credentials. that's necessary for querying LDAP using Certidude machine's credentials.
Common pitfalls:
* Following error message may mean that the IP address of the web server does not match the IP address used to join
the CA machine to domain, eg when you're running CA behind SSL terminating web server:
Bad credentials: Unspecified GSS failure. Minor code may provide more information (851968)
Automating certificate setup Automating certificate setup
---------------------------- ----------------------------

View File

@ -12,7 +12,7 @@ from certidude.auth import login_required, authorize_admin
from certidude.user import User from certidude.user import User
from certidude.decorators import serialize, event_source, csrf_protection from certidude.decorators import serialize, event_source, csrf_protection
from certidude.wrappers import Request, Certificate from certidude.wrappers import Request, Certificate
from certidude import constants, config from certidude import const, config
logger = logging.getLogger("api") logger = logging.getLogger("api")
@ -35,7 +35,7 @@ class CertificateAuthorityResource(object):
resp.stream = open(config.AUTHORITY_CERTIFICATE_PATH, "rb") resp.stream = open(config.AUTHORITY_CERTIFICATE_PATH, "rb")
resp.append_header("Content-Type", "application/x-x509-ca-cert") resp.append_header("Content-Type", "application/x-x509-ca-cert")
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % resp.append_header("Content-Disposition", "attachment; filename=%s.crt" %
constants.HOSTNAME.encode("ascii")) const.HOSTNAME.encode("ascii"))
class SessionResource(object): class SessionResource(object):
@ -112,7 +112,7 @@ class NormalizeMiddleware(object):
assert not req.get_param("unicode") or req.get_param("unicode") == u"", "Unicode sanity check failed" assert not req.get_param("unicode") or req.get_param("unicode") == u"", "Unicode sanity check failed"
req.context["remote_addr"] = ipaddress.ip_address(req.env["REMOTE_ADDR"].decode("utf-8")) req.context["remote_addr"] = ipaddress.ip_address(req.env["REMOTE_ADDR"].decode("utf-8"))
def process_response(self, req, resp, resource): def process_response(self, req, resp, resource=None):
# wtf falcon?! # wtf falcon?!
if isinstance(resp.location, unicode): if isinstance(resp.location, unicode):
resp.location = resp.location.encode("ascii") resp.location = resp.location.encode("ascii")
@ -125,7 +125,6 @@ def certidude_app():
from .request import RequestListResource, RequestDetailResource from .request import RequestListResource, RequestDetailResource
from .lease import LeaseResource from .lease import LeaseResource
from .whois import WhoisResource from .whois import WhoisResource
from .log import LogResource
from .tag import TagResource, TagDetailResource from .tag import TagResource, TagDetailResource
from .cfg import ConfigResource, ScriptResource from .cfg import ConfigResource, ScriptResource
@ -149,19 +148,6 @@ def certidude_app():
if config.USER_CERTIFICATE_ENROLLMENT: if config.USER_CERTIFICATE_ENROLLMENT:
app.add_route("/api/bundle/", BundleResource()) app.add_route("/api/bundle/", BundleResource())
log_handlers = []
if config.LOGGING_BACKEND == "sql":
from certidude.mysqllog import LogHandler
uri = config.cp.get("logging", "database")
log_handlers.append(LogHandler(uri))
app.add_route("/api/log/", LogResource(uri))
elif config.LOGGING_BACKEND == "syslog":
from logging.handlers import SyslogHandler
log_handlers.append(SysLogHandler())
# Browsing syslog via HTTP is obviously not possible out of the box
elif config.LOGGING_BACKEND:
raise ValueError("Invalid logging.backend = %s" % config.LOGGING_BACKEND)
if config.TAGGING_BACKEND == "sql": if config.TAGGING_BACKEND == "sql":
uri = config.cp.get("tagging", "database") uri = config.cp.get("tagging", "database")
app.add_route("/api/tag/", TagResource(uri)) app.add_route("/api/tag/", TagResource(uri))
@ -171,23 +157,5 @@ def certidude_app():
elif config.TAGGING_BACKEND: elif config.TAGGING_BACKEND:
raise ValueError("Invalid tagging.backend = %s" % config.TAGGING_BACKEND) raise ValueError("Invalid tagging.backend = %s" % config.TAGGING_BACKEND)
if config.PUSH_PUBLISH:
from certidude.push import PushLogHandler
log_handlers.append(PushLogHandler())
for facility in "api", "cli":
logger = logging.getLogger(facility)
logger.setLevel(logging.DEBUG)
for handler in log_handlers:
logger.addHandler(handler)
logging.getLogger("cli").debug("Started Certidude at %s", constants.FQDN)
import atexit
def exit_handler():
logging.getLogger("cli").debug("Shutting down Certidude")
atexit.register(exit_handler)
return app return app

View File

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

View File

@ -36,8 +36,6 @@ join
tag on device_tag.tag_id = tag.id tag on device_tag.tag_id = tag.id
join join
device on device_tag.device_id = device.id device on device_tag.device_id = device.id
where
device.cn = %s
""" """
@ -63,7 +61,7 @@ class ConfigResource(RelationalMixin):
@login_required @login_required
@authorize_admin @authorize_admin
def on_get(self, req, resp): def on_get(self, req, resp):
return self.iterfetch(SQL_SELECT_RULES) return self.iterfetch(SQL_SELECT_TAGS)
class ScriptResource(RelationalMixin): class ScriptResource(RelationalMixin):

View File

@ -10,6 +10,9 @@ from certidude.decorators import serialize, csrf_protection
from certidude.wrappers import Request, Certificate from certidude.wrappers import Request, Certificate
from certidude.firewall import whitelist_subnets, whitelist_content_types from certidude.firewall import whitelist_subnets, whitelist_content_types
from cryptography import x509
from cryptography.hazmat.backends import default_backend
logger = logging.getLogger("api") logger = logging.getLogger("api")
class RequestListResource(object): class RequestListResource(object):
@ -29,6 +32,7 @@ class RequestListResource(object):
""" """
body = req.stream.read(req.content_length) body = req.stream.read(req.content_length)
csr = Request(body) csr = Request(body)
if not csr.common_name: if not csr.common_name:
@ -38,6 +42,19 @@ class RequestListResource(object):
"Bad request", "Bad request",
"No common name specified!") "No common name specified!")
machine = req.context.get("machine")
if machine:
if csr.common_name != machine:
raise falcon.HTTPBadRequest(
"Bad request",
"Common name %s differs from Kerberos credential %s!" % (csr.common_name, machine))
if csr.signable:
# Automatic enroll with Kerberos machine cerdentials
resp.set_header("Content-Type", "application/x-x509-user-cert")
resp.body = authority.sign(csr, overwrite=True).dump()
return
# Check if this request has been already signed and return corresponding certificte if it has been signed # Check if this request has been already signed and return corresponding certificte if it has been signed
try: try:
cert = authority.get_signed(csr.common_name) cert = authority.get_signed(csr.common_name)
@ -51,8 +68,8 @@ class RequestListResource(object):
# TODO: check for revoked certificates and return HTTP 410 Gone # TODO: check for revoked certificates and return HTTP 410 Gone
# Process automatic signing if the IP address is whitelisted and autosigning was requested # Process automatic signing if the IP address is whitelisted, autosigning was requested and certificate can be automatically signed
if req.get_param_as_bool("autosign"): if req.get_param_as_bool("autosign") and csr.signable:
for subnet in config.AUTOSIGN_SUBNETS: for subnet in config.AUTOSIGN_SUBNETS:
if req.context.get("remote_addr") in subnet: if req.context.get("remote_addr") in subnet:
try: try:

View File

@ -2,7 +2,7 @@
import falcon import falcon
import json import json
import logging import logging
from certidude import constants from certidude import const
from certidude.authority import export_crl, list_revoked from certidude.authority import export_crl, list_revoked
from certidude.decorators import MyEncoder from certidude.decorators import MyEncoder
from cryptography import x509 from cryptography import x509
@ -21,7 +21,7 @@ class RevocationListResource(object):
resp.set_header("Content-Type", "application/x-pkcs7-crl") resp.set_header("Content-Type", "application/x-pkcs7-crl")
resp.append_header( resp.append_header(
"Content-Disposition", "Content-Disposition",
("attachment; filename=%s.crl" % constants.HOSTNAME).encode("ascii")) ("attachment; filename=%s.crl" % const.HOSTNAME).encode("ascii"))
# Convert PEM to DER # Convert PEM to DER
resp.body = x509.load_pem_x509_crl(export_crl(), resp.body = x509.load_pem_x509_crl(export_crl(),
default_backend()).public_bytes(Encoding.DER) default_backend()).public_bytes(Encoding.DER)
@ -29,7 +29,7 @@ class RevocationListResource(object):
resp.set_header("Content-Type", "application/x-pem-file") resp.set_header("Content-Type", "application/x-pem-file")
resp.append_header( resp.append_header(
"Content-Disposition", "Content-Disposition",
("attachment; filename=%s-crl.pem" % constants.HOSTNAME).encode("ascii")) ("attachment; filename=%s-crl.pem" % const.HOSTNAME).encode("ascii"))
resp.body = export_crl() resp.body = export_crl()
elif req.accept.startswith("application/json"): elif req.accept.startswith("application/json"):
resp.set_header("Content-Type", "application/json") resp.set_header("Content-Type", "application/json")

View File

@ -1,14 +1,14 @@
import click import click
import falcon import falcon
import kerberos import kerberos # If this fails pip install kerberos
import logging import logging
import os import os
import re import re
import socket import socket
from certidude.user import User from certidude.user import User
from certidude.firewall import whitelist_subnets from certidude.firewall import whitelist_subnets
from certidude import config, constants from certidude import config, const
logger = logging.getLogger("api") logger = logging.getLogger("api")
@ -23,32 +23,34 @@ if "kerberos" in config.AUTHENTICATION_BACKENDS:
exit(248) exit(248)
try: try:
principal = kerberos.getServerPrincipalDetails("HTTP", constants.FQDN) principal = kerberos.getServerPrincipalDetails("HTTP", const.FQDN)
except kerberos.KrbError as exc: except kerberos.KrbError as exc:
click.echo("Failed to initialize Kerberos, service principal is HTTP/%s, reason: %s" % ( click.echo("Failed to initialize Kerberos, service principal is HTTP/%s, reason: %s" % (
constants.FQDN, exc), err=True) const.FQDN, exc), err=True)
exit(249) exit(249)
else: else:
click.echo("Kerberos enabled, service principal is HTTP/%s" % constants.FQDN) click.echo("Kerberos enabled, service principal is HTTP/%s" % const.FQDN)
def authenticate(optional=False): def authenticate(optional=False):
def wrapper(func): def wrapper(func):
def kerberos_authenticate(resource, req, resp, *args, **kwargs): def kerberos_authenticate(resource, req, resp, *args, **kwargs):
if optional and not req.get_param_as_bool("authenticate"): # Try pre-emptive authentication
return func(resource, req, resp, *args, **kwargs)
if not req.auth: if not req.auth:
resp.append_header("WWW-Authenticate", "Negotiate") if optional:
req.context["user"] = None
return func(resource, req, resp, *args, **kwargs)
logger.debug(u"No Kerberos ticket offered while attempting to access %s from %s", logger.debug(u"No Kerberos ticket offered while attempting to access %s from %s",
req.env["PATH_INFO"], req.context.get("remote_addr")) req.env["PATH_INFO"], req.context.get("remote_addr"))
raise falcon.HTTPUnauthorized("Unauthorized", raise falcon.HTTPUnauthorized("Unauthorized",
"No Kerberos ticket offered, are you sure you've logged in with domain user account?") "No Kerberos ticket offered, are you sure you've logged in with domain user account?",
["Negotiate"])
token = ''.join(req.auth.split()[1:]) token = ''.join(req.auth.split()[1:])
try: try:
result, context = kerberos.authGSSServerInit("HTTP@" + constants.FQDN) result, context = kerberos.authGSSServerInit("HTTP@" + const.FQDN)
except kerberos.GSSError as ex: except kerberos.GSSError as ex:
# TODO: logger.error # TODO: logger.error
raise falcon.HTTPForbidden("Forbidden", raise falcon.HTTPForbidden("Forbidden",
@ -59,22 +61,30 @@ def authenticate(optional=False):
except kerberos.GSSError as ex: except kerberos.GSSError as ex:
kerberos.authGSSServerClean(context) kerberos.authGSSServerClean(context)
logger.error(u"Kerberos authentication failed from %s. " logger.error(u"Kerberos authentication failed from %s. "
"Bad credentials: %s (%d)", "GSSAPI error: %s (%d), perhaps the clock skew it too large?",
req.context.get("remote_addr"), req.context.get("remote_addr"),
ex.args[0][0], ex.args[0][1]) ex.args[0][0], ex.args[0][1])
raise falcon.HTTPForbidden("Forbidden", raise falcon.HTTPForbidden("Forbidden",
"Bad credentials: %s (%d)" % (ex.args[0][0], ex.args[0][1])) "GSSAPI error: %s (%d), perhaps the clock skew it too large?" % (ex.args[0][0], ex.args[0][1]))
except kerberos.KrbError as ex: except kerberos.KrbError as ex:
kerberos.authGSSServerClean(context) kerberos.authGSSServerClean(context)
logger.error(u"Kerberos authentication failed from %s. " logger.error(u"Kerberos authentication failed from %s. "
"Bad credentials: %s (%d)", "Kerberos error: %s (%d)",
req.context.get("remote_addr"), req.context.get("remote_addr"),
ex.args[0][0], ex.args[0][1]) ex.args[0][0], ex.args[0][1])
raise falcon.HTTPForbidden("Forbidden", raise falcon.HTTPForbidden("Forbidden",
"Bad credentials: %s" % (ex.args[0],)) "Kerberos error: %s" % (ex.args[0],))
user = kerberos.authGSSServerUserName(context) user = kerberos.authGSSServerUserName(context)
req.context["user"] = User.objects.get(user)
if "$@" in user and optional:
# Extract machine hostname
# TODO: Assert LDAP group membership
req.context["machine"], _ = user.lower().split("$@", 1)
req.context["user"] = None
else:
# Attempt to look up real user
req.context["user"] = User.objects.get(user)
try: try:
kerberos.authGSSServerClean(context) kerberos.authGSSServerClean(context)
@ -114,7 +124,7 @@ def authenticate(optional=False):
if not req.auth: if not req.auth:
resp.append_header("WWW-Authenticate", "Basic") resp.append_header("WWW-Authenticate", "Basic")
raise falcon.HTTPUnauthorized("Forbidden", raise falcon.HTTPUnauthorized("Forbidden",
"Please authenticate with %s domain account or supply UPN" % constants.DOMAIN) "Please authenticate with %s domain account or supply UPN" % const.DOMAIN)
if not req.auth.startswith("Basic "): if not req.auth.startswith("Basic "):
raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth) raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth)
@ -128,13 +138,13 @@ def authenticate(optional=False):
conn = ldap.initialize(server) conn = ldap.initialize(server)
conn.set_option(ldap.OPT_REFERRALS, 0) conn.set_option(ldap.OPT_REFERRALS, 0)
try: try:
conn.simple_bind_s(user if "@" in user else "%s@%s" % (user, constants.DOMAIN), passwd) conn.simple_bind_s(user if "@" in user else "%s@%s" % (user, const.DOMAIN), passwd)
except ldap.LDAPError, e: except ldap.LDAPError, e:
resp.append_header("WWW-Authenticate", "Basic") resp.append_header("WWW-Authenticate", "Basic")
logger.critical(u"LDAP bind authentication failed for user %s from %s", logger.critical(u"LDAP bind authentication failed for user %s from %s",
repr(user), req.context.get("remote_addr")) repr(user), req.context.get("remote_addr"))
raise falcon.HTTPUnauthorized("Forbidden", raise falcon.HTTPUnauthorized("Forbidden",
"Please authenticate with %s domain account or supply UPN" % constants.DOMAIN) "Please authenticate with %s domain account or supply UPN" % const.DOMAIN)
req.context["ldap_conn"] = conn req.context["ldap_conn"] = conn
break break
@ -154,8 +164,7 @@ def authenticate(optional=False):
return func(resource, req, resp, *args, **kwargs) return func(resource, req, resp, *args, **kwargs)
if not req.auth: if not req.auth:
resp.append_header("WWW-Authenticate", "Basic") raise falcon.HTTPUnauthorized("Forbidden", "Please authenticate", ("Basic",))
raise falcon.HTTPUnauthorized("Forbidden", "Please authenticate")
if not req.auth.startswith("Basic "): if not req.auth.startswith("Basic "):
raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth) raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth)
@ -168,7 +177,7 @@ def authenticate(optional=False):
if not simplepam.authenticate(user, passwd, "sshd"): if not simplepam.authenticate(user, passwd, "sshd"):
logger.critical(u"Basic authentication failed for user %s from %s", logger.critical(u"Basic authentication failed for user %s from %s",
repr(user), req.context.get("remote_addr")) repr(user), req.context.get("remote_addr"))
raise falcon.HTTPUnauthorized("Forbidden", "Invalid password") raise falcon.HTTPForbidden("Forbidden", "Invalid password")
req.context["user"] = User.objects.get(user) req.context["user"] = User.objects.get(user)
return func(resource, req, resp, *args, **kwargs) return func(resource, req, resp, *args, **kwargs)

View File

@ -1,13 +1,18 @@
import click import click
import os import os
import random
import re import re
import socket
import requests import requests
from OpenSSL import crypto import socket
from certidude import config, push, mailer from datetime import datetime, timedelta
from cryptography.hazmat.backends import default_backend
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtensionOID, AuthorityInformationAccessOID
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import hashes, serialization
from certidude import config, push, mailer, const
from certidude.wrappers import Certificate, Request from certidude.wrappers import Certificate, Request
from certidude.signer import raw_sign
from certidude import errors from certidude import errors
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])(@(([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]))?$" 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])(@(([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]))?$"
@ -46,7 +51,7 @@ def publish_certificate(func):
headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"}) headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"})
# For deleting request in the web view, use pubkey modulo # For deleting request in the web view, use pubkey modulo
push.publish("request-signed", csr.common_name) push.publish("request-signed", cert.common_name)
return cert return cert
return wrapped return wrapped
@ -73,8 +78,16 @@ def store_request(buf, overwrite=False):
""" """
Store CSR for later processing Store CSR for later processing
""" """
request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf)
common_name = request.get_subject().CN if not buf: return # No certificate supplied
csr = x509.load_pem_x509_csr(buf, backend=default_backend())
for name in csr.subject:
if name.oid == NameOID.COMMON_NAME:
common_name = name.value
break
else:
raise ValueError("No common name in %s" % csr.subject)
request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
if not re.match(RE_HOSTNAME, common_name): if not re.match(RE_HOSTNAME, common_name):
@ -98,7 +111,7 @@ def store_request(buf, overwrite=False):
def signer_exec(cmd, *bits): def signer_exec(cmd, *bits):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(config.SIGNER_SOCKET_PATH) sock.connect(const.SIGNER_SOCKET_PATH)
sock.send(cmd.encode("ascii")) sock.send(cmd.encode("ascii"))
sock.send(b"\n") sock.send(b"\n")
for bit in bits: for bit in bits:
@ -141,7 +154,7 @@ def list_revoked(directory=config.REVOKED_DIR):
def export_crl(): def export_crl():
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(config.SIGNER_SOCKET_PATH) sock.connect(const.SIGNER_SOCKET_PATH)
sock.send(b"export-crl\n") sock.send(b"export-crl\n")
for filename in os.listdir(config.REVOKED_DIR): for filename in os.listdir(config.REVOKED_DIR):
if not filename.endswith(".pem"): if not filename.endswith(".pem"):
@ -177,32 +190,49 @@ def generate_pkcs12_bundle(common_name, key_size=4096, owner=None):
""" """
Generate private key, sign certificate and return PKCS#12 bundle Generate private key, sign certificate and return PKCS#12 bundle
""" """
# Construct private key # Construct private key
click.echo("Generating %d-bit RSA key..." % key_size) click.echo("Generating %d-bit RSA key..." % key_size)
key = crypto.PKey()
key.generate_key(crypto.TYPE_RSA, key_size)
# Construct CSR key = rsa.generate_private_key(
csr = crypto.X509Req() public_exponent=65537,
csr.set_version(2) # Corresponds to X.509v3 key_size=4096,
csr.set_pubkey(key) backend=default_backend()
csr.get_subject().CN = common_name )
csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([
x509.NameAttribute(k, v) for k, v in (
(NameOID.COMMON_NAME, common_name),
(NameOID.GIVEN_NAME, owner and owner.given_name),
(NameOID.SURNAME, owner and owner.surname),
) if v
]))
if owner: if owner:
if owner.given_name: click.echo("Setting e-mail to: %s" % owner.mail)
csr.get_subject().GN = owner.given_name csr = csr.add_extension(
if owner.surname: x509.SubjectAlternativeName([
csr.get_subject().SN = owner.surname x509.RFC822Name(owner.mail)
csr.add_extensions([ ]),
crypto.X509Extension("subjectAltName", True, "email:%s" % owner.mail.encode("ascii"))]) critical=False)
buf = crypto.dump_certificate_request(crypto.FILETYPE_PEM, csr)
# Sign CSR # Sign CSR
cert = sign(Request(buf), overwrite=True) cert = sign(Request(
csr.sign(key, hashes.SHA512(), default_backend()).public_bytes(serialization.Encoding.PEM)), overwrite=True)
# Generate P12 # Generate P12, currently supported only by PyOpenSSL
from OpenSSL import crypto
p12 = crypto.PKCS12() p12 = crypto.PKCS12()
p12.set_privatekey( key ) p12.set_privatekey(
crypto.load_privatekey(
crypto.FILETYPE_PEM,
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
)
)
p12.set_certificate( cert._obj ) p12.set_certificate( cert._obj )
p12.set_ca_certificates([certificate._obj]) p12.set_ca_certificates([certificate._obj])
return p12.export(), cert return p12.export(), cert
@ -213,7 +243,6 @@ def sign(req, overwrite=False, delete=True):
""" """
Sign certificate signing request via signer process Sign certificate signing request via signer process
""" """
cert_path = os.path.join(config.SIGNED_DIR, req.common_name + ".pem") cert_path = os.path.join(config.SIGNED_DIR, req.common_name + ".pem")
# Move existing certificate if necessary # Move existing certificate if necessary
@ -236,35 +265,95 @@ def sign(req, overwrite=False, delete=True):
@publish_certificate @publish_certificate
def sign2(request, overwrite=False, delete=True, lifetime=None): def sign2(request, private_key, authority_certificate, overwrite=False, delete=True, lifetime=None):
""" """
Sign directly using private key, this is usually done by root. Sign directly using private key, this is usually done by root.
Basic constraints and certificate lifetime are copied from config, Basic constraints and certificate lifetime are copied from config,
lifetime may be overridden on the command line, lifetime may be overridden on the command line,
other extensions are copied as is. 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") certificate_path = os.path.join(config.SIGNED_DIR, request.common_name + ".pem")
if os.path.exists(path): if os.path.exists(certificate_path):
if overwrite: if overwrite:
revoke_certificate(request.common_name) revoke_certificate(request.common_name)
else: else:
raise EnvironmentError("File %s already exists!" % path) raise errors.DuplicateCommonNameError("Valid certificate with common name %s already exists" % request.common_name)
buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) now = datetime.utcnow()
with open(path + ".part", "wb") as fh: request_path = os.path.join(config.REQUESTS_DIR, request.common_name + ".pem")
request = x509.load_pem_x509_csr(open(request_path).read(), default_backend())
cert = x509.CertificateBuilder(
).subject_name(x509.Name([n for n in request.subject])
).serial_number(random.randint(
0x1000000000000000000000000000000000000000,
0xffffffffffffffffffffffffffffffffffffffff)
).issuer_name(authority_certificate.issuer
).public_key(request.public_key()
).not_valid_before(now - timedelta(hours=1)
).not_valid_after(now + timedelta(days=config.CERTIFICATE_LIFETIME)
).add_extension(x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False), critical=True
).add_extension(
x509.SubjectKeyIdentifier.from_public_key(request.public_key()),
critical=False
).add_extension(
x509.AuthorityInformationAccess([
x509.AccessDescription(
AuthorityInformationAccessOID.CA_ISSUERS,
x509.UniformResourceIdentifier(
config.CERTIFICATE_AUTHORITY_URL)
)
]),
critical=False
).add_extension(
x509.CRLDistributionPoints([
x509.DistributionPoint(
full_name=[
x509.UniformResourceIdentifier(
config.CERTIFICATE_CRL_URL)],
relative_name=None,
crl_issuer=None,
reasons=None)
]),
critical=False
).add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(
authority_certificate.public_key()),
critical=False
)
# Append subject alternative name, extended key usage flags etc
for extension in request.extensions:
if extension.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME:
click.echo("Appending subject alt name extension: %s" % extension)
cert = cert.add_extension(x509.SubjectAlternativeName(extension.value),
critical=extension.critical)
if extension.oid == ExtensionOID.EXTENDED_KEY_USAGE:
click.echo("Appending extended key usage flags extension: %s" % extension)
cert = cert.add_extension(x509.ExtendedKeyUsage(extension.value),
critical=extension.critical)
cert = cert.sign(private_key, hashes.SHA512(), default_backend())
buf = cert.public_bytes(serialization.Encoding.PEM)
with open(certificate_path + ".part", "wb") as fh:
fh.write(buf) fh.write(buf)
os.rename(path + ".part", path) os.rename(certificate_path + ".part", certificate_path)
click.echo("Wrote certificate to: %s" % path) click.echo("Wrote certificate to: %s" % certificate_path)
if delete: if delete:
os.unlink(request.path) os.unlink(request_path)
click.echo("Deleted request: %s" % request.path) click.echo("Deleted request: %s" % request_path)
return Certificate(open(path)) return Certificate(open(certificate_path))

File diff suppressed because it is too large Load Diff

View File

@ -5,11 +5,13 @@ import configparser
import ipaddress import ipaddress
import os import os
import string import string
import const
from random import choice from random import choice
from urllib.parse import urlparse
cp = configparser.ConfigParser() # Options that are parsed from config file are fetched here
cp.readfp(codecs.open("/etc/certidude/server.conf", "r", "utf8"))
cp = configparser.RawConfigParser()
cp.readfp(codecs.open(const.CONFIG_PATH, "r", "utf8"))
AUTHENTICATION_BACKENDS = set([j for j in AUTHENTICATION_BACKENDS = set([j for j in
cp.get("authentication", "backends").split(" ") if j]) # kerberos, pam, ldap cp.get("authentication", "backends").split(" ") if j]) # kerberos, pam, ldap
@ -28,9 +30,6 @@ AUTOSIGN_SUBNETS = set([ipaddress.ip_network(j) for j in
REQUEST_SUBNETS = set([ipaddress.ip_network(j) for j in REQUEST_SUBNETS = set([ipaddress.ip_network(j) for j in
cp.get("authorization", "request subnets").split(" ") if j]).union(AUTOSIGN_SUBNETS) 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_DIR = "/var/lib/certidude"
AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path") AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path")
AUTHORITY_CERTIFICATE_PATH = cp.get("authority", "certificate path") AUTHORITY_CERTIFICATE_PATH = cp.get("authority", "certificate path")
@ -49,16 +48,13 @@ USER_MULTIPLE_CERTIFICATES = {
CERTIFICATE_BASIC_CONSTRAINTS = "CA:FALSE" CERTIFICATE_BASIC_CONSTRAINTS = "CA:FALSE"
CERTIFICATE_KEY_USAGE_FLAGS = "digitalSignature,keyEncipherment" CERTIFICATE_KEY_USAGE_FLAGS = "digitalSignature,keyEncipherment"
CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS = "clientAuth" CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS = "clientAuth"
CERTIFICATE_LIFETIME = int(cp.get("signature", "certificate lifetime")) CERTIFICATE_LIFETIME = cp.getint("signature", "certificate lifetime")
CERTIFICATE_AUTHORITY_URL = cp.get("signature", "certificate url") CERTIFICATE_AUTHORITY_URL = cp.get("signature", "certificate url")
CERTIFICATE_CRL_URL = cp.get("signature", "revoked url") CERTIFICATE_CRL_URL = cp.get("signature", "revoked url")
REVOCATION_LIST_LIFETIME = int(cp.get("signature", "revocation list lifetime")) REVOCATION_LIST_LIFETIME = cp.getint("signature", "revocation list lifetime")
PUSH_TOKEN = "".join([choice(string.ascii_letters + string.digits) for j in range(0,32)])
PUSH_TOKEN = "ca"
PUSH_TOKEN = cp.get("push", "token")
PUSH_EVENT_SOURCE = cp.get("push", "event source") PUSH_EVENT_SOURCE = cp.get("push", "event source")
PUSH_LONG_POLL = cp.get("push", "long poll") PUSH_LONG_POLL = cp.get("push", "long poll")
PUSH_PUBLISH = cp.get("push", "publish") PUSH_PUBLISH = cp.get("push", "publish")

24
certidude/const.py Normal file
View File

@ -0,0 +1,24 @@
import click
import os
import socket
CONFIG_DIR = os.path.expanduser("~/.certidude") if os.getuid() else "/etc/certidude"
CONFIG_PATH = os.path.join(CONFIG_DIR, "server.conf")
CLIENT_CONFIG_PATH = os.path.join(CONFIG_DIR, "client.conf")
SERVICES_CONFIG_PATH = os.path.join(CONFIG_DIR, "services.conf")
SERVER_LOG_PATH = os.path.join(CONFIG_DIR, "server.log") if os.getuid() else "/var/log/certidude-server.log"
SIGNER_SOCKET_PATH = os.path.join(CONFIG_DIR, "signer.sock") if os.getuid() else "/run/certidude/signer.sock"
SIGNER_PID_PATH = os.path.join(CONFIG_DIR, "signer.pid") if os.getuid() else "/run/certidude/signer.pid"
SIGNER_LOG_PATH = os.path.join(CONFIG_DIR, "signer.log") if os.getuid() else "/var/log/certidude-signer.log"
FQDN = socket.getaddrinfo(socket.gethostname(), 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME)[0][3]
if "." in FQDN:
HOSTNAME, DOMAIN = FQDN.split(".", 1)
else:
HOSTNAME, DOMAIN = FQDN, "local"
click.echo("Unable to determine domain of this computer, falling back to local")
EXTENSION_WHITELIST = set(["subjectAltName"])

View File

@ -1,13 +0,0 @@
import click
import socket
FQDN = socket.getaddrinfo(socket.gethostname(), 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME)[0][3]
if "." in FQDN:
HOSTNAME, DOMAIN = FQDN.split(".", 1)
else:
HOSTNAME, DOMAIN = FQDN, "local"
click.echo("Unable to determine domain of this computer, falling back to local")
EXTENSION_WHITELIST = set(["subjectAltName"])

View File

@ -8,7 +8,7 @@ from datetime import date, time, datetime
from OpenSSL import crypto from OpenSSL import crypto
from certidude.auth import User from certidude.auth import User
from certidude.wrappers import Request, Certificate from certidude.wrappers import Request, Certificate
from urllib.parse import urlparse from urlparse import urlparse
logger = logging.getLogger("api") logger = logging.getLogger("api")
@ -23,15 +23,21 @@ def csrf_protection(func):
# For everything else assert referrer # For everything else assert referrer
referrer = req.headers.get("REFERER") referrer = req.headers.get("REFERER")
if referrer: if referrer:
scheme, netloc, path, params, query, fragment = urlparse(referrer) scheme, netloc, path, params, query, fragment = urlparse(referrer)
if netloc == req.host: if ":" in netloc:
host, port = netloc.split(":", 1)
else:
host, port = netloc, None
if host == req.host:
return func(self, req, resp, *args, **kwargs) return func(self, req, resp, *args, **kwargs)
# Kaboom! # Kaboom!
logger.warning(u"Prevented clickbait from '%s' with user agent '%s'", logger.warning(u"Prevented clickbait from '%s' with user agent '%s'",
referrer or "-", req.user_agent) referrer or "-", req.user_agent)
raise falcon.HTTPUnauthorized("Forbidden", raise falcon.HTTPForbidden("Forbidden",
"No suitable UA or referrer provided, cross-site scripting disabled") "No suitable UA or referrer provided, cross-site scripting disabled")
return wrapped return wrapped

View File

@ -6,14 +6,19 @@ import subprocess
import tempfile import tempfile
from certidude import errors from certidude import errors
from certidude.wrappers import Certificate, Request from certidude.wrappers import Certificate, Request
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, AuthorityInformationAccessOID
from configparser import ConfigParser from configparser import ConfigParser
from OpenSSL import crypto from OpenSSL import crypto
def certidude_request_certificate(server, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, org_unit=None, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None, ip_address=None, dns=None, bundle=False): def certidude_request_certificate(server, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, extended_key_usage_flags=None, org_unit=None, email_address=None, given_name=None, surname=None, autosign=False, wait=False, ip_address=None, dns=None, bundle=False, insecure=False):
""" """
Exchange CSR for certificate using Certidude HTTP API server Exchange CSR for certificate using Certidude HTTP API server
""" """
# Set up URL-s # Set up URL-s
request_params = set() request_params = set()
if autosign: if autosign:
@ -22,9 +27,10 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
request_params.add("wait=forever") request_params.add("wait=forever")
# Expand ca.example.com # Expand ca.example.com
authority_url = "http://%s/api/certificate/" % server scheme = "http" if insecure else "https" # TODO: Expose in CLI
request_url = "http://%s/api/request/" % server authority_url = "%s://%s/api/certificate/" % (scheme, server)
revoked_url = "http://%s/api/revoked/" % server request_url = "%s://%s/api/request/" % (scheme, server)
revoked_url = "%s://%s/api/revoked/" % (scheme, server)
if request_params: if request_params:
request_url = request_url + "?" + "&".join(request_params) request_url = request_url + "?" + "&".join(request_params)
@ -103,54 +109,62 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
# Construct private key # Construct private key
click.echo("Generating 4096-bit RSA key...") click.echo("Generating 4096-bit RSA key...")
key = crypto.PKey() key = rsa.generate_private_key(
key.generate_key(crypto.TYPE_RSA, 4096) public_exponent=65537,
key_size=4096,
backend=default_backend()
)
# Dump private key # Dump private key
key_partial = tempfile.mktemp(prefix=key_path + ".part") key_partial = tempfile.mktemp(prefix=key_path + ".part")
os.umask(0o077) os.umask(0o077)
with open(key_partial, "wb") as fh: with open(key_partial, "wb") as fh:
fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) fh.write(key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
))
# Construct CSR # Set subject name attributes
csr = crypto.X509Req() names = [x509.NameAttribute(NameOID.COMMON_NAME, common_name.decode("utf-8"))]
csr.set_version(2) # Corresponds to X.509v3
csr.set_pubkey(key)
csr.get_subject().CN = common_name
request = Request(csr)
# Set subject attributes
if given_name: if given_name:
request.given_name = given_name names.append(x509.NameAttribute(NameOID.GIVEN_NAME, given_name.decode("utf-8")))
if surname: if surname:
request.surname = surname names.append(x509.NameAttribute(NameOID.SURNAME, surname.decode("utf-8")))
if org_unit: if org_unit:
request.organizational_unit = org_unit names.append(x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT, org_unit.decode("utf-8")))
# Collect subject alternative names # Collect subject alternative names
subject_alt_name = set() subject_alt_names = set()
if email_address: if email_address:
subject_alt_name.add("email:%s" % email_address) subject_alt_names.add(x509.RFC822Name(email_address))
if ip_address: if ip_address:
subject_alt_name.add("IP:%s" % ip_address) subject_alt_names.add("IP:%s" % ip_address)
if dns: if dns:
subject_alt_name.add("DNS:%s" % dns) subject_alt_names.add(x509.DNSName(dns))
# Set extensions
extensions = []
if key_usage:
extensions.append(("keyUsage", key_usage, True))
if extended_key_usage:
extensions.append(("extendedKeyUsage", extended_key_usage, False))
if subject_alt_name:
extensions.append(("subjectAltName", ", ".join(subject_alt_name), False))
request.set_extensions(extensions)
# Dump CSR # Construct CSR
csr = x509.CertificateSigningRequestBuilder(
).subject_name(x509.Name(names))
if extended_key_usage_flags:
click.echo("Adding extended key usage extension: %s" % extended_key_usage_flags)
csr = csr.add_extension(x509.ExtendedKeyUsage(
extended_key_usage_flags), critical=True)
if subject_alt_names:
click.echo("Adding subject alternative name extension: %s" % subject_alt_names)
csr = csr.add_extension(
x509.SubjectAlternativeName(subject_alt_names),
critical=False)
# Sign & dump CSR
os.umask(0o022) os.umask(0o022)
with open(request_path + ".part", "w") as fh: with open(request_path + ".part", "wb") as f:
fh.write(request.dump()) f.write(csr.sign(key, hashes.SHA256(), default_backend()).public_bytes(serialization.Encoding.PEM))
click.echo("Writing private key to: %s" % key_path) click.echo("Writing private key to: %s" % key_path)
os.rename(key_partial, key_path) os.rename(key_partial, key_path)
@ -160,37 +174,36 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
# We have CSR now, save the paths to client.conf so we could: # We have CSR now, save the paths to client.conf so we could:
# Update CRL, renew certificate, maybe something extra? # Update CRL, renew certificate, maybe something extra?
if not os.path.exists("/etc/certidude"):
os.makedirs("/etc/certidude")
clients = ConfigParser()
if os.path.exists("/etc/certidude/client.conf"):
clients.readfp(open("/etc/certidude/client.conf"))
if clients.has_section(server):
click.echo("Section %s already exists in /etc/certidude/client.conf, not reconfiguring" % server)
else:
clients.add_section(server)
clients.set(server, "trigger", "interface up")
clients.set(server, "key_path", key_path)
clients.set(server, "request_path", request_path)
clients.set(server, "certificate_path", certificate_path)
clients.set(server, "authority_path", authority_path)
clients.set(server, "key_path", key_path)
clients.set(server, "revocations_path", revocations_path)
clients.write(open("/etc/certidude/client.conf", "w"))
click.echo("Section %s added to /etc/certidude/client.conf" % repr(server))
if os.path.exists(certificate_path): if os.path.exists(certificate_path):
click.echo("Found certificate: %s" % certificate_path) click.echo("Found certificate: %s" % certificate_path)
# TODO: Check certificate validity, download CRL? # TODO: Check certificate validity, download CRL?
return return
# If machine is joined to domain attempt to present machine credentials for authentication
if os.path.exists("/etc/krb5.keytab") and os.path.exists("/etc/samba/smb.conf"):
# Get HTTP service ticket
from configparser import ConfigParser
cp = ConfigParser(delimiters=("="))
cp.readfp(open("/etc/samba/smb.conf"))
name = cp.get("global", "netbios name")
realm = cp.get("global", "realm")
os.environ["KRB5CCNAME"]="/tmp/ca.ticket"
os.system("kinit -k %s$ -S HTTP/%s@%s -t /etc/krb5.keytab" % (name, server, realm))
from requests_kerberos import HTTPKerberosAuth, OPTIONAL
auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL, force_preemptive=True)
else:
auth = None
click.echo("Submitting to %s, waiting for response..." % request_url) click.echo("Submitting to %s, waiting for response..." % request_url)
submission = requests.post(request_url, submission = requests.post(request_url,
auth=auth,
data=open(request_path), data=open(request_path),
headers={"Content-Type": "application/pkcs10", "Accept": "application/x-x509-user-cert,application/x-pem-file"}) headers={"Content-Type": "application/pkcs10", "Accept": "application/x-x509-user-cert,application/x-pem-file"})
# Destroy service ticket
if os.path.exists("/tmp/ca.ticket"):
os.system("kdestroy")
if submission.status_code == requests.codes.ok: if submission.status_code == requests.codes.ok:
pass pass
if submission.status_code == requests.codes.accepted: if submission.status_code == requests.codes.accepted:

View File

@ -8,7 +8,7 @@ from jinja2 import Environment, PackageLoader
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from urllib.parse import urlparse from urlparse import urlparse
env = Environment(loader=PackageLoader("certidude", "templates/mail")) env = Environment(loader=PackageLoader("certidude", "templates/mail"))

View File

@ -23,12 +23,17 @@ def publish(event_type, event_data):
url, url,
data=event_data, data=event_data,
headers={"X-EventSource-Event": event_type, "User-Agent": "Certidude API"}) headers={"X-EventSource-Event": event_type, "User-Agent": "Certidude API"})
if notification.status_code != requests.codes.created: if notification.status_code == requests.codes.created:
click.echo("Failed to submit event to push server, server responded %d, expected %d" % ( pass # Sent to client
notification.status_code, requests.codes.created)) elif notification.status_code == requests.codes.accepted:
pass # Buffered in nchan
else:
click.echo("Failed to submit event to push server, server responded %d" % (
notification.status_code))
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
click.echo("Failed to submit event to push server, connection error") click.echo("Failed to submit event to push server, connection error")
class PushLogHandler(logging.Handler): class PushLogHandler(logging.Handler):
""" """
To be used with Python log handling framework for publishing log entries To be used with Python log handling framework for publishing log entries

View File

@ -3,7 +3,7 @@
import click import click
import re import re
import os import os
from urllib.parse import urlparse from urlparse import urlparse
SCRIPTS = {} SCRIPTS = {}

View File

@ -1,14 +1,11 @@
import random import random
import pwd
import socket import socket
import os import os
import asyncore import asyncore
import asynchat import asynchat
from certidude import constants, config from certidude import const, config
from OpenSSL import crypto
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
@ -20,99 +17,6 @@ import random
DN_WHITELIST = NameOID.COMMON_NAME, NameOID.GIVEN_NAME, NameOID.SURNAME, \ DN_WHITELIST = NameOID.COMMON_NAME, NameOID.GIVEN_NAME, NameOID.SURNAME, \
NameOID.EMAIL_ADDRESS NameOID.EMAIL_ADDRESS
SERIAL_MIN = 0x1000000000000000000000000000000000000000
SERIAL_MAX = 0xffffffffffffffffffffffffffffffffffffffff
def raw_sign(private_key, ca_cert, request, basic_constraints, lifetime, key_usage=None, extended_key_usage=None):
"""
Sign certificate signing request directly with private key assuming it's readable by the process
"""
# Initialize X.509 certificate object
cert = crypto.X509()
cert.set_version(2) # This corresponds to X.509v3
# Set public key
cert.set_pubkey(request.get_pubkey())
# Set issuer
cert.set_issuer(ca_cert.get_subject())
# Set SKID and AKID extensions
cert.add_extensions([
crypto.X509Extension(
b"subjectKeyIdentifier",
False,
b"hash",
subject = cert),
crypto.X509Extension(
b"authorityKeyIdentifier",
False,
b"keyid:always",
issuer = ca_cert),
crypto.X509Extension(
b"authorityInfoAccess",
False,
("caIssuers;URI: %s" % config.CERTIFICATE_AUTHORITY_URL).encode("ascii")),
crypto.X509Extension(
b"crlDistributionPoints",
False,
("URI: %s" % config.CERTIFICATE_CRL_URL).encode("ascii"))
])
# Copy attributes from request
cert.get_subject().CN = request.get_subject().CN
if request.get_subject().SN:
cert.get_subject().SN = request.get_subject().SN
if request.get_subject().GN:
cert.get_subject().GN = request.get_subject().GN
if request.get_subject().OU:
cert.get_subject().OU = req_subject.OU
# Copy e-mail, key usage, extended key from request
for extension in request.get_extensions():
cert.add_extensions([extension])
# TODO: Set keyUsage and extendedKeyUsage defaults if none has been provided in the request
# Override basic constraints if nececssary
if basic_constraints:
cert.add_extensions([
crypto.X509Extension(
b"basicConstraints",
True,
basic_constraints.encode("ascii"))])
if key_usage:
try:
cert.add_extensions([
crypto.X509Extension(
b"keyUsage",
True,
key_usage.encode("ascii"))])
except crypto.Error:
raise ValueError("Invalid value '%s' for keyUsage attribute" % key_usage)
if extended_key_usage:
cert.add_extensions([
crypto.X509Extension(
b"extendedKeyUsage",
True,
extended_key_usage.encode("ascii"))])
# Set certificate lifetime
cert.gmtime_adj_notBefore(-3600)
cert.gmtime_adj_notAfter(lifetime * 24 * 60 * 60)
# Generate random serial
cert.set_serial_number(random.randint(SERIAL_MIN, SERIAL_MAX))
cert.sign(private_key, 'sha512')
return cert
class SignHandler(asynchat.async_chat): class SignHandler(asynchat.async_chat):
def __init__(self, sock, server): def __init__(self, sock, server):
asynchat.async_chat.__init__(self, sock=sock) asynchat.async_chat.__init__(self, sock=sock)
@ -162,7 +66,9 @@ class SignHandler(asynchat.async_chat):
cert = x509.CertificateBuilder( cert = x509.CertificateBuilder(
).subject_name(subject ).subject_name(subject
).serial_number(random.randint(SERIAL_MIN, SERIAL_MAX) ).serial_number(random.randint(
0x1000000000000000000000000000000000000000,
0xffffffffffffffffffffffffffffffffffffffff)
).issuer_name(self.server.certificate.issuer ).issuer_name(self.server.certificate.issuer
).public_key(request.public_key() ).public_key(request.public_key()
).not_valid_before(now - timedelta(hours=1) ).not_valid_before(now - timedelta(hours=1)
@ -224,32 +130,31 @@ class SignHandler(asynchat.async_chat):
def collect_incoming_data(self, data): def collect_incoming_data(self, data):
self.buffer.append(data) self.buffer.append(data)
import signal
import click
class SignServer(asyncore.dispatcher): class SignServer(asyncore.dispatcher):
def __init__(self): def __init__(self):
asyncore.dispatcher.__init__(self) asyncore.dispatcher.__init__(self)
# Bind to sockets if os.path.exists(const.SIGNER_SOCKET_PATH):
if os.path.exists(config.SIGNER_SOCKET_PATH): os.unlink(const.SIGNER_SOCKET_PATH)
os.unlink(config.SIGNER_SOCKET_PATH)
os.umask(0o007)
self.create_socket(socket.AF_UNIX, socket.SOCK_STREAM) self.create_socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.bind(config.SIGNER_SOCKET_PATH) self.bind(const.SIGNER_SOCKET_PATH)
self.listen(5) self.listen(5)
# Load CA private key and certificate # Load CA private key and certificate
click.echo("Signer reading private key from %s" % config.AUTHORITY_PRIVATE_KEY_PATH)
self.private_key = serialization.load_pem_private_key( self.private_key = serialization.load_pem_private_key(
open(config.AUTHORITY_PRIVATE_KEY_PATH).read(), open(config.AUTHORITY_PRIVATE_KEY_PATH).read(),
password=None, # TODO: Ask password for private key? password=None, # TODO: Ask password for private key?
backend=default_backend()) backend=default_backend())
click.echo("Signer reading certificate from %s" % config.AUTHORITY_CERTIFICATE_PATH)
self.certificate = x509.load_pem_x509_certificate( self.certificate = x509.load_pem_x509_certificate(
open(config.AUTHORITY_CERTIFICATE_PATH).read(), open(config.AUTHORITY_CERTIFICATE_PATH).read(),
backend=default_backend()) backend=default_backend())
# Drop privileges
_, _, uid, gid, gecos, root, shell = pwd.getpwnam("nobody")
os.setgid(gid)
os.setuid(uid)
def handle_accept(self): def handle_accept(self):
pair = self.accept() pair = self.accept()

View File

@ -14,11 +14,10 @@ backends = pam
# The accounts backend specifies how the user's given name, surname and e-mail # The accounts backend specifies how the user's given name, surname and e-mail
# address are looked up. In case of 'posix' basically 'getent passwd' is performed, # address are looked up. In case of 'posix' basically 'getent passwd' is performed,
# in case of 'ldap' a search is performed on LDAP server specified in /etc/ldap/ldap.conf # in case of 'ldap' a search is performed on LDAP server specified in /etc/ldap/ldap.conf
# with Kerberos credential cache initialized at path specified by 'ldap gssapi credential cache' # with Kerberos credential cache initialized at path specified by environment variable KRB5CCNAME
backend = posix backend = posix
;backend = ldap ;backend = ldap
ldap gssapi credential cache = /run/certidude/krb5cc
[authorization] [authorization]
# The authorization backend specifies how the users are authorized. # The authorization backend specifies how the users are authorized.
@ -66,6 +65,7 @@ certificate url = {{ certificate_url }}
revoked url = {{ revoked_url }} revoked url = {{ revoked_url }}
[push] [push]
token = {{ push_token }}
event source = {{ push_server }}/ev/%s event source = {{ push_server }}/ev/%s
long poll = {{ push_server }}/lp/%s long poll = {{ push_server }}/lp/%s
publish = {{ push_server }}/pub?id=%s publish = {{ push_server }}/pub?id=%s
@ -80,7 +80,6 @@ publish = {{ push_server }}/pub?id=%s
;user certificate enrollment = single allowed ;user certificate enrollment = single allowed
user certificate enrollment = multiple allowed user certificate enrollment = multiple allowed
private key path = {{ ca_key }} private key path = {{ ca_key }}
certificate path = {{ ca_crt }} certificate path = {{ ca_crt }}

View File

@ -0,0 +1,5 @@
#!/bin/bash
KRB5CCNAME={{ticket_path}}.part kinit -k {{name}}$ -S ldap/dc1.{{domain}}@{{realm}} -t /etc/krb5.keytab
chown certidude:certidude {{ticket_path}}.part
mv {{ticket_path}}.part {{ticket_path}}

View File

@ -1,8 +1,8 @@
server { server {
listen 80; listen 80;
server_name {{constants.FQDN}}; server_name {{const.FQDN}};
rewrite ^ https://{{constants.FQDN}}$request_uri?; rewrite ^ https://{{const.FQDN}}$request_uri?;
} }
server { server {
@ -10,7 +10,7 @@ server {
add_header X-Frame-Options "DENY"; add_header X-Frame-Options "DENY";
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload"; add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";
listen 443 ssl; listen 443 ssl;
server_name {{constants.FQDN}}; server_name {{const.FQDN}};
client_max_body_size 10G; client_max_body_size 10G;
ssl_certificate {{certificate_path}}; ssl_certificate {{certificate_path}};
ssl_certificate_key {{key_path}}; ssl_certificate_key {{key_path}};

View File

@ -1,8 +1,4 @@
upstream certidude_api {
server unix:///run/uwsgi/app/certidude/socket;
}
server { server {
server_name {{ common_name }}; server_name {{ common_name }};
listen 80 default_server; listen 80 default_server;
@ -11,8 +7,13 @@ server {
root {{static_path}}; root {{static_path}};
location /api/ { location /api/ {
include uwsgi_params; proxy_pass http://127.0.0.1/api/;
uwsgi_pass certidude_api; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
send_timeout 600;
} }
{% if not push_server %} {% if not push_server %}

View File

@ -1,14 +0,0 @@
client
remote {{remote}}
remote-cert-tls server
proto {{proto}}
dev tap
nobind
key {{key_path}}
cert {{certificate_path}}
ca {{authority_path}}
comp-lzo
user nobody
group nogroup
persist-tun
persist-key

View File

@ -1,22 +0,0 @@
mode server
tls-server
proto {{proto}}
port {{port}}
dev tap
local {{local}}
key {{key_path}}
cert {{certificate_path}}
ca {{authority_path}}
crl-verify {{revocations_path}}
dh {{dhparam_path}}
comp-lzo
user nobody
group nogroup
persist-tun
persist-key
ifconfig-pool-persist /tmp/openvpn-leases.txt
ifconfig {{subnet_first}} {{subnet.netmask}}
server-bridge {{subnet_first}} {{subnet.netmask}} {{subnet_second}} {{subnet_last}}
{% for subnet in route %}
push "route {{subnet}}"
{% endfor %}

View File

@ -1,27 +0,0 @@
# /etc/ipsec.conf - strongSwan IPsec configuration file
# left/local = client
# right/remote = gateway
config setup
conn %default
ikelifetime=60m
keylife=20m
rekeymargin=3m
keyingtries=1
keyexchange=ikev2
dpdaction={{dpdaction}}
closeaction=restart
conn client-to-site
auto={{auto}}
left=%defaultroute # Use IP of default route for listening
leftsourceip=%config # Accept server suggested virtual IP as inner address for tunnel
leftcert={{certificate_path}} # Client certificate
leftid={{common_name}} # Client certificate identifier
leftfirewall=yes # Local machine may be behind NAT
right={{remote}} # Gateway IP address
rightid=%any # Allow any common name
rightsubnet=0.0.0.0/0 # Accept all subnets suggested by server

View File

@ -15,7 +15,7 @@ conn %default
keyexchange=ikev2 keyexchange=ikev2
conn site-to-clients conn site-to-clients
auto=add auto=ignore
right=%any # Allow connecting from any IP address right=%any # Allow connecting from any IP address
rightsourceip={{subnet}} # Serve virtual IP-s from this pool rightsourceip={{subnet}} # Serve virtual IP-s from this pool
left={{common_name}} # Gateway IP address left={{common_name}} # Gateway IP address

View File

@ -0,0 +1,15 @@
[Unit]
Description=Certidude server
After=network.target
[Service]
Environment=PYTHON_EGG_CACHE=/tmp/.cache
Environment=KRB5_KTNAME={{kerberos_keytab}}
PIDFile=/run/certidude/server.pid
ExecStart={{ certidude_path }} serve {% if listen} -l {{listen}}{% endif %}{% if port %} -p {{port}}{% endif %}
ExecReload=/bin/kill -s HUP $MAINPID
ExecStop=/bin/kill -s TERM $MAINPID
[Install]
WantedBy=multi-user.target

View File

@ -1,19 +0,0 @@
[uwsgi]
exec-as-root = /usr/local/bin/certidude signer spawn
master = true
processes = 1
vacuum = true
uid = {{username}}
gid = {{username}}
plugins = python27
chdir = /tmp
module = certidude.wsgi
callable = app
chmod-socket = 660
chown-socket = {{username}}:www-data
buffer-size = 32768
env = LANG=C.UTF-8
env = LC_ALL=C.UTF-8
env = KRB5_KTNAME={{kerberos_keytab}}
env = KRB5CCNAME=/run/certidude/krb5cc

View File

@ -5,7 +5,7 @@ import ldap
import ldap.sasl import ldap.sasl
import os import os
import pwd import pwd
from certidude import constants, config from certidude import const, config
class User(object): class User(object):
def __init__(self, username, mail, given_name="", surname=""): def __init__(self, username, mail, given_name="", surname=""):
@ -46,7 +46,7 @@ class PosixUserManager(object):
_, _, _, _, gecos, _, _ = pwd.getpwnam(username) _, _, _, _, gecos, _, _ = pwd.getpwnam(username)
gecos = gecos.decode("utf-8").split(",") gecos = gecos.decode("utf-8").split(",")
full_name = gecos[0] full_name = gecos[0]
mail = username + "@" + constants.DOMAIN mail = username + "@" + const.DOMAIN
if full_name and " " in full_name: if full_name and " " in full_name:
given_name, surname = full_name.split(" ", 1) given_name, surname = full_name.split(" ", 1)
return User(username, mail, given_name, surname) return User(username, mail, given_name, surname)
@ -67,8 +67,8 @@ class DirectoryConnection(object):
def __enter__(self): def __enter__(self):
# TODO: Implement simple bind # TODO: Implement simple bind
if not os.path.exists(config.LDAP_GSSAPI_CRED_CACHE): if not os.path.exists(config.LDAP_GSSAPI_CRED_CACHE):
raise ValueError("Ticket cache not initialized, unable to " raise ValueError("Ticket cache at %s not initialized, unable to "
"authenticate with computer account against LDAP server!") "authenticate with computer account against LDAP server!" % config.LDAP_GSSAPI_CRED_CACHE)
os.environ["KRB5CCNAME"] = config.LDAP_GSSAPI_CRED_CACHE os.environ["KRB5CCNAME"] = config.LDAP_GSSAPI_CRED_CACHE
for server in config.LDAP_SERVERS: for server in config.LDAP_SERVERS:
self.conn = ldap.initialize(server) self.conn = ldap.initialize(server)
@ -106,7 +106,7 @@ class ActiveDirectoryUserManager(object):
else: else:
given_name, surname = cn, "" given_name, surname = cn, ""
mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + constants.DOMAIN,) mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + const.DOMAIN,)
return User(username.decode("utf-8"), mail.decode("utf-8"), return User(username.decode("utf-8"), mail.decode("utf-8"),
given_name.decode("utf-8"), surname.decode("utf-8")) given_name.decode("utf-8"), surname.decode("utf-8"))
raise User.DoesNotExist("User %s does not exist" % username) raise User.DoesNotExist("User %s does not exist" % username)
@ -121,7 +121,7 @@ class ActiveDirectoryUserManager(object):
continue continue
username, = entry.get("sAMAccountName") username, = entry.get("sAMAccountName")
cn, = entry.get("cn") cn, = entry.get("cn")
mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + constants.DOMAIN,) mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + const.DOMAIN,)
if entry.get("givenName") and entry.get("sn"): if entry.get("givenName") and entry.get("sn"):
given_name, = entry.get("givenName") given_name, = entry.get("givenName")
surname, = entry.get("sn") surname, = entry.get("sn")

View File

@ -3,7 +3,7 @@ import hashlib
import re import re
import click import click
import io import io
from certidude import constants from certidude import const
from OpenSSL import crypto from OpenSSL import crypto
from datetime import datetime from datetime import datetime
@ -228,7 +228,7 @@ class Request(CertificateBase):
@property @property
def signable(self): def signable(self):
for key, value, data in self.extensions: for key, value, data in self.extensions:
if key not in constants.EXTENSION_WHITELIST: if key not in const.EXTENSION_WHITELIST:
return False return False
return True return True

View File

@ -1,12 +0,0 @@
"""
certidude.wsgi
~~~~~~~~~~~~~~
Certidude web app factory for WSGI-compatible web servers
"""
import os
from certidude.api import certidude_app
# TODO: set up /run/certidude/api paths and permissions
app = certidude_app()

View File

@ -1,4 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python
from certidude.cli import entry_point from certidude.cli import entry_point

View File

@ -1,21 +1,25 @@
cffi==1.2.1
click==5.1
configparser
cryptography==1.0
falcon==0.3.0
humanize==0.5.1
idna==2.0
ipaddress==1.0.16
ipsecparse==0.1.0
Jinja2==2.8 Jinja2==2.8
Markdown==2.6.5 Markdown==2.6.6
pyasn1==0.1.8 MarkupSafe==0.23
pycrypto==2.6.1 argparse==1.2.1
pykerberos==1.1.8 certifi==2016.2.28
pyOpenSSL==0.15.1 cffi==1.7.0
python-ldap==2.4.10 click==6.6
python-mimeparse==0.1.4 configparser==3.5.0
requests==2.2.1 cryptography==1.4
setproctitle==1.1.9 enum34==1.1.6
simplepam==0.1.5 falcon==1.0.0
six==1.9.0 humanize==0.5.1
ipaddress==1.0.16
Markdown==2.6.6
ndg-httpsclient==0.4.2
pyOpenSSL==16.0.0
pyasn1==0.1.9
pycparser==2.14
python-ldap==2.4.25
python-mimeparse==1.5.2
requests==2.10.0
setproctitle==1.1.10
six==1.10.0
urllib3==1.16
wsgiref==0.1.2

View File

@ -10,7 +10,7 @@ setup(
author_email = "lauri.vosandi@gmail.com", author_email = "lauri.vosandi@gmail.com",
description = "Certidude is a novel X.509 Certificate Authority management tool aiming to support PKCS#11 and in far future WebCrypto.", description = "Certidude is a novel X.509 Certificate Authority management tool aiming to support PKCS#11 and in far future WebCrypto.",
license = "MIT", license = "MIT",
keywords = "falcon http jinja2 x509 pkcs11 webcrypto", keywords = "falcon http jinja2 x509 pkcs11 webcrypto kerberos ldap",
url = "http://github.com/laurivosandi/certidude", url = "http://github.com/laurivosandi/certidude",
packages=[ packages=[
"certidude", "certidude",
@ -23,13 +23,9 @@ setup(
"falcon", "falcon",
"jinja2", "jinja2",
"pyopenssl", "pyopenssl",
"pycountry",
"humanize", "humanize",
"pycrypto",
"cryptography", "cryptography",
"markupsafe", "markupsafe",
"ldap3",
"pykerberos",
], ],
scripts=[ scripts=[
"misc/certidude" "misc/certidude"