* Remove PyOpenSSL based wrapper classes
* Remove unused API calls
* Add certificate renewal via X-Renewal-Signature header
* Remove (extended) key usage handling
* Clean up OpenVPN and nginx server setup code
* Use UDP port 51900 for OpenVPN by default
* Add basic auth fallback for iOS in addition to Android
* Reduce complexity
This commit is contained in:
Lauri Võsandi 2017-03-13 11:42:58 +00:00
parent d1aa2f2073
commit 06010ceaf3
30 changed files with 757 additions and 952 deletions

View File

@ -93,13 +93,9 @@ TODO
---- ----
* `OCSP <https://tools.ietf.org/html/rfc4557>`_ support, needs a bit hacking since OpenSSL wrappers are not exposing the functionality. * `OCSP <https://tools.ietf.org/html/rfc4557>`_ support, needs a bit hacking since OpenSSL wrappers are not exposing the functionality.
* `SECP <https://tools.ietf.org/html/draft-nourse-scep-23>`_ support, a client implementation available `here <https://github.com/certnanny/sscep>`_. Not sure if we can implement server-side events within current standard. * `SCEP <https://tools.ietf.org/html/draft-nourse-scep-23>`_ support, a client implementation available `here <https://github.com/certnanny/sscep>`_. Not sure if we can implement server-side events within current standard.
* Deep mailbox integration, eg fetch CSR-s from mailbox via IMAP.
* WebCrypto support, meanwhile check out `hwcrypto.js <https://github.com/open-eid/hwcrypto.js>`_. * WebCrypto support, meanwhile check out `hwcrypto.js <https://github.com/open-eid/hwcrypto.js>`_.
* Certificate push/pull, making it possible to sign offline. * Ability to send OpenVPN profile URL tokens via e-mail, for simplified VPN adoption.
* PKCS#11 hardware token support for signatures at command-line.
* Ability to send ``.ovpn`` bundle URL tokens via e-mail, for simplified VPN adoption.
* Cronjob for deleting expired certificates
* Signer process logging. * Signer process logging.
@ -384,15 +380,14 @@ as this information will already exist in AD and duplicating it in the certifica
doesn't make sense. Additionally the information will get out of sync if doesn't make sense. Additionally the information will get out of sync if
attributes are changed in AD but certificates won't be updated. attributes are changed in AD but certificates won't be updated.
If machine is enrolled, eg by running certidude request: If machine is enrolled, eg by running ``certidude request`` as root on Ubuntu/Fedora/Mac OS X:
* If Kerberos credentials are presented machine is automatically enrolled * If Kerberos credentials are presented machine can be automatically enrolled depending on the ``machine enrollment`` setting
* Common name is set to short hostname/machine name in AD * Common name is set to short ``hostname``
* E-mail is not filled in (maybe we can fill in something from AD?) * It is tricky to determine user who is triggering the action so given name, surname and e-mail attributes are not filled in
* Given name and surname are not filled in
If user enrolls, eg by clicking generate bundle button in the web interface: If user enrolls, eg by clicking generate bundle button in the web interface:
* Common name is either set to username or username@device-identifier depending on the 'user certificate enrollment' setting * Common name is either set to ``username`` or ``username@device-identifier`` depending on the ``user enrollment`` setting
* Given name and surname are filled in based on LDAP attributes of the user * Given name and surname are not filled in because Unicode characters cause issues in OpenVPN Connect app
* E-mail not filled in (should it be filled in? Can we even send mail to user if it's in external domain?) * E-mail is not filled in because it might change in AD

View File

@ -5,13 +5,14 @@ import mimetypes
import logging import logging
import os import os
import click import click
import hashlib
from datetime import datetime from datetime import datetime
from time import sleep from time import sleep
from certidude import authority, mailer from certidude import authority, mailer
from certidude.auth import login_required, authorize_admin 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 cryptography.x509.oid import NameOID
from certidude import const, config from certidude import const, config
logger = logging.getLogger("api") logger = logging.getLogger("api")
@ -44,6 +45,33 @@ class SessionResource(object):
@login_required @login_required
@event_source @event_source
def on_get(self, req, resp): def on_get(self, req, resp):
def serialize_requests(g):
for common_name, path, buf, obj, server in g():
yield dict(
common_name = common_name,
server = server,
md5sum = hashlib.md5(buf).hexdigest(),
sha1sum = hashlib.sha1(buf).hexdigest(),
sha256sum = hashlib.sha256(buf).hexdigest(),
sha512sum = hashlib.sha512(buf).hexdigest()
)
def serialize_certificates(g):
for common_name, path, buf, obj, server in g():
yield dict(
serial_number = "%x" % obj.serial_number,
common_name = common_name,
server = server,
# TODO: key type, key length, key exponent, key modulo
signed = obj.not_valid_before,
expires = obj.not_valid_after,
sha256sum = hashlib.sha256(buf).hexdigest()
)
if req.context.get("user").is_admin():
logger.info("Logged in authority administrator %s" % req.context.get("user"))
else:
logger.info("Logged in authority user %s" % req.context.get("user"))
return dict( return dict(
user = dict( user = dict(
name=req.context.get("user").name, name=req.context.get("user").name,
@ -51,29 +79,31 @@ class SessionResource(object):
sn=req.context.get("user").surname, sn=req.context.get("user").surname,
mail=req.context.get("user").mail mail=req.context.get("user").mail
), ),
request_submission_allowed = sum( # Dirty hack! request_submission_allowed = config.REQUEST_SUBMISSION_ALLOWED,
[req.context.get("remote_addr") in j
for j in config.REQUEST_SUBNETS]),
authority = dict( authority = dict(
common_name = authority.ca_cert.subject.get_attributes_for_oid(
NameOID.COMMON_NAME)[0].value,
outbox = dict( outbox = dict(
server = config.OUTBOX, server = config.OUTBOX,
name = config.OUTBOX_NAME, name = config.OUTBOX_NAME,
mail = config.OUTBOX_MAIL mail = config.OUTBOX_MAIL
), ),
user_certificate_enrollment=config.USER_CERTIFICATE_ENROLLMENT, machine_enrollment_allowed=config.MACHINE_ENROLLMENT_ALLOWED,
user_mutliple_certificates=config.USER_MULTIPLE_CERTIFICATES, user_enrollment_allowed=config.USER_ENROLLMENT_ALLOWED,
certificate = authority.certificate, user_multiple_certificates=config.USER_MULTIPLE_CERTIFICATES,
events = config.EVENT_SOURCE_SUBSCRIBE % config.EVENT_SOURCE_TOKEN, events = config.EVENT_SOURCE_SUBSCRIBE % config.EVENT_SOURCE_TOKEN,
requests=authority.list_requests(), requests=serialize_requests(authority.list_requests),
signed=authority.list_signed(), signed=serialize_certificates(authority.list_signed),
revoked=authority.list_revoked(), revoked=serialize_certificates(authority.list_revoked),
users=User.objects.all(),
admin_users = User.objects.filter_admins(), admin_users = User.objects.filter_admins(),
user_subnets = config.USER_SUBNETS, user_subnets = config.USER_SUBNETS,
autosign_subnets = config.AUTOSIGN_SUBNETS, autosign_subnets = config.AUTOSIGN_SUBNETS,
request_subnets = config.REQUEST_SUBNETS, request_subnets = config.REQUEST_SUBNETS,
admin_subnets=config.ADMIN_SUBNETS, admin_subnets=config.ADMIN_SUBNETS,
signature = dict( signature = dict(
certificate_lifetime=config.CERTIFICATE_LIFETIME, server_certificate_lifetime=config.SERVER_CERTIFICATE_LIFETIME,
client_certificate_lifetime=config.CLIENT_CERTIFICATE_LIFETIME,
revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME
) )
) if req.context.get("user").is_admin() else None, ) if req.context.get("user").is_admin() else None,
@ -88,7 +118,6 @@ class StaticResource(object):
self.root = os.path.realpath(root) self.root = os.path.realpath(root)
def __call__(self, req, resp): def __call__(self, req, resp):
path = os.path.realpath(os.path.join(self.root, req.path[1:])) path = os.path.realpath(os.path.join(self.root, req.path[1:]))
if not path.startswith(self.root): if not path.startswith(self.root):
raise falcon.HTTPForbidden raise falcon.HTTPForbidden
@ -124,7 +153,7 @@ def certidude_app():
from certidude import config from certidude import config
from .bundle import BundleResource from .bundle import BundleResource
from .revoked import RevocationListResource from .revoked import RevocationListResource
from .signed import SignedCertificateListResource, SignedCertificateDetailResource from .signed import SignedCertificateDetailResource
from .request import RequestListResource, RequestDetailResource from .request import RequestListResource, RequestDetailResource
from .lease import LeaseResource, StatusFileLeaseResource from .lease import LeaseResource, StatusFileLeaseResource
from .whois import WhoisResource from .whois import WhoisResource
@ -138,7 +167,6 @@ def certidude_app():
app.add_route("/api/certificate/", CertificateAuthorityResource()) app.add_route("/api/certificate/", CertificateAuthorityResource())
app.add_route("/api/revoked/", RevocationListResource()) app.add_route("/api/revoked/", RevocationListResource())
app.add_route("/api/signed/{cn}/", SignedCertificateDetailResource()) 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/{cn}/", RequestDetailResource())
app.add_route("/api/request/", RequestListResource()) app.add_route("/api/request/", RequestListResource())
app.add_route("/api/", SessionResource()) app.add_route("/api/", SessionResource())
@ -151,7 +179,7 @@ def certidude_app():
app.add_route("/api/whois/", WhoisResource()) app.add_route("/api/whois/", WhoisResource())
# Optional user enrollment API call # Optional user enrollment API call
if config.USER_CERTIFICATE_ENROLLMENT: if config.USER_ENROLLMENT_ALLOWED:
app.add_route("/api/bundle/", BundleResource()) app.add_route("/api/bundle/", BundleResource())
if config.TAGGING_BACKEND == "sql": if config.TAGGING_BACKEND == "sql":

View File

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

View File

@ -4,91 +4,125 @@ import falcon
import logging import logging
import ipaddress import ipaddress
import os import os
import hashlib
from base64 import b64decode
from certidude import config, authority, helpers, push, errors from certidude import config, authority, helpers, push, errors
from certidude.auth import login_required, login_optional, authorize_admin from certidude.auth import login_required, login_optional, authorize_admin
from certidude.decorators import serialize, csrf_protection from certidude.decorators import serialize, csrf_protection
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 import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature
from cryptography.x509.oid import NameOID
from datetime import datetime
logger = logging.getLogger("api") logger = logging.getLogger("api")
class RequestListResource(object): class RequestListResource(object):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
return authority.list_requests()
@login_optional @login_optional
@whitelist_subnets(config.REQUEST_SUBNETS) @whitelist_subnets(config.REQUEST_SUBNETS)
@whitelist_content_types("application/pkcs10") @whitelist_content_types("application/pkcs10")
def on_post(self, req, resp): def on_post(self, req, resp):
""" """
Submit certificate signing request (CSR) in PEM format Validate and parse certificate signing request
""" """
body = req.stream.read(req.content_length) body = req.stream.read(req.content_length)
csr = x509.load_pem_x509_csr(body, default_backend())
# Normalize body, TODO: newlines try:
if not body.endswith("\n"): common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
body += "\n" except: # ValueError?
csr = Request(body)
if not csr.common_name:
logger.warning(u"Rejected signing request without common name from %s", logger.warning(u"Rejected signing request without common name from %s",
req.context.get("remote_addr")) req.context.get("remote_addr"))
raise falcon.HTTPBadRequest( raise falcon.HTTPBadRequest(
"Bad request", "Bad request",
"No common name specified!") "No common name specified!")
"""
Handle domain computer automatic enrollment
"""
machine = req.context.get("machine") machine = req.context.get("machine")
if machine: if config.MACHINE_ENROLLMENT_ALLOWED and machine:
if csr.common_name != machine: if common_name.value != machine:
raise falcon.HTTPBadRequest( raise falcon.HTTPBadRequest(
"Bad request", "Bad request",
"Common name %s differs from Kerberos credential %s!" % (csr.common_name, machine)) "Common name %s differs from Kerberos credential %s!" % (common_name.value, machine))
# Automatic enroll with Kerberos machine cerdentials # Automatic enroll with Kerberos machine cerdentials
resp.set_header("Content-Type", "application/x-x509-user-cert") resp.set_header("Content-Type", "application/x-x509-user-cert")
resp.body = authority.sign(csr, overwrite=True).dump() cert, resp.body = authority._sign(csr, body, overwrite=True)
logger.info(u"Automatically enrolled Kerberos authenticated machine %s from %s",
machine, req.context.get("remote_addr"))
return return
"""
# Check if this request has been already signed and return corresponding certificte if it has been signed Attempt to renew certificate using currently valid key pair
"""
try: try:
cert = authority.get_signed(csr.common_name) path, buf, cert = authority.get_signed(common_name.value)
except EnvironmentError: except EnvironmentError:
pass pass
else: else:
if cert.pubkey == csr.pubkey: if cert.public_key().public_numbers() == csr.public_key().public_numbers():
resp.status = falcon.HTTP_SEE_OTHER try:
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name) renewal_signature = b64decode(req.get_header("X-Renewal-Signature"))
return except TypeError, ValueError: # No header supplied, redirect to signed API call
resp.status = falcon.HTTP_SEE_OTHER
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", common_name.value)
return
else:
try:
verifier = cert.public_key().verifier(
renewal_signature,
padding.PSS(
mgf=padding.MGF1(hashes.SHA512()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA512()
)
verifier.update(buf)
verifier.update(body)
verifier.verify()
except InvalidSignature:
logger.error("Renewal failed, invalid signature supplied for %s", common_name.value)
else:
# At this point renewal signature was valid but we need to perform some extra checks
if datetime.utcnow() > cert.not_valid_after:
logger.error("Renewal failed, current certificate for %s has expired", common_name.value)
# Put on hold
elif not config.CERTIFICATE_RENEWAL_ALLOWED:
logger.error("Renewal requested for %s, but not allowed by authority settings", common_name.value)
# Put on hold
else:
resp.set_header("Content-Type", "application/x-x509-user-cert")
_, resp.body = authority._sign(csr, body, overwrite=True)
logger.info("Renewed certificate for %s", common_name.value)
return
# TODO: check for revoked certificates and return HTTP 410 Gone
# 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") and csr.is_client: 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") and "." not in common_name.value:
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:
resp.set_header("Content-Type", "application/x-x509-user-cert") resp.set_header("Content-Type", "application/x-x509-user-cert")
resp.body = authority.sign(csr).dump() _, resp.body = authority._sign(csr, body)
logger.info("Autosigned %s as %s is whitelisted", common_name.value, req.context.get("remote_addr"))
return return
except EnvironmentError: # Certificate already exists, try to save the request except EnvironmentError:
pass logger.info("Autosign for %s failed, signed certificate already exists",
common_name.value, req.context.get("remote_addr"))
break break
# Attempt to save the request otherwise # Attempt to save the request otherwise
try: try:
csr = authority.store_request(body) csr = authority.store_request(body)
except errors.RequestExists: except errors.RequestExists:
# We should stil redirect client to long poll URL below # We should still redirect client to long poll URL below
pass pass
except errors.DuplicateCommonNameError: except errors.DuplicateCommonNameError:
# TODO: Certificate renewal # TODO: Certificate renewal
@ -98,12 +132,13 @@ class RequestListResource(object):
"CSR with such CN already exists", "CSR with such CN already exists",
"Will not overwrite existing certificate signing request, explicitly delete CSR and try again") "Will not overwrite existing certificate signing request, explicitly delete CSR and try again")
else: else:
push.publish("request-submitted", csr.common_name) push.publish("request-submitted", common_name.value)
# Wait the certificate to be signed if waiting is requested # Wait the certificate to be signed if waiting is requested
logger.info(u"Signing request %s from %s stored", common_name.value, req.context.get("remote_addr"))
if req.get_param("wait"): if req.get_param("wait"):
# Redirect to nginx pub/sub # Redirect to nginx pub/sub
url = config.LONG_POLL_SUBSCRIBE % csr.fingerprint() url = config.LONG_POLL_SUBSCRIBE % hashlib.sha256(body).hexdigest()
click.echo("Redirecting to: %s" % url) click.echo("Redirecting to: %s" % url)
resp.status = falcon.HTTP_SEE_OTHER resp.status = falcon.HTTP_SEE_OTHER
resp.set_header("Location", url.encode("ascii")) resp.set_header("Location", url.encode("ascii"))
@ -111,20 +146,17 @@ class RequestListResource(object):
else: else:
# Request was accepted, but not processed # Request was accepted, but not processed
resp.status = falcon.HTTP_202 resp.status = falcon.HTTP_202
logger.info(u"Signing request from %s stored", req.context.get("remote_addr"))
class RequestDetailResource(object): class RequestDetailResource(object):
@serialize
def on_get(self, req, resp, cn): def on_get(self, req, resp, cn):
""" """
Fetch certificate signing request as PEM Fetch certificate signing request as PEM
""" """
csr = authority.get_request(cn) resp.set_header("Content-Type", "application/pkcs10")
_, resp.body, _ = authority.get_request(cn)
logger.debug(u"Signing request %s was downloaded by %s", logger.debug(u"Signing request %s was downloaded by %s",
csr.common_name, req.context.get("remote_addr")) cn, req.context.get("remote_addr"))
return csr
@csrf_protection @csrf_protection
@login_required @login_required
@ -133,16 +165,15 @@ class RequestDetailResource(object):
""" """
Sign a certificate signing request Sign a certificate signing request
""" """
csr = authority.get_request(cn) cert, buf = authority.sign(cn, overwrite=True)
cert = authority.sign(csr, overwrite=True, delete=True) # Mailing and long poll publishing implemented in the function above
os.unlink(csr.path)
resp.body = "Certificate successfully signed" resp.body = "Certificate successfully signed"
resp.status = falcon.HTTP_201 resp.status = falcon.HTTP_201
resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn) resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn)
logger.info(u"Signing request %s signed by %s from %s", csr.common_name, logger.info(u"Signing request %s signed by %s from %s", cn,
req.context.get("user"), req.context.get("remote_addr")) req.context.get("user"), req.context.get("remote_addr"))
@csrf_protection @csrf_protection
@login_required @login_required
@authorize_admin @authorize_admin

View File

@ -1,10 +1,10 @@
import click
import falcon import falcon
import json import json
import logging import logging
from certidude import const, config from certidude import const, config
from certidude.authority import export_crl, list_revoked from certidude.authority import export_crl, list_revoked
from certidude.decorators import MyEncoder
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.serialization import Encoding from cryptography.hazmat.primitives.serialization import Encoding
@ -31,16 +31,13 @@ class RevocationListResource(object):
resp.status = falcon.HTTP_SEE_OTHER resp.status = falcon.HTTP_SEE_OTHER
resp.set_header("Location", url.encode("ascii")) resp.set_header("Location", url.encode("ascii"))
logger.debug(u"Redirecting to CRL request to %s", url) logger.debug(u"Redirecting to CRL request to %s", url)
resp.body = "Redirecting to %s" % url
else: else:
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" % const.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"):
resp.set_header("Content-Type", "application/json")
resp.set_header("Content-Disposition", "inline")
resp.body = json.dumps(list_revoked(), cls=MyEncoder)
else: else:
raise falcon.HTTPUnsupportedMediaType( raise falcon.HTTPUnsupportedMediaType(
"Client did not accept application/x-pkcs7-crl or application/x-pem-file") "Client did not accept application/x-pkcs7-crl or application/x-pem-file")

View File

@ -1,38 +1,46 @@
import falcon import falcon
import logging import logging
import json
import hashlib
from certidude import authority from certidude import authority
from certidude.auth import login_required, authorize_admin from certidude.auth import login_required, authorize_admin
from certidude.decorators import serialize, csrf_protection from certidude.decorators import csrf_protection
logger = logging.getLogger("api") logger = logging.getLogger("api")
class SignedCertificateListResource(object):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
return {"signed":authority.list_signed()}
class SignedCertificateDetailResource(object): class SignedCertificateDetailResource(object):
@serialize
def on_get(self, req, resp, cn): def on_get(self, req, resp, cn):
# Compensate for NTP lag
# from time import sleep preferred_type = req.client_prefers(("application/json", "application/x-pem-file"))
# sleep(5)
try: try:
cert = authority.get_signed(cn) path, buf, cert = authority.get_signed(cn)
except EnvironmentError: except EnvironmentError:
logger.warning(u"Failed to serve non-existant certificate %s to %s", logger.warning(u"Failed to serve non-existant certificate %s to %s",
cn, req.context.get("remote_addr")) cn, req.context.get("remote_addr"))
resp.body = "No certificate CN=%s found" % cn raise falcon.HTTPNotFound("No certificate CN=%s found" % cn)
raise falcon.HTTPNotFound()
else: else:
logger.debug(u"Served certificate %s to %s", if preferred_type == "application/x-pem-file":
cn, req.context.get("remote_addr")) resp.set_header("Content-Type", "application/x-pem-file")
return cert resp.set_header("Content-Disposition", ("attachment; filename=%s.pem" % cn))
resp.body = buf
logger.debug(u"Served certificate %s to %s as application/x-pem-file",
cn, req.context.get("remote_addr"))
elif preferred_type == "application/json":
resp.set_header("Content-Type", "application/json")
resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % cn))
resp.body = json.dumps(dict(
common_name = cn,
serial_number = "%x" % cert.serial_number,
signed = cert.not_valid_before.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
expires = cert.not_valid_after.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
sha256sum = hashlib.sha256(buf).hexdigest()))
logger.debug(u"Served certificate %s to %s as application/json",
cn, req.context.get("remote_addr"))
else:
logger.debug("Client did not accept application/json or application/x-pem-file")
raise falcon.HTTPUnsupportedMediaType(
"Client did not accept application/json or application/x-pem-file")
@csrf_protection @csrf_protection
@login_required @login_required
@ -40,5 +48,5 @@ class SignedCertificateDetailResource(object):
def on_delete(self, req, resp, cn): def on_delete(self, req, resp, cn):
logger.info(u"Revoked certificate %s by %s from %s", logger.info(u"Revoked certificate %s by %s from %s",
cn, req.context.get("user"), req.context.get("remote_addr")) cn, req.context.get("user"), req.context.get("remote_addr"))
authority.revoke_certificate(cn) authority.revoke(cn)

View File

@ -31,6 +31,8 @@ if "kerberos" in config.AUTHENTICATION_BACKENDS:
else: else:
click.echo("Kerberos enabled, service principal is HTTP/%s" % const.FQDN) click.echo("Kerberos enabled, service principal is HTTP/%s" % const.FQDN)
click.echo("Accepting requests only for realm: %s" % const.DOMAIN)
def authenticate(optional=False): def authenticate(optional=False):
def wrapper(func): def wrapper(func):
@ -38,7 +40,7 @@ def authenticate(optional=False):
# If LDAP enabled and device is not Kerberos capable fall # If LDAP enabled and device is not Kerberos capable fall
# back to LDAP bind authentication # back to LDAP bind authentication
if "ldap" in config.AUTHENTICATION_BACKENDS: if "ldap" in config.AUTHENTICATION_BACKENDS:
if "Android" in req.user_agent: if "Android" in req.user_agent or "iPhone" in req.user_agent:
return ldap_authenticate(resource, req, resp, *args, **kwargs) return ldap_authenticate(resource, req, resp, *args, **kwargs)
# Try pre-emptive authentication # Try pre-emptive authentication
@ -81,16 +83,20 @@ def authenticate(optional=False):
raise falcon.HTTPForbidden("Forbidden", raise falcon.HTTPForbidden("Forbidden",
"Kerberos error: %s" % (ex.args[0],)) "Kerberos error: %s" % (ex.args[0],))
user = kerberos.authGSSServerUserName(context) user_principal = kerberos.authGSSServerUserName(context)
username, domain = user_principal.split("@")
if domain.lower() != const.DOMAIN:
raise falcon.HTTPForbidden("Forbidden",
"Invalid realm supplied")
if "$@" in user and optional: if username.endswith("$") and optional:
# Extract machine hostname # Extract machine hostname
# TODO: Assert LDAP group membership # TODO: Assert LDAP group membership
req.context["machine"], _ = user.lower().split("$@", 1) req.context["machine"] = username[:-1].lower()
req.context["user"] = None req.context["user"] = None
else: else:
# Attempt to look up real user # Attempt to look up real user
req.context["user"] = User.objects.get(user) req.context["user"] = User.objects.get(username)
try: try:
kerberos.authGSSServerClean(context) kerberos.authGSSServerClean(context)
@ -143,12 +149,8 @@ def authenticate(optional=False):
conn = ldap.initialize(config.LDAP_AUTHENTICATION_URI) conn = ldap.initialize(config.LDAP_AUTHENTICATION_URI)
conn.set_option(ldap.OPT_REFERRALS, 0) conn.set_option(ldap.OPT_REFERRALS, 0)
if "@" not in user:
user = "%s@%s" % (user, const.DOMAIN)
logger.debug("Expanded username to %s", user)
try: try:
conn.simple_bind_s(user, passwd) conn.simple_bind_s("%s@%s" % (user, const.DOMAIN), passwd)
except ldap.STRONG_AUTH_REQUIRED: except ldap.STRONG_AUTH_REQUIRED:
logger.critical("LDAP server demands encryption, use ldaps:// instead of ldaps://") logger.critical("LDAP server demands encryption, use ldaps:// instead of ldaps://")
raise raise
@ -160,8 +162,8 @@ def authenticate(optional=False):
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" % const.DOMAIN, "Please authenticate with %s domain account username" % const.DOMAIN,
("Basic",)) ("Basic",))
req.context["ldap_conn"] = conn req.context["ldap_conn"] = conn
req.context["user"] = User.objects.get(user) req.context["user"] = User.objects.get(user)

View File

@ -4,15 +4,15 @@ import os
import random import random
import re import re
import requests import requests
import hashlib
import socket import socket
from datetime import datetime, timedelta from datetime import datetime, timedelta
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography import x509 from cryptography import x509
from cryptography.x509.oid import NameOID, ExtensionOID, AuthorityInformationAccessOID from cryptography.x509.oid import NameOID, ExtensionOID, ExtendedKeyUsageOID
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from certidude import config, push, mailer, const from certidude import config, push, mailer, const
from certidude.wrappers import Certificate, Request
from certidude import errors from certidude import errors
from jinja2 import Template from jinja2 import Template
@ -23,71 +23,51 @@ RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z
# http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py # http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py
# Cache CA certificate # Cache CA certificate
certificate = Certificate(open(config.AUTHORITY_CERTIFICATE_PATH))
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))
recipient = None
mailer.send(
"certificate-signed.md",
to=recipient,
attachments=(cert,),
certificate=cert)
if config.LONG_POLL_PUBLISH:
url = config.LONG_POLL_PUBLISH % csr.fingerprint()
click.echo("Publishing certificate at %s ..." % url)
requests.post(url, data=cert.dump(),
headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"})
# For deleting request in the web view, use pubkey modulo
push.publish("request-signed", cert.common_name)
return cert
return wrapped
with open(config.AUTHORITY_CERTIFICATE_PATH) as fh:
ca_buf = fh.read()
ca_cert = x509.load_pem_x509_certificate(ca_buf, default_backend())
def get_request(common_name): def get_request(common_name):
if not re.match(RE_HOSTNAME, common_name): if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name %s" % repr(common_name)) raise ValueError("Invalid common name %s" % repr(common_name))
return Request(open(os.path.join(config.REQUESTS_DIR, common_name + ".pem"))) path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
with open(path) as fh:
buf = fh.read()
return path, buf, x509.load_pem_x509_csr(buf, default_backend())
def get_signed(common_name): def get_signed(common_name):
if not re.match(RE_HOSTNAME, common_name): if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name %s" % repr(common_name)) raise ValueError("Invalid common name %s" % repr(common_name))
return Certificate(open(os.path.join(config.SIGNED_DIR, common_name + ".pem"))) path = os.path.join(config.SIGNED_DIR, common_name + ".pem")
with open(path) as fh:
buf = fh.read()
def get_revoked(common_name): return path, buf, x509.load_pem_x509_certificate(buf, default_backend())
if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name %s" % repr(common_name))
return Certificate(open(os.path.join(config.SIGNED_DIR, common_name + ".pem")))
def get_revoked(serial):
path = os.path.join(config.REVOKED_DIR, serial + ".pem")
with open(path) as fh:
buf = fh.read()
return path, buf, x509.load_pem_x509_certificate(buf, default_backend())
def store_request(buf, overwrite=False): def store_request(buf, overwrite=False):
""" """
Store CSR for later processing Store CSR for later processing
""" """
if not buf: return # No certificate supplied if not buf:
raise ValueError("No certificate supplied") # No certificate supplied
csr = x509.load_pem_x509_csr(buf, backend=default_backend()) csr = x509.load_pem_x509_csr(buf, backend=default_backend())
for name in csr.subject: common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
if name.oid == NameOID.COMMON_NAME: # TODO: validate common name again
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") if not re.match(RE_HOSTNAME, common_name.value):
if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name") raise ValueError("Invalid common name")
request_path = os.path.join(config.REQUESTS_DIR, common_name.value + ".pem")
# If there is cert, check if it's the same # If there is cert, check if it's the same
if os.path.exists(request_path): if os.path.exists(request_path):
if open(request_path).read() == buf: if open(request_path).read() == buf:
@ -99,9 +79,11 @@ def store_request(buf, overwrite=False):
fh.write(buf) fh.write(buf)
os.rename(request_path + ".part", request_path) os.rename(request_path + ".part", request_path)
req = Request(open(request_path)) attach_csr = buf, "application/x-pem-file", common_name.value + ".csr"
mailer.send("request-stored.md", attachments=(req,), request=req) mailer.send("request-stored.md",
return req attachments=(attach_csr,),
common_name=common_name.value)
return csr
def signer_exec(cmd, *bits): def signer_exec(cmd, *bits):
@ -118,14 +100,15 @@ def signer_exec(cmd, *bits):
return buf return buf
def revoke_certificate(common_name): def revoke(common_name):
""" """
Revoke valid certificate Revoke valid certificate
""" """
cert = get_signed(common_name) path, buf, cert = get_signed(common_name)
revoked_filename = os.path.join(config.REVOKED_DIR, "%s.pem" % cert.serial_number) revoked_path = os.path.join(config.REVOKED_DIR, "%x.pem" % cert.serial_number)
os.rename(cert.path, revoked_filename) signed_path = os.path.join(config.SIGNED_DIR, "%s.pem" % common_name)
push.publish("certificate-revoked", cert.common_name) os.rename(signed_path, revoked_path)
push.publish("certificate-revoked", common_name)
# Publish CRL for long polls # Publish CRL for long polls
if config.LONG_POLL_PUBLISH: if config.LONG_POLL_PUBLISH:
@ -134,26 +117,52 @@ def revoke_certificate(common_name):
requests.post(url, data=export_crl(), requests.post(url, data=export_crl(),
headers={"User-Agent": "Certidude API", "Content-Type": "application/x-pem-file"}) headers={"User-Agent": "Certidude API", "Content-Type": "application/x-pem-file"})
mailer.send("certificate-revoked.md", attachments=(cert,), certificate=cert) attach_cert = buf, "application/x-pem-file", common_name + ".crt"
mailer.send("certificate-revoked.md",
attachments=(attach_cert,),
serial_number="%x" % cert.serial,
common_name=common_name)
def server_flags(cn):
if config.USER_ENROLLMENT_ALLOWED and not config.USER_MULTIPLE_CERTIFICATES:
# Common name set to username, used for only HTTPS client validation anyway
return False
if "@" in cn:
# username@hostname is user certificate anyway, can't be server
return False
if "." in cn:
# CN is hostname, if contains dot has to be FQDN, hence a server
return True
return False
def list_requests(directory=config.REQUESTS_DIR): def list_requests(directory=config.REQUESTS_DIR):
for filename in os.listdir(directory): for filename in os.listdir(directory):
if filename.endswith(".pem"): if filename.endswith(".pem"):
yield Request(open(os.path.join(directory, filename))) common_name = filename[:-4]
path, buf, req = get_request(common_name)
yield common_name, path, buf, req, server_flags(common_name),
def _list_certificates(directory):
def list_signed(directory=config.SIGNED_DIR):
for filename in os.listdir(directory): for filename in os.listdir(directory):
if filename.endswith(".pem"): if filename.endswith(".pem"):
yield Certificate(open(os.path.join(directory, filename))) common_name = filename[:-4]
path = os.path.join(directory, filename)
with open(path) as fh:
buf = fh.read()
cert = x509.load_pem_x509_certificate(buf, default_backend())
server = False
extension = cert.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE)
for usage in extension.value:
if usage == ExtendedKeyUsageOID.SERVER_AUTH: # TODO: IKE intermediate?
server = True
yield common_name, path, buf, cert, server
def list_signed():
return _list_certificates(config.SIGNED_DIR)
def list_revoked(directory=config.REVOKED_DIR): def list_revoked():
for filename in os.listdir(directory): return _list_certificates(config.REVOKED_DIR)
if filename.endswith(".pem"):
yield Certificate(open(os.path.join(directory, filename)))
def export_crl(): def export_crl():
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
@ -178,14 +187,15 @@ def delete_request(common_name):
raise ValueError("Invalid common name") raise ValueError("Invalid common name")
path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
request = Request(open(path)) _, buf, csr = get_request(common_name)
os.unlink(path) os.unlink(path)
# Publish event at CA channel # Publish event at CA channel
push.publish("request-deleted", request.common_name) push.publish("request-deleted", common_name)
# Write empty certificate to long-polling URL # Write empty certificate to long-polling URL
requests.delete(config.LONG_POLL_PUBLISH % request.fingerprint(), requests.delete(
config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest(),
headers={"User-Agent": "Certidude API"}) headers={"User-Agent": "Certidude API"})
def generate_ovpn_bundle(common_name, owner=None): def generate_ovpn_bundle(common_name, owner=None):
@ -198,26 +208,26 @@ def generate_ovpn_bundle(common_name, owner=None):
backend=default_backend() backend=default_backend()
) )
key_buf = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([ csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([
x509.NameAttribute(k, v) for k, v in ( x509.NameAttribute(k, v) for k, v in (
(NameOID.COMMON_NAME, common_name), (NameOID.COMMON_NAME, common_name),
) if v ) if v
])) ])).sign(key, hashes.SHA512(), default_backend())
buf = csr.public_bytes(serialization.Encoding.PEM)
# Sign CSR # Sign CSR
cert = sign(Request( cert, cert_buf = _sign(csr, buf, overwrite=True)
csr.sign(key, hashes.SHA512(), default_backend()).public_bytes(serialization.Encoding.PEM)), overwrite=True)
bundle = Template(open(config.OPENVPN_BUNDLE_TEMPLATE).read()).render( bundle = Template(open(config.OPENVPN_PROFILE_TEMPLATE).read()).render(
ca = certificate.dump(), ca = ca_buf, key = key_buf, cert = cert_buf, crl=export_crl(),
key = key.private_bytes( servers = [cn for cn, path, buf, cert, server in list_signed() if server])
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
),
cert = cert.dump(),
crl=export_crl(),
)
return bundle, cert return bundle, cert
def generate_pkcs12_bundle(common_name, key_size=4096, owner=None): def generate_pkcs12_bundle(common_name, key_size=4096, owner=None):
@ -236,11 +246,12 @@ def generate_pkcs12_bundle(common_name, key_size=4096, owner=None):
csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([ csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, common_name) x509.NameAttribute(NameOID.COMMON_NAME, common_name)
])) ])).sign(key, hashes.SHA512(), default_backend())
buf = csr.public_bytes(serialization.Encoding.PEM)
# Sign CSR # Sign CSR
cert = sign(Request( cert, cert_buf = _sign(csr, buf, overwrite=True)
csr.sign(key, hashes.SHA512(), default_backend()).public_bytes(serialization.Encoding.PEM)), overwrite=True)
# Generate P12, currently supported only by PyOpenSSL # Generate P12, currently supported only by PyOpenSSL
try: try:
@ -256,131 +267,102 @@ def generate_pkcs12_bundle(common_name, key_size=4096, owner=None):
key.private_bytes( key.private_bytes(
encoding=serialization.Encoding.PEM, encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL, format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption() encryption_algorithm=serialization.NoEncryption())))
) p12.set_certificate(
) crypto.load_certificate(crypto.FILETYPE_PEM, cert_buf))
) p12.set_ca_certificates([
p12.set_certificate( cert._obj ) crypto.load_certificate(crypto.FILETYPE_PEM, ca_buf)])
p12.set_ca_certificates([certificate._obj])
return p12.export("1234"), cert return p12.export("1234"), cert
@publish_certificate def sign(common_name, overwrite=False):
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")
req_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
with open(req_path) as fh:
csr_buf = fh.read()
csr = x509.load_pem_x509_csr(csr_buf, backend=default_backend())
common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
# Sign with function below
cert, buf = _sign(csr, csr_buf, overwrite)
os.unlink(req_path)
return cert, buf
def _sign(csr, buf, overwrite=False):
assert buf.startswith("-----BEGIN CERTIFICATE REQUEST-----\n")
assert isinstance(csr, x509.CertificateSigningRequest)
common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
cert_path = os.path.join(config.SIGNED_DIR, common_name.value + ".pem")
renew = False
# Move existing certificate if necessary # Move existing certificate if necessary
if os.path.exists(cert_path): if os.path.exists(cert_path):
old_cert = Certificate(open(cert_path)) with open(cert_path) as fh:
prev_buf = fh.read()
prev = x509.load_pem_x509_certificate(prev_buf, default_backend())
# TODO: assert validity here again?
renew = prev.public_key().public_numbers() == csr.public_key().public_numbers()
if overwrite: if overwrite:
revoke_certificate(req.common_name) if renew:
elif req.pubkey == old_cert.pubkey: # TODO: is this the best approach?
return old_cert signed_path = os.path.join(config.SIGNED_DIR, "%s.pem" % common_name.value)
revoked_path = os.path.join(config.REVOKED_DIR, "%x.pem" % prev.serial_number)
os.rename(signed_path, revoked_path)
else:
revoke(common_name.value)
else: else:
raise EnvironmentError("Will not overwrite existing certificate") raise EnvironmentError("Will not overwrite existing certificate")
# Sign via signer process # Sign via signer process
cert_buf = signer_exec("sign-request", req.dump()) cert_buf = signer_exec("sign-request", buf)
cert = x509.load_pem_x509_certificate(cert_buf, default_backend())
with open(cert_path + ".part", "wb") as fh: with open(cert_path + ".part", "wb") as fh:
fh.write(cert_buf) fh.write(cert_buf)
os.rename(cert_path + ".part", cert_path) os.rename(cert_path + ".part", cert_path)
return Certificate(open(cert_path)) # Send mail
recipient = None
if renew:
@publish_certificate mailer.send(
def sign2(request, private_key, authority_certificate, overwrite=False, delete=True, lifetime=None): "certificate-renewed.md",
""" to=recipient,
Sign directly using private key, this is usually done by root. attachments=(
Basic constraints and certificate lifetime are copied from config, (prev_buf, "application/x-pem-file", "deprecated.crt"),
lifetime may be overridden on the command line, (cert_buf, "application/x-pem-file", common_name.value + ".crt")
other extensions are copied as is. ),
""" serial_number="%x" % cert.serial,
common_name=common_name.value,
certificate_path = os.path.join(config.SIGNED_DIR, request.common_name + ".pem") certificate=cert,
if os.path.exists(certificate_path): )
if overwrite: else:
revoke_certificate(request.common_name) mailer.send(
else: "certificate-signed.md",
raise errors.DuplicateCommonNameError("Valid certificate with common name %s already exists" % request.common_name) to=recipient,
attachments=(
now = datetime.utcnow() (buf, "application/x-pem-file", common_name.value + ".csr"),
request_path = os.path.join(config.REQUESTS_DIR, request.common_name + ".pem") (cert_buf, "application/x-pem-file", common_name.value + ".crt")
request = x509.load_pem_x509_csr(open(request_path).read(), default_backend()) ),
serial_number="%x" % cert.serial,
cert = x509.CertificateBuilder( common_name=common_name.value,
).subject_name(x509.Name([n for n in request.subject]) certificate=cert,
).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 config.LONG_POLL_PUBLISH:
if extension.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME: url = config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest()
click.echo("Appending subject alt name extension: %s" % extension) click.echo("Publishing certificate at %s ..." % url)
cert = cert.add_extension(x509.SubjectAlternativeName(extension.value), requests.post(url, data=cert_buf,
critical=extension.critical) headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"})
if extension.oid == ExtensionOID.EXTENDED_KEY_USAGE:
click.echo("Appending extended key usage flags extension: %s" % extension) if config.EVENT_SOURCE_PUBLISH: # TODO: handle renewal
cert = cert.add_extension(x509.ExtendedKeyUsage(extension.value), push.publish("request-signed", common_name.value)
critical=extension.critical)
return cert, cert_buf
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)
os.rename(certificate_path + ".part", certificate_path)
click.echo("Wrote certificate to: %s" % certificate_path)
if delete:
os.unlink(request_path)
click.echo("Deleted request: %s" % request_path)
return Certificate(open(certificate_path))

View File

@ -25,7 +25,6 @@ from cryptography.hazmat.primitives.asymmetric import rsa
from datetime import datetime, timedelta from datetime import datetime, timedelta
from humanize import naturaltime from humanize import naturaltime
from jinja2 import Environment, PackageLoader from jinja2 import Environment, PackageLoader
from time import sleep
from setproctitle import setproctitle from setproctitle import setproctitle
import const import const
@ -38,22 +37,29 @@ env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=Tr
# Parse command-line argument defaults from environment # Parse command-line argument defaults from environment
USERNAME = os.environ.get("USER")
NOW = datetime.utcnow().replace(tzinfo=None) NOW = datetime.utcnow().replace(tzinfo=None)
FIRST_NAME = None
SURNAME = None
EMAIL = None
if USERNAME: CERTIDUDE_TIMER = """
EMAIL = USERNAME + "@" + const.FQDN [Unit]
Description=Run certidude service weekly
if os.getuid() >= 1000: [Timer]
_, _, _, _, gecos, _, _ = pwd.getpwnam(USERNAME) OnCalendar=weekly
if " " in gecos: Persistent=true
FIRST_NAME, SURNAME = gecos.split(" ", 1) Unit=certidude.service
else:
FIRST_NAME = gecos
[Install]
WantedBy=timers.target
"""
CERTIDUDE_SERVICE = """
[Unit]
Description=Renew certificates and update revocation lists
[Service]
Type=simple
ExecStart=%s request
"""
@click.command("request", help="Run processes for requesting certificates and configuring services") @click.command("request", help="Run processes for requesting certificates and configuring services")
@click.option("-f", "--fork", default=False, is_flag=True, help="Fork to background") @click.option("-f", "--fork", default=False, is_flag=True, help="Fork to background")
@ -80,7 +86,24 @@ def certidude_request(fork):
click.echo("Creating: %s" % run_dir) click.echo("Creating: %s" % run_dir)
os.makedirs(run_dir) os.makedirs(run_dir)
if not os.path.exists("/etc/systemd/system/certidude.timer"):
click.echo("Creating systemd timer...")
with open("/etc/systemd/system/certidude.timer", "w") as fh:
fh.write(CERTIDUDE_TIMER)
if not os.path.exists("/etc/systemd/system/certidude.service"):
click.echo("Creating systemd service...")
with open("/etc/systemd/system/certidude.service", "w") as fh:
fh.write(CERTIDUDE_SERVICE % sys.argv[0])
for authority in clients.sections(): for authority in clients.sections():
try:
endpoint_dhparam = clients.get(authority, "dhparam path")
if not os.path.exists(endpoint_dhparam):
cmd = "openssl", "dhparam", "-out", endpoint_dhparam, "2048"
subprocess.check_call(cmd)
except NoOptionError:
pass
try: try:
endpoint_insecure = clients.getboolean(authority, "insecure") endpoint_insecure = clients.getboolean(authority, "insecure")
except NoOptionError: except NoOptionError:
@ -111,22 +134,6 @@ def certidude_request(fork):
endpoint_revocations_path = "/var/lib/certidude/%s/ca_crl.pem" % authority endpoint_revocations_path = "/var/lib/certidude/%s/ca_crl.pem" % authority
# TODO: Create directories automatically # TODO: Create directories automatically
extended_key_usage_flags=[]
try:
endpoint_key_flags = set([j.strip() for j in clients.get(authority, "extended key usage flags").lower().split(",") if j.strip()])
except NoOptionError:
pass
else:
if "server auth" in endpoint_key_flags:
endpoint_key_flags -= set(["server auth"])
extended_key_usage_flags.append(ExtendedKeyUsageOID.SERVER_AUTH)
if "ike intermediate" in endpoint_key_flags:
endpoint_key_flags -= set(["ike intermediate"])
extended_key_usage_flags.append(x509.ObjectIdentifier("1.3.6.1.5.5.8.2.2"))
if endpoint_key_flags:
raise ValueError("Extended key usage flags %s not understood!" % endpoint_key_flags)
# TODO: IKE Intermediate
if clients.get(authority, "trigger") == "domain joined": if clients.get(authority, "trigger") == "domain joined":
if not os.path.exists("/etc/krb5.keytab"): if not os.path.exists("/etc/krb5.keytab"):
continue continue
@ -168,8 +175,6 @@ def certidude_request(fork):
endpoint_authority_path, endpoint_authority_path,
endpoint_revocations_path, endpoint_revocations_path,
endpoint_common_name, endpoint_common_name,
extended_key_usage_flags,
None,
insecure=endpoint_insecure, insecure=endpoint_insecure,
autosign=True, autosign=True,
wait=True) wait=True)
@ -229,6 +234,10 @@ def certidude_request(fork):
# OpenVPN set up with NetworkManager # OpenVPN set up with NetworkManager
if service_config.get(endpoint, "service") == "network-manager/openvpn": if service_config.get(endpoint, "service") == "network-manager/openvpn":
nm_config_path = os.path.join("/etc/NetworkManager/system-connections", endpoint)
if os.path.exists(nm_config_path):
click.echo("Not creating %s, remove to regenerate" % nm_config_path)
continue
nm_config = ConfigParser() nm_config = ConfigParser()
nm_config.add_section("connection") nm_config.add_section("connection")
nm_config.set("connection", "id", endpoint) nm_config.set("connection", "id", endpoint)
@ -242,6 +251,7 @@ def certidude_request(fork):
nm_config.set("vpn", "tap-dev", "no") nm_config.set("vpn", "tap-dev", "no")
nm_config.set("vpn", "remote-cert-tls", "server") # Assert TLS Server flag of X.509 certificate nm_config.set("vpn", "remote-cert-tls", "server") # Assert TLS Server flag of X.509 certificate
nm_config.set("vpn", "remote", service_config.get(endpoint, "remote")) nm_config.set("vpn", "remote", service_config.get(endpoint, "remote"))
nm_config.set("vpn", "port", "51900")
nm_config.set("vpn", "key", endpoint_key_path) nm_config.set("vpn", "key", endpoint_key_path)
nm_config.set("vpn", "cert", endpoint_certificate_path) nm_config.set("vpn", "cert", endpoint_certificate_path)
nm_config.set("vpn", "ca", endpoint_authority_path) nm_config.set("vpn", "ca", endpoint_authority_path)
@ -255,9 +265,9 @@ def certidude_request(fork):
os.umask(0o177) os.umask(0o177)
# Write NetworkManager configuration # Write NetworkManager configuration
with open(os.path.join("/etc/NetworkManager/system-connections", endpoint), "w") as fh: with open(nm_config_path, "w") as fh:
nm_config.write(fh) nm_config.write(fh)
click.echo("Created %s" % fh.name) click.echo("Created %s" % nm_config_path)
os.system("nmcli con reload") os.system("nmcli con reload")
continue continue
@ -302,50 +312,18 @@ def certidude_request(fork):
os.unlink(pid_path) os.unlink(pid_path)
@click.command("client", help="Setup X.509 certificates for application")
@click.argument("server")
@click.option("--common-name", "-cn", default=const.HOSTNAME, help="Common name, '%s' by default" % const.HOSTNAME)
@click.option("--given-name", "-gn", default=FIRST_NAME, help="Given name of the person associted with the certificate, '%s' by default" % FIRST_NAME)
@click.option("--surname", "-sn", default=SURNAME, help="Surname of the person associted with the certificate, '%s' by default" % SURNAME)
@click.option("--key-usage", "-ku", help="Key usage attributes, none requested by default")
@click.option("--extended-key-usage", "-eku", help="Extended key usage attributes, none requested by default")
@click.option("--quiet", "-q", default=False, is_flag=True, help="Disable verbose output")
@click.option("--autosign", "-s", default=False, is_flag=True, help="Request for automatic signing if available")
@click.option("--wait", "-w", default=False, is_flag=True, help="Wait for certificate, by default return immideately")
@click.option("--key-path", "-k", default=const.HOSTNAME + ".key", help="Key path, %s.key by default" % const.HOSTNAME)
@click.option("--request-path", "-r", default=const.HOSTNAME + ".csr", help="Request path, %s.csr by default" % const.HOSTNAME)
@click.option("--certificate-path", "-c", default=const.HOSTNAME + ".crt", help="Certificate path, %s.crt by default" % const.HOSTNAME)
@click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt by default")
@click.option("--revocations-path", "-crl", default="ca.crl", help="Certificate revocation list, ca.crl by default")
def certidude_setup_client(quiet, **kwargs):
return certidude_request_certificate(**kwargs)
@click.command("server", help="Set up OpenVPN server") @click.command("server", help="Set up OpenVPN server")
@click.argument("authority") @click.argument("authority")
@click.option("--subnet", "-s", default="192.168.33.0/24", type=ip_network, help="OpenVPN subnet, 192.168.33.0/24 by default") @click.option("--subnet", "-s", default="192.168.33.0/24", type=ip_network, help="OpenVPN subnet, 192.168.33.0/24 by default")
@click.option("--local", "-l", default="0.0.0.0", help="OpenVPN listening address, defaults to all interfaces") @click.option("--local", "-l", default="0.0.0.0", help="OpenVPN listening address, defaults to all interfaces")
@click.option("--port", "-p", default=1194, type=click.IntRange(1,60000), help="OpenVPN listening port, 1194 by default") @click.option("--port", "-p", default=51900, type=click.IntRange(1,60000), help="OpenVPN listening port, 51900 by default")
@click.option('--proto', "-t", default="udp", type=click.Choice(['udp', 'tcp']), help="OpenVPN transport protocol, UDP by default") @click.option('--proto', "-t", default="udp", type=click.Choice(['udp', 'tcp']), help="OpenVPN transport protocol, UDP by default")
@click.option("--route", "-r", type=ip_network, multiple=True, help="Subnets to advertise via this connection, multiple allowed") @click.option("--route", "-r", type=ip_network, multiple=True, help="Subnets to advertise via this connection, multiple allowed")
@click.option("--config", "-o", @click.option("--config", "-o",
default="/etc/openvpn/site-to-client.conf", default="/etc/openvpn/site-to-client.conf",
type=click.File(mode="w", atomic=True, lazy=True), type=click.File(mode="w", atomic=True, lazy=True),
help="OpenVPN configuration file") help="OpenVPN configuration file")
def certidude_setup_openvpn_server(authority, config, subnet, route, org_unit, local, proto, port): def certidude_setup_openvpn_server(authority, config, subnet, route, local, proto, port):
# TODO: Make dirs
# TODO: Intelligent way of getting last IP address in the subnet
subnet_first = None
subnet_last = None
subnet_second = None
for addr in subnet.hosts():
if not subnet_first:
subnet_first = addr
continue
if not subnet_second:
subnet_second = addr
subnet_last = addr
# Create corresponding section in Certidude client configuration file # Create corresponding section in Certidude client configuration file
client_config = ConfigParser() client_config = ConfigParser()
@ -356,13 +334,12 @@ def certidude_setup_openvpn_server(authority, config, subnet, route, org_unit, l
else: else:
client_config.set(authority, "trigger", "interface up") client_config.set(authority, "trigger", "interface up")
client_config.set(authority, "common name", const.HOSTNAME) client_config.set(authority, "common name", const.HOSTNAME)
client_config.set(authority, "subject alternative name dns", const.FQDN)
client_config.set(authority, "extended key usage flags", "server auth")
client_config.set(authority, "request path", "/etc/openvpn/keys/%s.csr" % const.HOSTNAME) client_config.set(authority, "request path", "/etc/openvpn/keys/%s.csr" % const.HOSTNAME)
client_config.set(authority, "key path", "/etc/openvpn/keys/%s.key" % const.HOSTNAME) client_config.set(authority, "key path", "/etc/openvpn/keys/%s.key" % const.HOSTNAME)
client_config.set(authority, "certificate path", "/etc/openvpn/keys/%s.crt" % const.HOSTNAME) client_config.set(authority, "certificate path", "/etc/openvpn/keys/%s.crt" % const.HOSTNAME)
client_config.set(authority, "authority path", "/etc/openvpn/keys/ca.crt") client_config.set(authority, "authority path", "/etc/openvpn/keys/ca.crt")
client_config.set(authority, "revocations path", "/etc/openvpn/keys/ca.crl") client_config.set(authority, "revocations path", "/etc/openvpn/keys/ca.crl")
client_config.set(authority, "dhparam path", "/etc/openvpn/keys/dhparam.pem")
with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh:
client_config.write(fh) client_config.write(fh)
os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH)
@ -380,49 +357,38 @@ def certidude_setup_openvpn_server(authority, config, subnet, route, org_unit, l
service_config.add_section(endpoint) service_config.add_section(endpoint)
service_config.set(endpoint, "authority", authority) service_config.set(endpoint, "authority", authority)
service_config.set(endpoint, "service", "init/openvpn") service_config.set(endpoint, "service", "init/openvpn")
with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh: with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh:
service_config.write(fh) service_config.write(fh)
os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH) os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH)
click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH)) click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH))
dhparam_path = "/etc/openvpn/keys/dhparam.pem" authority_hostname = authority.split(".")[0]
if not os.path.exists(dhparam_path): config.write("server %s %s\n" % (subnet.network_address, subnet.netmask))
cmd = "openssl", "dhparam", "-out", dhparam_path, "2048" config.write("dev tun-%s\n" % authority_hostname)
subprocess.check_call(cmd)
config.write("mode server\n")
config.write("tls-server\n")
config.write("proto %s\n" % proto) config.write("proto %s\n" % proto)
config.write("port %d\n" % port) config.write("port %d\n" % port)
config.write("dev tap\n")
config.write("local %s\n" % local) config.write("local %s\n" % local)
config.write("key %s\n" % client_config.get(authority, "key path")) config.write("key %s\n" % client_config.get(authority, "key path"))
config.write("cert %s\n" % client_config.get(authority, "certificate path")) config.write("cert %s\n" % client_config.get(authority, "certificate path"))
config.write("ca %s\n" % client_config.get(authority, "authority path")) config.write("ca %s\n" % client_config.get(authority, "authority path"))
config.write("dh %s\n" % dhparam_path) config.write("dh %s\n" % client_config.get(authority, "dhparam path"))
config.write("comp-lzo\n") config.write("comp-lzo\n")
config.write("user nobody\n") config.write("user nobody\n")
config.write("group nogroup\n") config.write("group nogroup\n")
config.write("persist-tun\n") config.write("persist-tun\n")
config.write("persist-key\n") config.write("persist-key\n")
config.write("ifconfig-pool-persist /tmp/openvpn-leases.txt\n") config.write("#ifconfig-pool-persist /tmp/openvpn-leases.txt\n")
config.write("ifconfig %s 255.255.255.0\n" % subnet_first)
config.write("server-bridge %s 255.255.255.0 %s %s\n" % (subnet_first, subnet_second, subnet_last))
config.write("#crl-verify %s\n" % client_config.get(authority, "revocations path")) config.write("#crl-verify %s\n" % client_config.get(authority, "revocations path"))
click.echo("Generated %s" % config.name) click.echo("Generated %s" % config.name)
click.echo("Inspect generated files and issue following to request certificate:") click.echo("Inspect generated files and issue following to request certificate:")
click.echo() click.echo()
click.echo(" certidude request") click.echo(" certidude request")
click.echo()
click.echo("As OpenVPN server certificate needs specific key usage extensions please")
click.echo("use following command to sign on Certidude server instead of web interface:")
click.echo()
click.echo(" certidude sign %s" % const.HOSTNAME)
@click.command("nginx", help="Set up nginx as HTTPS server") @click.command("nginx", help="Set up nginx as HTTPS server")
@click.argument("server") @click.argument("authority")
@click.option("--common-name", "-cn", default=const.FQDN, help="Common name, %s by default" % const.FQDN) @click.option("--common-name", "-cn", default=const.FQDN, help="Common name, %s by default" % const.FQDN)
@click.option("--tls-config", @click.option("--tls-config",
default="/etc/nginx/conf.d/tls.conf", default="/etc/nginx/conf.d/tls.conf",
@ -439,51 +405,56 @@ def certidude_setup_openvpn_server(authority, config, subnet, route, org_unit, l
@click.option("--dhparam-path", "-dh", default="dhparam2048.pem", help="Diffie/Hellman parameters path, dhparam2048.pem relative to -d by default") @click.option("--dhparam-path", "-dh", default="dhparam2048.pem", help="Diffie/Hellman parameters path, dhparam2048.pem relative to -d by default")
@click.option("--authority-path", "-ca", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to -d by default") @click.option("--authority-path", "-ca", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to -d by default")
@click.option("--revocations-path", "-crl", default="ca.crl", help="Certificate revocation list, ca.crl relative to -d by default") @click.option("--revocations-path", "-crl", default="ca.crl", help="Certificate revocation list, ca.crl relative to -d by default")
@click.option("--verify-client", "-vc", type=click.Choice(['optional', 'on', 'off'])) @click.option("--verify-client", "-vc", default="optional", type=click.Choice(['optional', 'on', 'off']))
@expand_paths() @expand_paths()
def certidude_setup_nginx(authority, site_config, tls_config, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, revocations_path, dhparam_path, verify_client): def certidude_setup_nginx(authority, site_config, tls_config, common_name, directory, key_path, request_path, certificate_path, authority_path, revocations_path, dhparam_path, verify_client):
# TODO: Intelligent way of getting last IP address in the subnet if not os.path.exists("/etc/nginx"):
raise ValueError("nginx not installed")
if not os.path.exists(certificate_path): if "." not in common_name:
click.echo("As HTTPS server certificate needs specific key usage extensions please") raise ValueError("Fully qualified hostname not specified as common name, make sure hostname -f works")
click.echo("use following command to sign on Certidude server instead of web interface:") client_config = ConfigParser()
click.echo() if os.path.exists(const.CLIENT_CONFIG_PATH):
click.echo(" certidude sign %s" % common_name) client_config.readfp(open(const.CLIENT_CONFIG_PATH))
click.echo() if client_config.has_section(authority):
retval = certidude_request_certificate(authority, key_path, request_path, click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH))
certificate_path, authority_path, revocations_path, common_name, org_unit, else:
extended_key_usage_flags = [ExtendedKeyUsageOID.SERVER_AUTH], client_config.add_section(authority)
dns = const.FQDN, wait=True, bundle=True) client_config.set(authority, "trigger", "interface up")
client_config.set(authority, "common name", common_name)
if not os.path.exists(dhparam_path): client_config.set(authority, "request path", request_path)
cmd = "openssl", "dhparam", "-out", dhparam_path, "2048" client_config.set(authority, "key path", key_path)
subprocess.check_call(cmd) client_config.set(authority, "certificate path", certificate_path)
client_config.set(authority, "authority path", authority_path)
if retval: client_config.set(authority, "dhparam path", dhparam_path)
return retval client_config.set(authority, "revocations path", revocations_path)
with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh:
client_config.write(fh)
os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH)
click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH))
context = globals() # Grab const.BLAH context = globals() # Grab const.BLAH
context.update(locals()) context.update(locals())
if os.path.exists(site_client_config.name): if os.path.exists(site_config.name):
click.echo("Configuration file %s already exists, not overwriting" % site_client_config.name) click.echo("Configuration file %s already exists, not overwriting" % site_config.name)
else: else:
site_client_config.write(env.get_template("nginx-https-site.conf").render(context)) site_config.write(env.get_template("nginx-https-site.conf").render(context))
click.echo("Generated %s" % site_client_config.name) click.echo("Generated %s" % site_config.name)
if os.path.exists(tls_client_config.name): if os.path.exists(tls_config.name):
click.echo("Configuration file %s already exists, not overwriting" % tls_client_config.name) click.echo("Configuration file %s already exists, not overwriting" % tls_config.name)
else: else:
tls_client_config.write(env.get_template("nginx-tls.conf").render(context)) tls_config.write(env.get_template("nginx-tls.conf").render(context))
click.echo("Generated %s" % tls_client_config.name) click.echo("Generated %s" % tls_config.name)
click.echo() click.echo()
click.echo("Inspect configuration files, enable it and start nginx service:") click.echo("Inspect configuration files, enable it and start nginx service:")
click.echo() click.echo()
click.echo(" ln -s %s /etc/nginx/sites-enabled/%s" % ( click.echo(" ln -s %s /etc/nginx/sites-enabled/%s" % (
os.path.relpath(site_client_config.name, "/etc/nginx/sites-enabled"), os.path.relpath(site_config.name, "/etc/nginx/sites-enabled"),
os.path.basename(site_client_config.name))) os.path.basename(site_config.name)))
click.secho(" service nginx restart", bold=True) click.echo(" service nginx restart")
click.echo() click.echo()
@ -495,7 +466,7 @@ def certidude_setup_nginx(authority, site_config, tls_config, common_name, org_u
default="/etc/openvpn/client-to-site.conf", default="/etc/openvpn/client-to-site.conf",
type=click.File(mode="w", atomic=True, lazy=True), type=click.File(mode="w", atomic=True, lazy=True),
help="OpenVPN configuration file") help="OpenVPN configuration file")
def certidude_setup_openvpn_client(authority, remote, config, org_unit, proto): def certidude_setup_openvpn_client(authority, remote, config, proto):
# Create corresponding section in Certidude client configuration file # Create corresponding section in Certidude client configuration file
client_config = ConfigParser() client_config = ConfigParser()
@ -553,15 +524,10 @@ def certidude_setup_openvpn_client(authority, remote, config, org_unit, proto):
click.echo("Inspect generated files and issue following to request certificate:") click.echo("Inspect generated files and issue following to request certificate:")
click.echo() click.echo()
click.echo(" certidude request") click.echo(" certidude request")
click.echo()
click.echo("As OpenVPN server certificate needs specific key usage extensions please")
click.echo("use following command to sign on Certidude server instead of web interface:")
click.echo()
click.echo(" certidude sign %s" % const.HOSTNAME)
@click.command("server", help="Set up strongSwan server") @click.command("server", help="Set up strongSwan server")
@click.argument("server") @click.argument("authority")
@click.option("--subnet", "-sn", default=u"192.168.33.0/24", type=ip_network, help="IPsec virtual subnet, 192.168.33.0/24 by default") @click.option("--subnet", "-sn", default=u"192.168.33.0/24", type=ip_network, help="IPsec virtual subnet, 192.168.33.0/24 by default")
@click.option("--local", "-l", type=ip_address, help="IP address associated with the certificate, none by default") @click.option("--local", "-l", type=ip_address, help="IP address associated with the certificate, none by default")
@click.option("--route", "-r", type=ip_network, multiple=True, help="Subnets to advertise via this connection, multiple allowed") @click.option("--route", "-r", type=ip_network, multiple=True, help="Subnets to advertise via this connection, multiple allowed")
@ -580,8 +546,6 @@ def certidude_setup_strongswan_server(authority, config, secrets, subnet, route,
else: else:
client_config.set(authority, "trigger", "interface up") client_config.set(authority, "trigger", "interface up")
client_config.set(authority, "common name", const.FQDN) client_config.set(authority, "common name", const.FQDN)
client_config.set(authority, "subject alternative name dns", const.FQDN)
client_config.set(authority, "extended key usage flags", "server auth, ike intermediate")
client_config.set(authority, "request path", "/etc/ipsec.d/reqs/%s.pem" % const.HOSTNAME) client_config.set(authority, "request path", "/etc/ipsec.d/reqs/%s.pem" % const.HOSTNAME)
client_config.set(authority, "key path", "/etc/ipsec.d/private/%s.pem" % const.HOSTNAME) client_config.set(authority, "key path", "/etc/ipsec.d/private/%s.pem" % const.HOSTNAME)
client_config.set(authority, "certificate path", "/etc/ipsec.d/certs/%s.pem" % const.HOSTNAME) client_config.set(authority, "certificate path", "/etc/ipsec.d/certs/%s.pem" % const.HOSTNAME)
@ -617,9 +581,9 @@ def certidude_setup_strongswan_server(authority, config, secrets, subnet, route,
@click.command("client", help="Set up strongSwan client") @click.command("client", help="Set up strongSwan client")
@click.argument("server") @click.argument("authority")
@click.argument("remote") @click.argument("remote")
def certidude_setup_strongswan_client(authority, config, org_unit, remote, dpdaction): def certidude_setup_strongswan_client(authority, config, remote, dpdaction):
# Create corresponding section in /etc/certidude/client.conf # Create corresponding section in /etc/certidude/client.conf
client_config = ConfigParser() client_config = ConfigParser()
if os.path.exists(const.CLIENT_CONFIG_PATH): if os.path.exists(const.CLIENT_CONFIG_PATH):
@ -664,9 +628,9 @@ def certidude_setup_strongswan_client(authority, config, org_unit, remote, dpdac
@click.command("networkmanager", help="Set up strongSwan client via NetworkManager") @click.command("networkmanager", help="Set up strongSwan client via NetworkManager")
@click.argument("server") # Certidude server @click.argument("authority") # Certidude server
@click.argument("remote") # StrongSwan gateway @click.argument("remote") # StrongSwan gateway
def certidude_setup_strongswan_networkmanager(server,remote, org_unit): def certidude_setup_strongswan_networkmanager(authority, remote):
endpoint = "IPSec to %s" % remote endpoint = "IPSec to %s" % remote
# Create corresponding section in /etc/certidude/client.conf # Create corresponding section in /etc/certidude/client.conf
@ -679,7 +643,6 @@ def certidude_setup_strongswan_networkmanager(server,remote, org_unit):
client_config.add_section(authority) client_config.add_section(authority)
client_config.set(authority, "trigger", "interface up") client_config.set(authority, "trigger", "interface up")
client_config.set(authority, "common name", const.HOSTNAME) client_config.set(authority, "common name", const.HOSTNAME)
client_config.set(authority, "org unit", org_unit)
client_config.set(authority, "request path", "/etc/ipsec.d/reqs/%s.pem" % const.HOSTNAME) client_config.set(authority, "request path", "/etc/ipsec.d/reqs/%s.pem" % const.HOSTNAME)
client_config.set(authority, "key path", "/etc/ipsec.d/private/%s.pem" % const.HOSTNAME) client_config.set(authority, "key path", "/etc/ipsec.d/private/%s.pem" % const.HOSTNAME)
client_config.set(authority, "certificate path", "/etc/ipsec.d/certs/%s.pem" % const.HOSTNAME) client_config.set(authority, "certificate path", "/etc/ipsec.d/certs/%s.pem" % const.HOSTNAME)
@ -708,10 +671,10 @@ def certidude_setup_strongswan_networkmanager(server,remote, org_unit):
@click.command("networkmanager", help="Set up OpenVPN client via NetworkManager") @click.command("networkmanager", help="Set up OpenVPN client via NetworkManager")
@click.argument("server") # Certidude server @click.argument("authority")
@click.argument("remote") # OpenVPN gateway @click.argument("remote") # OpenVPN gateway
@click.option("--common-name", "-cn", default=const.HOSTNAME, help="Common name, %s by default" % const.HOSTNAME) @click.option("--common-name", "-cn", default=const.HOSTNAME, help="Common name, %s by default" % const.HOSTNAME)
def certidude_setup_openvpn_networkmanager(authority, org_unit, remote): def certidude_setup_openvpn_networkmanager(authority, remote):
# Create corresponding section in /etc/certidude/client.conf # Create corresponding section in /etc/certidude/client.conf
client_config = ConfigParser() client_config = ConfigParser()
if os.path.exists(const.CLIENT_CONFIG_PATH): if os.path.exists(const.CLIENT_CONFIG_PATH):
@ -750,29 +713,24 @@ def certidude_setup_openvpn_networkmanager(authority, org_unit, remote):
@click.command("authority", help="Set up Certificate Authority in a directory") @click.command("authority", help="Set up Certificate Authority in a directory")
@click.option("--username", default="certidude", help="Service user account, created if necessary, 'certidude' by default") @click.option("--username", default="certidude", help="Service user account, created if necessary, 'certidude' by default")
@click.option("--static-path", default=os.path.join(os.path.dirname(__file__), "static"), help="Path to Certidude's static JS/CSS/etc")
@click.option("--kerberos-keytab", default="/etc/certidude/server.keytab", help="Kerberos keytab for using 'kerberos' authentication backend, /etc/certidude/server.keytab by default") @click.option("--kerberos-keytab", default="/etc/certidude/server.keytab", help="Kerberos keytab for using 'kerberos' authentication backend, /etc/certidude/server.keytab by default")
@click.option("--nginx-config", "-n", @click.option("--nginx-config", "-n",
default="/etc/nginx/sites-available/certidude.conf", default="/etc/nginx/sites-available/certidude.conf",
type=click.File(mode="w", atomic=True, lazy=True), type=click.File(mode="w", atomic=True, lazy=True),
help="nginx site config for serving Certidude, /etc/nginx/sites-available/certidude by default") help="nginx site config for serving Certidude, /etc/nginx/sites-available/certidude by default")
@click.option("--parent", "-p", help="Parent CA, none by default")
@click.option("--common-name", "-cn", default=const.FQDN, help="Common name, fully qualified hostname by default") @click.option("--common-name", "-cn", default=const.FQDN, help="Common name, fully qualified hostname by default")
@click.option("--country", "-c", default=None, help="Country, none by default") @click.option("--country", "-c", default=None, help="Country, none by default")
@click.option("--state", "-s", default=None, help="State or country, none by default") @click.option("--state", "-s", default=None, help="State or country, none by default")
@click.option("--locality", "-l", default=None, help="City or locality, none by default") @click.option("--locality", "-l", default=None, help="City or locality, none by default")
@click.option("--authority-lifetime", default=20*365, help="Authority certificate lifetime in days, 7300 days (20 years) by default") @click.option("--authority-lifetime", default=20*365, help="Authority certificate lifetime in days, 20 years by default")
@click.option("--certificate-lifetime", default=5*365, help="Certificate lifetime in days, 1825 days (5 years) by default")
@click.option("--revocation-list-lifetime", default=20*60, help="Revocation list lifetime in days, 1200 seconds (20 minutes) by default")
@click.option("--organization", "-o", default=None, help="Company or organization name") @click.option("--organization", "-o", default=None, help="Company or organization name")
@click.option("--organizational-unit", "-ou", default=None) @click.option("--organizational-unit", "-ou", default=None)
@click.option("--revoked-url", default=None, help="CRL distribution URL")
@click.option("--certificate-url", default=None, help="Authority certificate URL")
@click.option("--push-server", default="http://" + const.FQDN, help="Push server, by default http://%s" % const.FQDN) @click.option("--push-server", default="http://" + const.FQDN, help="Push server, by default http://%s" % const.FQDN)
@click.option("--directory", help="Directory for authority files") @click.option("--directory", help="Directory for authority files")
@click.option("--server-flags", is_flag=True, help="Add TLS Server and IKE Intermediate extended key usage flags") @click.option("--server-flags", is_flag=True, help="Add TLS Server and IKE Intermediate extended key usage flags")
@click.option("--outbox", default="smtp://smtp.%s" % const.DOMAIN, help="SMTP server, smtp://smtp.%s by default" % const.DOMAIN) @click.option("--outbox", default="smtp://smtp.%s" % const.DOMAIN, help="SMTP server, smtp://smtp.%s by default" % const.DOMAIN)
def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_config, parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, revoked_url, certificate_url, push_server, outbox, server_flags): def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, state, locality, organization, organizational_unit, common_name, directory, authority_lifetime, push_server, outbox, server_flags):
openvpn_profile_template_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates", "openvpn-client.conf")
if not directory: if not directory:
if os.getuid(): if os.getuid():
@ -781,18 +739,13 @@ def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_conf
directory = os.path.join("/var/lib/certidude", const.FQDN) directory = os.path.join("/var/lib/certidude", const.FQDN)
click.echo("Using fully qualified hostname: %s" % common_name) click.echo("Using fully qualified hostname: %s" % common_name)
certificate_url = "http://%s/api/certificate/" % common_name
revoked_url = "http://%s/api/revoked/" % common_name
# Expand variables # Expand variables
if not revoked_url:
revoked_url = "http://%s/api/revoked/" % common_name
if not certificate_url:
certificate_url = "http://%s/api/certificate/" % common_name
ca_key = os.path.join(directory, "ca_key.pem") ca_key = os.path.join(directory, "ca_key.pem")
ca_crt = os.path.join(directory, "ca_crt.pem") ca_crt = os.path.join(directory, "ca_crt.pem")
if not static_path.endswith("/"):
static_path += "/"
if os.getuid() == 0: if os.getuid() == 0:
try: try:
pwd.getpwnam("certidude") pwd.getpwnam("certidude")
@ -833,6 +786,7 @@ def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_conf
working_directory = os.path.realpath(os.path.dirname(__file__)) working_directory = os.path.realpath(os.path.dirname(__file__))
certidude_path = sys.argv[0] certidude_path = sys.argv[0]
# Push server config generation
if not os.path.exists("/etc/nginx"): if not os.path.exists("/etc/nginx"):
click.echo("Directory /etc/nginx does not exist, hence not creating nginx configuration") click.echo("Directory /etc/nginx does not exist, hence not creating nginx configuration")
listen = "0.0.0.0" listen = "0.0.0.0"
@ -924,7 +878,6 @@ def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_conf
).add_extension( ).add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()),
critical=False critical=False
) )
if server_flags: if server_flags:
@ -1002,121 +955,100 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign
from certidude import authority from certidude import authority
from pycountry import countries from pycountry import countries
def dump_common(j): def dump_common(common_name, path, cert):
click.echo("certidude revoke %s" % common_name)
person = [j for j in (j.given_name, j.surname) if j] with open(path, "rb") as fh:
if person: buf = fh.read()
click.echo("Associated person: %s" % " ".join(person) + (" <%s>" % j.email_address if j.email_address else "")) click.echo("md5sum: %s" % hashlib.md5(buf).hexdigest())
elif j.email_address: click.echo("sha1sum: %s" % hashlib.sha1(buf).hexdigest())
click.echo("Associated e-mail: " + j.email_address) click.echo("sha256sum: %s" % hashlib.sha256(buf).hexdigest())
click.echo()
bits = [j for j in ( for ext in cert.extensions:
countries.get(alpha2=j.country_code.upper()).name if print " -", ext.value
j.country_code else "", click.echo()
j.state_or_county,
j.city,
j.organization,
j.organizational_unit) if j]
if bits:
click.echo("Organization: %s" % ", ".join(bits))
if show_key_type:
click.echo("Key type: %s-bit %s" % (j.key_length, j.key_type))
if show_extensions:
for key, value, data in j.extensions:
click.echo(("Extension " + key + ":").ljust(50) + " " + value)
else:
if j.key_usage:
click.echo("Key usage: " + j.key_usage)
if j.fqdn:
click.echo("Associated hostname: " + j.fqdn)
if not hide_requests: if not hide_requests:
for j in authority.list_requests(): for common_name, path, buf, csr, server in authority.list_requests():
created = 0
if not verbose: if not verbose:
click.echo("s " + j.path + " " + j.identity) click.echo("s " + path)
continue continue
click.echo(click.style(j.common_name, fg="blue")) click.echo(click.style(common_name, fg="blue"))
click.echo("=" * len(j.common_name)) click.echo("=" * len(common_name))
click.echo("State: ? " + click.style("submitted", fg="yellow") + " " + naturaltime(j.created) + click.style(", %s" %j.created, fg="white")) click.echo("State: ? " + click.style("submitted", fg="yellow") + " " + naturaltime(created) + click.style(", %s" %created, fg="white"))
click.echo("openssl req -in %s -text -noout" % path)
dump_common(common_name, path, cert)
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())
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: if show_signed:
for j in authority.list_signed(): for common_name, path, buf, cert, server in authority.list_signed():
if not verbose: if not verbose:
if j.signed < NOW and j.expires > NOW: if cert.not_valid_before < NOW and cert.not_valid_after > NOW:
click.echo("v " + j.path + " " + j.identity) click.echo("v " + path)
elif NOW > j.expires: elif NOW > cert.not_valid_after:
click.echo("e " + j.path + " " + j.identity) click.echo("e " + path)
else: else:
click.echo("y " + j.path + " " + j.identity) click.echo("y " + path)
continue continue
click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white")) click.echo(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial_number, fg="white"))
click.echo("="*(len(j.common_name)+60)) click.echo("="*(len(common_name)+60))
expires = 0 # TODO
if j.signed < NOW and j.expires > NOW: if cert.not_valid_before < NOW and cert.not_valid_after > NOW:
click.echo("Status: \u2713 " + click.style("valid", fg="green") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white")) click.echo("Status: " + click.style("valid", fg="green") + " until " + naturaltime(cert.not_valid_after) + click.style(", %s" % cert.not_valid_after, fg="white"))
elif NOW > j.expires: elif NOW > cert.not_valid_after:
click.echo("Status: \u2717 " + click.style("expired", fg="red") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white")) click.echo("Status: " + click.style("expired", fg="red") + " " + naturaltime(expires) + click.style(", %s" %expires, fg="white"))
else: else:
click.echo("Status: \u2717 " + click.style("not valid yet", fg="red") + click.style(", %s" %j.expires, fg="white")) click.echo("Status: " + click.style("not valid yet", fg="red") + click.style(", %s" %expires, fg="white"))
dump_common(j)
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.echo("openssl x509 -in %s -text -noout" % path)
dump_common(common_name, path, cert)
if show_revoked: if show_revoked:
for j in authority.list_revoked(): for common_name, path, buf, cert, server in authority.list_revoked():
if not verbose: if not verbose:
click.echo("r " + j.path + " " + j.identity) click.echo("r " + path)
continue 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(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial, fg="white"))
click.echo("Status: \u2717 " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-j.changed), click.style(", %s" % j.changed, fg="white"))) click.echo("="*(len(common_name)+60))
dump_common(j)
if show_path: _, _, _, _, _, _, _, _, mtime, _ = os.stat(path)
click.echo("Details: openssl x509 -in %s -text -noout" % j.path) changed = datetime.fromtimestamp(mtime)
click.echo() click.echo("Status: " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-changed), click.style(", %s" % changed, fg="white")))
click.echo("openssl x509 -in %s -text -noout" % path)
dump_common(common_name, path, cert)
click.echo() click.echo()
@click.command("sign", help="Sign certificates") @click.command("sign", help="Sign certificate")
@click.argument("common_name") @click.argument("common_name")
@click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN") @click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN")
@click.option("--lifetime", "-l", help="Lifetime") def certidude_sign(common_name, overwrite):
def certidude_sign(common_name, overwrite, lifetime): from certidude import authority
from certidude import authority, config cert = authority.sign(common_name, overwrite)
request = authority.get_request(common_name)
cert = authority.sign(request)
@click.command("revoke", help="Revoke certificate")
@click.argument("common_name")
def certidude_revoke(common_name):
from certidude import authority
authority.revoke(common_name)
@click.command("cron", help="Run from cron to manage Certidude server")
def certidude_cron():
import itertools
from certidude import authority, config
now = datetime.now()
for cn, path, buf, cert, server in itertools.chain(authority.list_signed(), authority.list_revoked()):
if cert.not_valid_after < now:
expired_path = os.path.join(config.EXPIRED_DIR, "%x.pem" % cert.serial)
assert not os.path.exists(expired_path)
os.rename(path, expired_path)
click.echo("Moved %s to %s" % (path, expired_path))
@click.command("serve", help="Run server") @click.command("serve", help="Run server")
@click.option("-p", "--port", default=8080 if os.getuid() else 80, help="Listen port") @click.option("-p", "--port", default=8080 if os.getuid() else 80, help="Listen port")
@click.option("-l", "--listen", default="0.0.0.0", help="Listen address") @click.option("-l", "--listen", default="0.0.0.0", help="Listen address")
@ -1276,9 +1208,6 @@ def certidude_setup_openvpn(): pass
@click.group("setup", help="Getting started section") @click.group("setup", help="Getting started section")
def certidude_setup(): pass def certidude_setup(): pass
@click.group("signer", help="Signer process management")
def certidude_signer(): pass
@click.group() @click.group()
def entry_point(): pass def entry_point(): pass
@ -1291,15 +1220,15 @@ certidude_setup_openvpn.add_command(certidude_setup_openvpn_networkmanager)
certidude_setup.add_command(certidude_setup_authority) certidude_setup.add_command(certidude_setup_authority)
certidude_setup.add_command(certidude_setup_openvpn) certidude_setup.add_command(certidude_setup_openvpn)
certidude_setup.add_command(certidude_setup_strongswan) certidude_setup.add_command(certidude_setup_strongswan)
certidude_setup.add_command(certidude_setup_client)
certidude_setup.add_command(certidude_setup_nginx) certidude_setup.add_command(certidude_setup_nginx)
entry_point.add_command(certidude_setup) entry_point.add_command(certidude_setup)
entry_point.add_command(certidude_serve) entry_point.add_command(certidude_serve)
entry_point.add_command(certidude_signer)
entry_point.add_command(certidude_request) entry_point.add_command(certidude_request)
entry_point.add_command(certidude_sign) entry_point.add_command(certidude_sign)
entry_point.add_command(certidude_revoke)
entry_point.add_command(certidude_list) entry_point.add_command(certidude_list)
entry_point.add_command(certidude_users) entry_point.add_command(certidude_users)
entry_point.add_command(certidude_cron)
if __name__ == "__main__": if __name__ == "__main__":
entry_point() entry_point()

View File

@ -38,27 +38,31 @@ AUTHORITY_CERTIFICATE_PATH = cp.get("authority", "certificate path")
REQUESTS_DIR = cp.get("authority", "requests dir") REQUESTS_DIR = cp.get("authority", "requests dir")
SIGNED_DIR = cp.get("authority", "signed dir") SIGNED_DIR = cp.get("authority", "signed dir")
REVOKED_DIR = cp.get("authority", "revoked dir") REVOKED_DIR = cp.get("authority", "revoked dir")
EXPIRED_DIR = cp.get("authority", "expired dir")
OUTBOX = cp.get("authority", "outbox uri") OUTBOX = cp.get("authority", "outbox uri")
OUTBOX_NAME = cp.get("authority", "outbox sender name") OUTBOX_NAME = cp.get("authority", "outbox sender name")
OUTBOX_MAIL = cp.get("authority", "outbox sender address") OUTBOX_MAIL = cp.get("authority", "outbox sender address")
BUNDLE_FORMAT = cp.get("authority", "bundle format") BUNDLE_FORMAT = cp.get("bundle", "format")
OPENVPN_BUNDLE_TEMPLATE = cp.get("authority", "openvpn bundle template") OPENVPN_PROFILE_TEMPLATE = cp.get("bundle", "openvpn profile template")
USER_CERTIFICATE_ENROLLMENT = { MACHINE_ENROLLMENT_ALLOWED = {
"forbidden": False, "allowed": True }[
cp.get("authority", "machine enrollment")]
USER_ENROLLMENT_ALLOWED = {
"forbidden": False, "single allowed": True, "multiple allowed": True }[ "forbidden": False, "single allowed": True, "multiple allowed": True }[
cp.get("authority", "user certificate enrollment")] cp.get("authority", "user enrollment")]
USER_MULTIPLE_CERTIFICATES = { USER_MULTIPLE_CERTIFICATES = {
"forbidden": False, "single allowed": False, "multiple allowed": True }[ "forbidden": False, "single allowed": False, "multiple allowed": True }[
cp.get("authority", "user certificate enrollment")] cp.get("authority", "user enrollment")]
CERTIFICATE_BASIC_CONSTRAINTS = "CA:FALSE" REQUEST_SUBMISSION_ALLOWED = cp.getboolean("authority", "request submission allowed")
CERTIFICATE_KEY_USAGE_FLAGS = "digitalSignature,keyEncipherment" CLIENT_CERTIFICATE_LIFETIME = cp.getint("signature", "client certificate lifetime")
CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS = "clientAuth" SERVER_CERTIFICATE_LIFETIME = cp.getint("signature", "server certificate lifetime")
CERTIFICATE_LIFETIME = cp.getint("signature", "certificate lifetime") AUTHORITY_CERTIFICATE_URL = cp.get("signature", "authority 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")
CERTIFICATE_RENEWAL_ALLOWED = cp.getboolean("signature", "renewal allowed")
REVOCATION_LIST_LIFETIME = cp.getint("signature", "revocation list lifetime") REVOCATION_LIST_LIFETIME = cp.getint("signature", "revocation list lifetime")

View File

@ -2,12 +2,9 @@ import falcon
import ipaddress import ipaddress
import json import json
import logging import logging
import re
import types import types
from datetime import date, time, datetime from datetime import date, time, datetime
from OpenSSL import crypto
from certidude.auth import User from certidude.auth import User
from certidude.wrappers import Request, Certificate
from urlparse import urlparse from urlparse import urlparse
logger = logging.getLogger("api") logger = logging.getLogger("api")
@ -52,21 +49,7 @@ def event_source(func):
return wrapped return wrapped
class MyEncoder(json.JSONEncoder): class MyEncoder(json.JSONEncoder):
REQUEST_ATTRIBUTES = "is_client", "identity", "changed", "common_name", \
"organizational_unit", "fqdn", \
"key_type", "key_length", "md5sum", "sha1sum", "sha256sum", "key_usage"
CERTIFICATE_ATTRIBUTES = "revokable", "identity", "common_name", \
"organizational_unit", "fqdn", \
"key_type", "key_length", "sha256sum", "serial_number", "key_usage", \
"signed", "expires"
def default(self, obj): 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): if isinstance(obj, ipaddress._IPAddressBase):
return str(obj) return str(obj)
if isinstance(obj, set): if isinstance(obj, set):
@ -77,17 +60,9 @@ class MyEncoder(json.JSONEncoder):
return obj.strftime("%Y-%m-%d") return obj.strftime("%Y-%m-%d")
if isinstance(obj, types.GeneratorType): if isinstance(obj, types.GeneratorType):
return tuple(obj) 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, User): if isinstance(obj, User):
return dict(name=obj.name, given_name=obj.given_name, return dict(name=obj.name, given_name=obj.given_name,
surname=obj.surname, mail=obj.mail) surname=obj.surname, mail=obj.mail)
if hasattr(obj, "serialize"):
return obj.serialize()
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, obj)
@ -96,29 +71,13 @@ def serialize(func):
Falcon response serialization Falcon response serialization
""" """
def wrapped(instance, req, resp, **kwargs): def wrapped(instance, req, resp, **kwargs):
if not req.client_accepts("application/json"):
logger.debug("Client did not accept application/json")
raise falcon.HTTPUnsupportedMediaType(
"Client did not accept application/json")
resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate") resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate")
resp.set_header("Pragma", "no-cache") resp.set_header("Pragma", "no-cache")
resp.set_header("Expires", "0") resp.set_header("Expires", "0")
r = func(instance, req, resp, **kwargs) resp.body = json.dumps(func(instance, req, resp, **kwargs), cls=MyEncoder)
if resp.body is None:
if req.accept.startswith("application/json"):
resp.set_header("Content-Type", "application/json")
resp.set_header("Content-Disposition", "inline")
resp.body = json.dumps(r, cls=MyEncoder)
elif hasattr(r, "content_type") and req.client_accepts(r.content_type):
resp.set_header("Content-Type", r.content_type)
resp.set_header("Content-Disposition",
("attachment; filename=%s" % r.suggested_filename).encode("ascii"))
resp.body = r.dump()
elif hasattr(r, "content_type"):
logger.debug(u"Client did not accept application/json or %s, "
"client expected %s", r.content_type, req.accept)
raise falcon.HTTPUnsupportedMediaType(
"Client did not accept application/json or %s" % r.content_type)
else:
logger.debug(u"Client did not accept application/json, client expected %s", req.accept)
raise falcon.HTTPUnsupportedMediaType(
"Client did not accept application/json")
return r
return wrapped return wrapped

View File

@ -4,18 +4,20 @@ import os
import requests import requests
import subprocess import subprocess
import tempfile import tempfile
from base64 import b64encode
from datetime import datetime, timedelta
from certidude import errors, const from certidude import errors, const
from certidude.wrappers import Certificate, Request
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa, padding
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
from cryptography.hazmat.primitives.serialization import Encoding from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, AuthorityInformationAccessOID from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, AuthorityInformationAccessOID
from configparser import ConfigParser from configparser import ConfigParser
from OpenSSL import crypto from cryptography import x509
from cryptography.hazmat.backends import default_backend
def certidude_request_certificate(server, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, autosign=False, wait=False, ip_address=None, bundle=False, insecure=False): def certidude_request_certificate(server, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, autosign=False, wait=False, bundle=False, insecure=False):
""" """
Exchange CSR for certificate using Certidude HTTP API server Exchange CSR for certificate using Certidude HTTP API server
""" """
@ -26,6 +28,8 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
if wait: if wait:
request_params.add("wait=forever") request_params.add("wait=forever")
renew = False # Attempt to renew if certificate has expired
# Expand ca.example.com # Expand ca.example.com
scheme = "http" if insecure else "https" # TODO: Expose in CLI scheme = "http" if insecure else "https" # TODO: Expose in CLI
authority_url = "%s://%s/api/certificate/" % (scheme, server) authority_url = "%s://%s/api/certificate/" % (scheme, server)
@ -41,13 +45,14 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
click.echo("Attempting to fetch authority certificate from %s" % authority_url) click.echo("Attempting to fetch authority certificate from %s" % authority_url)
try: try:
r = requests.get(authority_url, r = requests.get(authority_url,
headers={"Accept": "application/x-x509-ca-cert,application/x-pem-file"}) headers={"Accept": "application/x-x509-ca-cert,application/x-pem-file"})
cert = crypto.load_certificate(crypto.FILETYPE_PEM, r.text) x509.load_pem_x509_certificate(r.content, default_backend())
except crypto.Error: except:
raise ValueError("Failed to parse PEM: %s" % r.text) raise
# raise ValueError("Failed to parse PEM: %s" % r.text)
authority_partial = tempfile.mktemp(prefix=authority_path + ".part") authority_partial = tempfile.mktemp(prefix=authority_path + ".part")
with open(authority_partial, "w") as oh: with open(authority_partial, "w") as oh:
oh.write(r.text) oh.write(r.content)
click.echo("Writing authority certificate to: %s" % authority_path) click.echo("Writing authority certificate to: %s" % authority_path)
os.rename(authority_partial, authority_path) os.rename(authority_partial, authority_path)
@ -68,18 +73,19 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
# Check if we have been inserted into CRL # Check if we have been inserted into CRL
if os.path.exists(certificate_path): if os.path.exists(certificate_path):
cert = crypto.load_certificate(crypto.FILETYPE_PEM, open(certificate_path).read()) cert = x509.load_pem_x509_certificate(open(certificate_path).read(), default_backend())
revocation_list = crypto.load_crl(crypto.FILETYPE_PEM, open(revocations_path).read())
for revocation in revocation_list.get_revoked(): for revocation in x509.load_pem_x509_crl(open(revocations_path).read(), default_backend()):
if int(revocation.get_serial(), 16) == cert.get_serial_number(): extension, = revocation.extensions
if revocation.get_reason() == "Certificate Hold": # TODO: 'Remove From CRL'
# TODO: Disable service for time being if revocation.serial_number == cert.serial_number:
click.echo("Certificate put on hold, doing nothing for now") if extension.value.reason == x509.ReasonFlags.certificate_hold:
# Don't do anything for now
# TODO: disable service
break break
# Disable the client if operation has been ceased or # Disable the client if operation has been ceased
# the certificate has been superseded by other if extension.value.reason == x509.ReasonFlags.cessation_of_operation:
if revocation.get_reason() in ("Cessation Of Operation", "Superseded"):
if os.path.exists("/etc/certidude/client.conf"): if os.path.exists("/etc/certidude/client.conf"):
clients.readfp(open("/etc/certidude/client.conf")) clients.readfp(open("/etc/certidude/client.conf"))
if clients.has_section(server): if clients.has_section(server):
@ -87,9 +93,7 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
clients.write(open("/etc/certidude/client.conf", "w")) clients.write(open("/etc/certidude/client.conf", "w"))
click.echo("Authority operation ceased, disabling in /etc/certidude/client.conf") click.echo("Authority operation ceased, disabling in /etc/certidude/client.conf")
# TODO: Disable related services # TODO: Disable related services
if revocation.get_reason() in ("CA Compromise", "AA Compromise"): return
if os.path.exists(authority_path):
os.remove(key_path)
click.echo("Certificate has been revoked, wiping keys and certificates!") click.echo("Certificate has been revoked, wiping keys and certificates!")
if os.path.exists(key_path): if os.path.exists(key_path):
@ -102,9 +106,16 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
else: else:
click.echo("Certificate does not seem to be revoked. Good!") click.echo("Certificate does not seem to be revoked. Good!")
try: try:
request = Request(open(request_path)) request_buf = open(request_path).read()
request = x509.load_pem_x509_csr(request_buf, default_backend())
click.echo("Found signing request: %s" % request_path) click.echo("Found signing request: %s" % request_path)
with open(key_path) as fh:
key = serialization.load_pem_private_key(
fh.read(),
password=None,
backend=default_backend())
except EnvironmentError: except EnvironmentError:
# Construct private key # Construct private key
@ -146,9 +157,16 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
# Update CRL, renew certificate, maybe something extra? # Update CRL, renew certificate, maybe something extra?
if os.path.exists(certificate_path): if os.path.exists(certificate_path):
click.echo("Found certificate: %s" % certificate_path) cert_buf = open(certificate_path).read()
# TODO: Check certificate validity, download CRL? cert = x509.load_pem_x509_certificate(cert_buf, default_backend())
return lifetime = (cert.not_valid_after - cert.not_valid_before)
rollover = lifetime / 1 # TODO: Make rollover configurable
if datetime.now() > cert.not_valid_after - rollover:
click.echo("Certificate expired %s" % cert.not_valid_after)
renew = True
else:
click.echo("Found valid certificate: %s" % certificate_path)
return
# If machine is joined to domain attempt to present machine credentials for authentication # If machine is joined to domain attempt to present machine credentials for authentication
if os.path.exists("/etc/krb5.keytab"): if os.path.exists("/etc/krb5.keytab"):
@ -169,10 +187,25 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
auth = None 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, headers={
auth=auth, "Content-Type": "application/pkcs10",
data=open(request_path), "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"}) }
if renew:
signer = key.signer(
padding.PSS(
mgf=padding.MGF1(hashes.SHA512()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA512()
)
signer.update(cert_buf)
signer.update(request_buf)
headers["X-Renewal-Signature"] = b64encode(signer.finalize())
click.echo("Attached renewal signature %s" % headers["X-Renewal-Signature"])
submission = requests.post(request_url, auth=auth, data=open(request_path), headers=headers)
# Destroy service ticket # Destroy service ticket
if os.path.exists("/tmp/ca.ticket"): if os.path.exists("/tmp/ca.ticket"):
@ -192,8 +225,8 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
submission.raise_for_status() submission.raise_for_status()
try: try:
cert = crypto.load_certificate(crypto.FILETYPE_PEM, submission.text) cert = x509.load_pem_x509_certificate(submission.text.encode("ascii"), default_backend())
except crypto.Error: except: # TODO: catch correct exceptions
raise ValueError("Failed to parse PEM: %s" % submission.text) raise ValueError("Failed to parse PEM: %s" % submission.text)
os.umask(0o022) os.umask(0o022)

View File

@ -79,10 +79,10 @@ def send(template, to=None, attachments=(), **context):
msg.attach(part1) msg.attach(part1)
msg.attach(part2) msg.attach(part2)
for attachment in attachments: for attachment, content_type, suggested_filename in attachments:
part = MIMEBase(*attachment.content_type.split("/")) part = MIMEBase(*content_type.split("/"))
part.add_header('Content-Disposition', 'attachment', filename=attachment.suggested_filename) part.add_header('Content-Disposition', 'attachment', filename=suggested_filename)
part.set_payload(attachment.dump()) part.set_payload(attachment)
msg.attach(part) msg.attach(part)
# Gmail employs some sort of IPS # Gmail employs some sort of IPS

View File

@ -29,7 +29,8 @@ class SignHandler(asynchat.async_chat):
""" """
builder = x509.CertificateRevocationListBuilder( builder = x509.CertificateRevocationListBuilder(
).last_update(now ).last_update(
now - timedelta(minutes=5)
).next_update( ).next_update(
now + timedelta(seconds=config.REVOCATION_LIST_LIFETIME) now + timedelta(seconds=config.REVOCATION_LIST_LIFETIME)
).issuer_name(self.server.certificate.issuer ).issuer_name(self.server.certificate.issuer
@ -89,9 +90,12 @@ class SignHandler(asynchat.async_chat):
).public_key( ).public_key(
request.public_key() request.public_key()
).not_valid_before( ).not_valid_before(
now - timedelta(hours=1) now
).not_valid_after( ).not_valid_after(
now + timedelta(days=config.CERTIFICATE_LIFETIME) now + timedelta(days=
config.SERVER_CERTIFICATE_LIFETIME
if server_flags
else config.CLIENT_CERTIFICATE_LIFETIME)
).add_extension( ).add_extension(
x509.BasicConstraints( x509.BasicConstraints(
ca=False, ca=False,
@ -122,7 +126,7 @@ class SignHandler(asynchat.async_chat):
x509.AccessDescription( x509.AccessDescription(
AuthorityInformationAccessOID.CA_ISSUERS, AuthorityInformationAccessOID.CA_ISSUERS,
x509.UniformResourceIdentifier( x509.UniformResourceIdentifier(
config.CERTIFICATE_AUTHORITY_URL) config.AUTHORITY_CERTIFICATE_URL)
) )
]), ]),
critical=False critical=False

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M13 19h-2v2h-4v2h10v-2h-4v-2zm9 2h-4v2h4v-2zm-16 0h-4v2h4v-2zm18-11h-24v7h24v-7zm-22 5l.863-3h1.275l-.863 3h-1.275zm2.066 0l.863-3h1.275l-.863 3h-1.275zm2.067 0l.863-3h1.275l-.864 3h-1.274zm2.066 0l.863-3h1.274l-.863 3h-1.274zm3.341 0h-1.275l.864-3h1.275l-.864 3zm9.46-.5c-.552 0-1-.448-1-1s.448-1 1-1 1 .448 1 1-.448 1-1 1zm1-6.5h-20l4-7h12l4 7z"/></svg>

After

Width:  |  Height:  |  Size: 447 B

View File

@ -108,18 +108,23 @@ function onClientDown(e) {
function onRequestSigned(e) { function onRequestSigned(e) {
console.log("Request signed:", e.data); console.log("Request signed:", e.data);
var slug = e.data.replace("@", "--").replace(".", "-");
console.log("Removing:", slug);
$("#request-" + e.data.replace("@", "--").replace(".", "-")).slideUp("normal", function() { $(this).remove(); }); $("#request-" + slug).slideUp("normal", function() { $(this).remove(); });
$("#certificate-" + e.data.replace("@", "--").replace(".", "-")).slideUp("normal", function() { $(this).remove(); }); $("#certificate-" + slug).slideUp("normal", function() { $(this).remove(); });
$.ajax({ $.ajax({
method: "GET", method: "GET",
url: "/api/signed/" + e.data + "/", url: "/api/signed/" + e.data + "/",
dataType: "json", dataType: "json",
success: function(certificate, status, xhr) { success: function(certificate, status, xhr) {
console.info(certificate); console.info("Retrieved certificate:", certificate);
$("#signed_certificates").prepend( $("#signed_certificates").prepend(
nunjucks.render('views/signed.html', { certificate: certificate })); nunjucks.render('views/signed.html', { certificate: certificate }));
},
error: function(response) {
console.info("Failed to retrieve certificate:", response);
} }
}); });
} }
@ -234,7 +239,7 @@ $(document).ready(function() {
$(window).on("search", function() { $(window).on("search", function() {
var q = $("#search").val(); var q = $("#search").val();
$(".filterable").each(function(i, e) { $(".filterable").each(function(i, e) {
if ($(e).attr("data-dn").toLowerCase().indexOf(q) >= 0) { if ($(e).attr("data-cn").toLowerCase().indexOf(q) >= 0) {
$(e).show(); $(e).show();
} else { } else {
$(e).hide(); $(e).hide();

View File

@ -2,12 +2,10 @@
<section id="about"> <section id="about">
<h2>{{ session.user.gn }} {{ session.user.sn }} ({{session.user.name }}) settings</h2> <h2>{{ session.user.gn }} {{ session.user.sn }} ({{session.user.name }}) settings</h2>
<p>Mails will be sent to: {{ session.user.mail }}</p> <p title="Bundles are mainly intended for Android and iOS users">
Click <a href="/api/bundle/">here</a> to generate Android or iOS bundle for current user account.</p>
{% if session.authority.user_certificate_enrollment %} <p>Mails will be sent to: {{ session.user.mail }}</p>
<p>You can click <a href="/api/bundle/">here</a> to generate bundle
for current user account.</p>
{% endif %}
{% if session.authority %} {% if session.authority %}
@ -28,9 +26,9 @@ as such require complete reset of X509 infrastructure if some of them needs to b
{% endif %} {% endif %}
<p>User certificate enrollment: <p>User enrollment:
{% if session.authority.user_certificate_enrollment %} {% if session.authority.user_enrollment_allowed %}
{% if session.authority.user_mutliple_certificates %} {% if session.authority.user_multiple_certificates %}
multiple multiple
{% else %} {% else %}
single single
@ -42,10 +40,20 @@ forbidden
</p> </p>
<p>Web signed certificate attributes:</p> <p>Machine enrollment:
{% if session.authority.machine_enrollment_allowed %}
allowed
{% else %}
forbidden
{% endif %}
</p>
<p>Certificate attributes:</p>
<ul> <ul>
<li>Certificate lifetime: {{ session.authority.signature.certificate_lifetime }} days</li> <li>Server certificate lifetime: {{ session.authority.signature.server_certificate_lifetime }} days</li>
<li>Client certificate lifetime: {{ session.authority.signature.client_certificate_lifetime }} days</li>
<li>Revocation list lifetime: {{ session.authority.signature.revocation_list_lifetime }} seconds</li> <li>Revocation list lifetime: {{ session.authority.signature.revocation_list_lifetime }} seconds</li>
</ul> </ul>
@ -134,13 +142,13 @@ cat example.csr
</pre> </pre>
<p>Paste the contents here and click submit:</p> <p>Paste the contents here and click submit:</p>
<textarea id="request_body" style="width:100%; min-height: 4em;" placeholder="-----BEGIN CERTIFICATE REQUEST----- <textarea id="request_body" style="width:100%; min-height: 4em;" placeholder="-----BEGIN CERTIFICATE REQUEST-----"></textarea>
...
-----END CERTIFICATE REQUEST-----"></textarea>
<button class="icon upload" id="request_submit" style="float:none;">Submit</button> <button class="icon upload" id="request_submit" style="float:none;">Submit</button>
{% else %} {% else %}
<p>Submit a certificate signing request with Certidude:</p> <p>Submit a certificate signing request from Mac OS X, Ubuntu or Fedora:</p>
<pre>certidude setup client {{session.common_name}}</pre> <pre>easy_install pip
pip install certidude
certidude bootstrap {{session.authority.common_name}}</pre>
{% endif %} {% endif %}
<ul id="pending_requests"> <ul id="pending_requests">
@ -180,7 +188,8 @@ cat example.csr
<h1>Revoked certificates</h1> <h1>Revoked certificates</h1>
<p>To fetch <a href="{{window.location.href}}api/revoked/">certificate revocation list</a>:</p> <p>To fetch <a href="{{window.location.href}}api/revoked/">certificate revocation list</a>:</p>
<pre>curl {{window.location.href}}api/revoked/ > crl.der <pre>curl {{window.location.href}}api/revoked/ > crl.der
curl http://ca2.koodur.lan/api/revoked/?wait=yes -H "Accept: application/x-pem-file" > crl.pem</pre> curl http://ca2.koodur.lan/api/revoked/ -L -H "Accept: application/x-pem-file"
curl http://ca2.koodur.lan/api/revoked/?wait=yes -L -H "Accept: application/x-pem-file" > crl.pem</pre>
<!-- <!--
<p>To perform online certificate status request</p> <p>To perform online certificate status request</p>

View File

@ -1,13 +1,18 @@
<li id="request-{{ request.common_name | replace('@', '--') | replace('.', '-') }}" class="filterable"> <li id="request-{{ request.common_name | replace('@', '--') | replace('.', '-') }}" class="filterable">
<a class="button icon download" href="/api/request/{{request.common_name}}/">Fetch</a> <a class="button icon download" href="/api/request/{{request.common_name}}/">Fetch</a>
<button class="icon sign" onClick="javascript:$(this).addClass('busy');$.ajax({url:'/api/request/{{request.common_name}}/',type:'patch'});">Sign</button> <button class="icon sign" onClick="javascript:$(this).addClass('busy');$.ajax({url:'/api/request/{{request.common_name}}/?sha256sum={{ request.sha256sum }}',type:'patch'});">Sign</button>
<button class="icon revoke" onClick="javascript:$(this).addClass('busy');$.ajax({url:'/api/request/{{request.common_name}}/',type:'delete'});">Delete</button> <button class="icon revoke" onClick="javascript:$(this).addClass('busy');$.ajax({url:'/api/request/{{request.common_name}}/?sha256sum={{ request.sha256sum }}',type:'delete'});">Delete</button>
<div class="monospace"> <div class="monospace">
{% if request.server %}
{% include 'img/iconmonstr-server-1.svg' %}
{% else %}
{% include 'img/iconmonstr-certificate-15.svg' %} {% include 'img/iconmonstr-certificate-15.svg' %}
{{request.identity}} {% endif %}
{{request.common_name}}
</div> </div>
{% if request.email_address %} {% if request.email_address %}
@ -16,7 +21,7 @@
<div class="monospace"> <div class="monospace">
{% include 'img/iconmonstr-key-3.svg' %} {% include 'img/iconmonstr-key-3.svg' %}
<span title="SHA-1 of public key"> <span title="SHA-256 of certificate signing request">
{{ request.sha256sum }} {{ request.sha256sum }}
</span> </span>
{{ request.key_length }}-bit {{ request.key_length }}-bit

View File

@ -1,9 +1,14 @@
<li id="certificate-{{ certificate.common_name | replace('@', '--') | replace('.', '-') }}" data-dn="{{ certificate.identity }}" data-cn="{{ certificate.common_name }}" class="filterable"> <li id="certificate-{{ certificate.common_name | replace('@', '--') | replace('.', '-') }}" data-dn="CN={{ certificate.common_name }}" data-cn="{{ certificate.common_name }}" class="filterable">
<a class="button icon download" href="/api/signed/{{certificate.common_name}}/">Fetch</a> <a class="button icon download" href="/api/signed/{{certificate.common_name}}/">Fetch</a>
<button class="icon revoke" onClick="javascript:$(this).addClass('busy');$.ajax({url:'/api/signed/{{certificate.common_name}}/',type:'delete'});">Revoke</button> <button class="icon revoke" onClick="javascript:$(this).addClass('busy');$.ajax({url:'/api/signed/{{certificate.common_name}}/',type:'delete'});">Revoke</button>
<div class="monospace"> <div class="monospace">
{% if certificate.server %}
{% include 'img/iconmonstr-server-1.svg' %}
{% else %}
{% include 'img/iconmonstr-certificate-15.svg' %} {% include 'img/iconmonstr-certificate-15.svg' %}
{% endif %}
{{certificate.common_name}} {{certificate.common_name}}
</div> </div>

View File

@ -78,11 +78,33 @@ database = sqlite://{{ directory }}/db.sqlite
openvpn status uri = http://router.example.com/status.log openvpn status uri = http://router.example.com/status.log
[signature] [signature]
certificate lifetime = {{ certificate_lifetime }} # Server certificate is granted to certificate with
revocation list lifetime = {{ revocation_list_lifetime }} # common name that includes period which translates to FQDN of the machine.
certificate url = {{ certificate_url }} # TLS Server Auth and IKE Intermediate flags are attached to such certificate.
# Due to problematic CRL support in client applications
# we keep server certificate lifetime short and
# have it renewed automatically.
server certificate lifetime = 3
# Client certificates are granted to everything else
# TLS Client Auth flag is attached to such certificate.
# In this case it's set to 4 months.
client certificate lifetime = 120
revocation list lifetime = 24
# URL where CA certificate can be fetched from
authority certificate url = {{ certificate_url }}
# Strongswan can be configured to automatically fetch CRL
# in that case CRL URL has to be embedded in the certificate
revoked url = {{ revoked_url }} revoked url = {{ revoked_url }}
# If certificate renewal is allowed clients can request a certificate
# for the same public key with extended lifetime
renewal allowed = false
;renewal allowed = true
[push] [push]
event source token = {{ push_token }} event source token = {{ push_token }}
event source subscribe = {{ push_server }}/ev/sub/%s event source subscribe = {{ push_server }}/ev/sub/%s
@ -91,14 +113,25 @@ long poll subscribe = {{ push_server }}/lp/sub/%s
long poll publish = {{ push_server }}/lp/pub/%s long poll publish = {{ push_server }}/lp/pub/%s
[authority] [authority]
# Present form for CSR submission for logged in users
;request submission allowed = true
request submission allowed = false
# User certificate enrollment specifies whether logged in users are allowed to # User certificate enrollment specifies whether logged in users are allowed to
# request bundles. In case of 'single allowed' the common name of the # request bundles. In case of 'single allowed' the common name of the
# certificate is set to username, this should work well with REMOTE_USER # certificate is set to username, this should work well with REMOTE_USER
# enabled web apps running behind Apache/nginx. # enabled web apps running behind Apache/nginx.
# In case of 'multiple allowed' the common name is set to username@device-identifier. # In case of 'multiple allowed' the common name is set to username@device-identifier.
;user certificate enrollment = forbidden ;user enrollment = forbidden
;user certificate enrollment = single allowed ;user enrollment = single allowed
user certificate enrollment = multiple allowed user enrollment = multiple allowed
# Machine certificate enrollment specifies whether Kerberos authenticated
# machines are allowed to automatically enroll with certificate where
# common name is set to machine's account name
machine enrollment = forbidden
;machine enrollment = allowed
private key path = {{ ca_key }} private key path = {{ ca_key }}
certificate path = {{ ca_crt }} certificate path = {{ ca_crt }}
@ -112,8 +145,10 @@ outbox uri = {{ outbox }}
outbox sender name = Certificate management outbox sender name = Certificate management
outbox sender address = certificates@example.com outbox sender address = certificates@example.com
bundle format = p12 [bundle]
;bundle format = ovpn format = p12
;format = ovpn
openvpn bundle template = /etc/certidude/template.ovpn
# Template for OpenVPN profile, copy certidude/templates/openvpn-client.conf
# to /etc/certidude/ and make modifications as necessary
openvpn profile template = {{ openvpn_profile_template_path }}

View File

@ -0,0 +1,9 @@
Renewed {{ common_name }} ({{ serial_number }})
This is simply to notify that certificate for {{ common_name }}
was renewed and the serial number of the new certificate is {{ serial_number }}.
The new certificate is valid from {{ certificate.not_valid_before }} until
{{ certificate.not_valid_after }}.
Services making use of those certificates should continue working as expected.

View File

@ -1,6 +1,6 @@
Certificate {{certificate.common_name}} ({{certificate.serial_number}}) revoked Revoked {{ common_name }} ({{ serial_number }})
This is simply to notify that certificate {{ certificate.common_name }} This is simply to notify that certificate {{ common_name }}
was revoked. was revoked.
Services making use of this certificates might become unavailable. Services making use of this certificates might become unavailable.

View File

@ -1,7 +1,11 @@
Certificate {{certificate.common_name}} ({{certificate.serial_number}}) signed Signed {{ common_name }} ({{ serial_number }})
This is simply to notify that certificate {{ certificate.common_name }} This is simply to notify that certificate {{ common_name }}
with serial number {{ serial_number }}
was signed{% if signer %} by {{ signer }}{% endif %}. was signed{% if signer %} by {{ signer }}{% endif %}.
The certificate is valid from {{ certificate.not_valid_before }} until
{{ certificate.not_valid_after }}.
Any existing certificates with the same common name were rejected by doing so Any existing certificates with the same common name were rejected by doing so
and services making use of those certificates might become unavailable. and services making use of those certificates might become unavailable.

View File

@ -1,5 +1,5 @@
Certificate signing request {{request.common_name}} stored Stored request {{ common_name }}
This is simply to notify that certificate signing request for {{ request.common_name }} This is simply to notify that certificate signing request for {{ common_name }}
was stored. You may log in with a certificate authority administration account to sign it. was stored. You may log in with a certificate authority administration account to sign it.

View File

@ -0,0 +1,5 @@
Stored request {{ common_name }}
This is simply to notify that certificate signing request for {{ common_name }}
was stored. You may log in with a certificate authority administration account to sign it.

View File

@ -1,5 +1,6 @@
ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Following are already enabled by /etc/nginx/nginx.conf
ssl_prefer_server_ciphers on; #ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
#ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m; ssl_session_cache shared:SSL:10m;
ssl_ciphers "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; ssl_ciphers "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
ssl_dhparam {{dhparam_path}}; ssl_dhparam {{dhparam_path}};

View File

@ -7,16 +7,22 @@ nobind
# OpenVPN gateway(s), uncomment remote-random to load balance # OpenVPN gateway(s), uncomment remote-random to load balance
comp-lzo comp-lzo
proto udp proto udp
remote 1.2.3.4 {% if servers %}
;remote 1.2.3.5 remote-random
;remote-random {% for server in servers %}
remote {{ server }} 51900
{% endfor %}
{% else %}
remote 1.2.3.4 1194
{% endif %}
# Virtual network interface settings # Virtual network interface settings
dev tun dev tun
persist-tun persist-tun
# Customize crypto settings # Customize crypto settings
;tls-cipher TLS-DHE-RSA-WITH-AES-256-CBC-SHA384 ;tls-version-min 1.2
;tls-cipher TLS-DHE-RSA-WITH-AES-256-GCM-SHA384
;cipher AES-256-CBC ;cipher AES-256-CBC
;auth SHA384 ;auth SHA384
@ -36,12 +42,12 @@ persist-key
</cert> </cert>
# Revocation list # Revocation list
<crl-verify> # Tunnelblick doens't handle inlined CRL
{{crl}} # hard to update as well
</crl-verify> ;<crl-verify>
;</crl-verify>
# Pre-shared key for extra layer of security # Pre-shared key for extra layer of security
;<ta> ;<ta>
;...
;</ta> ;</ta>

View File

@ -84,8 +84,6 @@ class DirectoryConnection(object):
class ActiveDirectoryUserManager(object): class ActiveDirectoryUserManager(object):
def get(self, username): def get(self, username):
# TODO: Sanitize username # TODO: Sanitize username
if "@" in username:
username, _ = username.split("@", 1)
with DirectoryConnection() as conn: with DirectoryConnection() as conn:
ft = config.LDAP_USER_FILTER % username ft = config.LDAP_USER_FILTER % username
attribs = "cn", "givenName", "sn", "mail", "userPrincipalName" attribs = "cn", "givenName", "sn", "mail", "userPrincipalName"

View File

@ -1,248 +0,0 @@
import os
import hashlib
import re
import click
import io
from certidude import const
from OpenSSL import crypto
from datetime import datetime
def subject2dn(subject):
bits = []
for j in "CN", "GN", "SN", "C", "S", "L", "O", "OU":
if getattr(subject, j, None):
bits.append("%s=%s" % (j, getattr(subject, j)))
return ", ".join(bits)
class CertificateBase:
# Others will cause browsers to import the cert instead of offering to
# download it
content_type = "application/x-pem-file"
def __repr__(self):
return self.buf
@property
def common_name(self):
return self.subject.CN
@common_name.setter
def common_name(self, value):
self.subject.CN = value
@property
def key_usage(self):
def iterate():
for key, value, data in self.extensions:
if key == "keyUsage" or key == "extendedKeyUsage":
for bit in value.split(", "):
if bit == "1.3.6.1.5.5.8.2.2":
yield "IKE Intermediate"
else:
yield bit
return ", ".join(iterate())
@property
def subject(self):
return self._obj.get_subject()
@property
def issuer(self):
return self._obj.get_issuer()
@property
def issuer_dn(self):
return subject2dn(self.issuer)
@property
def identity(self):
return subject2dn(self.subject)
@property
def key_length(self):
return self._obj.get_pubkey().bits()
@property
def key_type(self):
if self._obj.get_pubkey().type() == 6:
return "RSA"
else:
raise NotImplementedError()
@property
def extensions(self):
for e in self._obj.get_extensions():
yield e.get_short_name().decode("ascii"), str(e), e.get_data()
def set_extensions(self, extensions):
# X509Req().add_extensions() first invocation takes only effect?!
assert self._obj.get_extensions() == [], "Extensions already set!"
self._obj.add_extensions([
crypto.X509Extension(
key.encode("ascii"),
critical,
value.encode("ascii")) for (key,value,critical) in extensions])
@property
def fqdn(self):
for bit in self.subject_alt_name.split(", "):
if bit.startswith("DNS:"):
return bit[4:]
return ""
@property
def pubkey(self):
from Crypto.Util import asn1
pubkey_asn1=crypto.dump_privatekey(crypto.FILETYPE_ASN1, self._obj.get_pubkey())
pubkey_der=asn1.DerSequence()
pubkey_der.decode(pubkey_asn1)
zero, modulo, exponent = pubkey_der
return modulo, exponent
@property
def pubkey_hex(self):
modulo, exponent = self.pubkey
h = "%x" % modulo
assert len(h) * 4 == self.key_length, "%s is not %s" % (len(h)*4, self.key_length)
return re.findall("\d\d", h)
def fingerprint(self, algorithm="sha256"):
return hashlib.new(algorithm, self.buf.encode("ascii")).hexdigest()
@property
def md5sum(self):
return self.fingerprint("md5")
@property
def sha1sum(self):
return self.fingerprint("sha1")
@property
def sha256sum(self):
return self.fingerprint("sha256")
class Request(CertificateBase):
@property
def suggested_filename(self):
return self.common_name + ".csr"
def __init__(self, mixed=None):
self.buf = None
self.path = NotImplemented
self.created = NotImplemented
if hasattr(mixed, "read"):
self.path = mixed.name
_, _, _, _, _, _, _, _, mtime, _ = os.stat(self.path)
self.created = datetime.fromtimestamp(mtime)
mixed = mixed.read()
if isinstance(mixed, str):
try:
self.buf = mixed
mixed = crypto.load_certificate_request(crypto.FILETYPE_PEM, mixed)
except crypto.Error:
raise ValueError("Failed to parse: %s" % mixed)
if isinstance(mixed, crypto.X509Req):
self._obj = mixed
else:
raise ValueError("Can't parse %s (%s) as X.509 certificate signing request!" % (mixed, type(mixed)))
assert not self.buf or self.buf == self.dump(), "%s is not %s" % (repr(self.buf), repr(self.dump()))
@property
def is_server(self):
return "." in self.common_name
@property
def is_client(self):
return not self.is_server
def dump(self):
return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self._obj).decode("ascii")
def create(self):
# Generate 4096-bit RSA key
key = crypto.PKey()
key.generate_key(crypto.TYPE_RSA, 4096)
# Create request
req = crypto.X509Req()
req.set_pubkey(key)
return Request(req)
class Certificate(CertificateBase):
@property
def suggested_filename(self):
return self.common_name + ".crt"
def __init__(self, mixed):
self.buf = NotImplemented
self.path = NotImplemented
self.changed = NotImplemented
if hasattr(mixed, "read"):
self.path = mixed.name
_, _, _, _, _, _, _, _, mtime, _ = os.stat(self.path)
self.changed = datetime.fromtimestamp(mtime)
mixed = mixed.read()
if isinstance(mixed, str):
try:
self.buf = mixed
mixed = crypto.load_certificate(crypto.FILETYPE_PEM, mixed)
except crypto.Error:
raise ValueError("Failed to parse: %s" % mixed)
if isinstance(mixed, crypto.X509):
self._obj = mixed
else:
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())
@property
def extensions(self):
# WTF?!
for j in range(0, self._obj.get_extension_count()):
e = self._obj.get_extension(j)
yield e.get_short_name().decode("ascii"), str(e), e.get_data()
@property
def serial_number(self):
return "%040x" % self._obj.get_serial_number()
@property
def serial_number_hex(self):
return ":".join(re.findall("[0123456789abcdef]{2}", self.serial_number))
@property
def signed(self):
return datetime.strptime(self._obj.get_notBefore().decode("ascii"), "%Y%m%d%H%M%SZ")
@property
def expires(self):
return datetime.strptime(self._obj.get_notAfter().decode("ascii"), "%Y%m%d%H%M%SZ")
def dump(self):
return crypto.dump_certificate(crypto.FILETYPE_PEM, self._obj).decode("ascii")
def digest(self):
return self._obj.digest("md5").decode("ascii")
def __eq__(self, other):
return self.serial_number == other.serial_number
def __gt__(self, other):
return self.signed > other.signed
def __lt__(self, other):
return self.signed < other.signed
def __gte__(self, other):
return self.signed >= other.signed
def __lte__(self, other):
return self.signed <= other.signed

View File

@ -4,7 +4,7 @@ cryptography==1.7.2
falcon==1.1.0 falcon==1.1.0
humanize==0.5.1 humanize==0.5.1
ipaddress==1.0.18 ipaddress==1.0.18
Jinja2==2.8 Jinja2==2.9.5
Markdown==2.6.8 Markdown==2.6.8
pyldap==2.4.28 pyldap==2.4.28
requests==2.10.0 requests==2.10.0