* 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.
* `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.
* Deep mailbox integration, eg fetch CSR-s from mailbox via IMAP.
* `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.
* WebCrypto support, meanwhile check out `hwcrypto.js <https://github.com/open-eid/hwcrypto.js>`_.
* Certificate push/pull, making it possible to sign offline.
* 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
* Ability to send OpenVPN profile URL tokens via e-mail, for simplified VPN adoption.
* 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
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
* Common name is set to short hostname/machine name in AD
* E-mail is not filled in (maybe we can fill in something from AD?)
* Given name and surname are not filled in
* If Kerberos credentials are presented machine can be automatically enrolled depending on the ``machine enrollment`` setting
* Common name is set to short ``hostname``
* It is tricky to determine user who is triggering the action so given name, surname and e-mail attributes are not filled in
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
* Given name and surname are filled in based on LDAP attributes of the user
* E-mail not filled in (should it be filled in? Can we even send mail to user if it's in external domain?)
* Common name is either set to ``username`` or ``username@device-identifier`` depending on the ``user enrollment`` setting
* Given name and surname are not filled in because Unicode characters cause issues in OpenVPN Connect app
* E-mail is not filled in because it might change in AD

View File

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

View File

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

View File

@ -4,91 +4,125 @@ import falcon
import logging
import ipaddress
import os
import hashlib
from base64 import b64decode
from certidude import config, authority, helpers, push, errors
from certidude.auth import login_required, login_optional, authorize_admin
from certidude.decorators import serialize, csrf_protection
from certidude.wrappers import Request, Certificate
from certidude.firewall import whitelist_subnets, whitelist_content_types
from cryptography import x509
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")
class RequestListResource(object):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
return authority.list_requests()
@login_optional
@whitelist_subnets(config.REQUEST_SUBNETS)
@whitelist_content_types("application/pkcs10")
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)
# Normalize body, TODO: newlines
if not body.endswith("\n"):
body += "\n"
csr = Request(body)
if not csr.common_name:
csr = x509.load_pem_x509_csr(body, default_backend())
try:
common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
except: # ValueError?
logger.warning(u"Rejected signing request without common name from %s",
req.context.get("remote_addr"))
raise falcon.HTTPBadRequest(
"Bad request",
"No common name specified!")
"""
Handle domain computer automatic enrollment
"""
machine = req.context.get("machine")
if machine:
if csr.common_name != machine:
if config.MACHINE_ENROLLMENT_ALLOWED and machine:
if common_name.value != machine:
raise falcon.HTTPBadRequest(
"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
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
# 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:
cert = authority.get_signed(csr.common_name)
path, buf, cert = authority.get_signed(common_name.value)
except EnvironmentError:
pass
else:
if cert.pubkey == csr.pubkey:
resp.status = falcon.HTTP_SEE_OTHER
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name)
return
if cert.public_key().public_numbers() == csr.public_key().public_numbers():
try:
renewal_signature = b64decode(req.get_header("X-Renewal-Signature"))
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:
if req.context.get("remote_addr") in subnet:
try:
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
except EnvironmentError: # Certificate already exists, try to save the request
pass
except EnvironmentError:
logger.info("Autosign for %s failed, signed certificate already exists",
common_name.value, req.context.get("remote_addr"))
break
# Attempt to save the request otherwise
try:
csr = authority.store_request(body)
except errors.RequestExists:
# We should stil redirect client to long poll URL below
# We should still redirect client to long poll URL below
pass
except errors.DuplicateCommonNameError:
# TODO: Certificate renewal
@ -98,12 +132,13 @@ class RequestListResource(object):
"CSR with such CN already exists",
"Will not overwrite existing certificate signing request, explicitly delete CSR and try again")
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
logger.info(u"Signing request %s from %s stored", common_name.value, req.context.get("remote_addr"))
if req.get_param("wait"):
# 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)
resp.status = falcon.HTTP_SEE_OTHER
resp.set_header("Location", url.encode("ascii"))
@ -111,20 +146,17 @@ class RequestListResource(object):
else:
# Request was accepted, but not processed
resp.status = falcon.HTTP_202
logger.info(u"Signing request from %s stored", req.context.get("remote_addr"))
class RequestDetailResource(object):
@serialize
def on_get(self, req, resp, cn):
"""
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",
csr.common_name, req.context.get("remote_addr"))
return csr
cn, req.context.get("remote_addr"))
@csrf_protection
@login_required
@ -133,16 +165,15 @@ class RequestDetailResource(object):
"""
Sign a certificate signing request
"""
csr = authority.get_request(cn)
cert = authority.sign(csr, overwrite=True, delete=True)
os.unlink(csr.path)
cert, buf = authority.sign(cn, overwrite=True)
# Mailing and long poll publishing implemented in the function above
resp.body = "Certificate successfully signed"
resp.status = falcon.HTTP_201
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"))
@csrf_protection
@login_required
@authorize_admin

View File

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

View File

@ -1,38 +1,46 @@
import falcon
import logging
import json
import hashlib
from certidude import authority
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")
class SignedCertificateListResource(object):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
return {"signed":authority.list_signed()}
class SignedCertificateDetailResource(object):
@serialize
def on_get(self, req, resp, cn):
# Compensate for NTP lag
# from time import sleep
# sleep(5)
preferred_type = req.client_prefers(("application/json", "application/x-pem-file"))
try:
cert = authority.get_signed(cn)
path, buf, cert = authority.get_signed(cn)
except EnvironmentError:
logger.warning(u"Failed to serve non-existant certificate %s to %s",
cn, req.context.get("remote_addr"))
resp.body = "No certificate CN=%s found" % cn
raise falcon.HTTPNotFound()
raise falcon.HTTPNotFound("No certificate CN=%s found" % cn)
else:
logger.debug(u"Served certificate %s to %s",
cn, req.context.get("remote_addr"))
return cert
if preferred_type == "application/x-pem-file":
resp.set_header("Content-Type", "application/x-pem-file")
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
@login_required
@ -40,5 +48,5 @@ class SignedCertificateDetailResource(object):
def on_delete(self, req, resp, cn):
logger.info(u"Revoked certificate %s by %s from %s",
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:
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 wrapper(func):
@ -38,7 +40,7 @@ def authenticate(optional=False):
# If LDAP enabled and device is not Kerberos capable fall
# back to LDAP bind authentication
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)
# Try pre-emptive authentication
@ -81,16 +83,20 @@ def authenticate(optional=False):
raise falcon.HTTPForbidden("Forbidden",
"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
# TODO: Assert LDAP group membership
req.context["machine"], _ = user.lower().split("$@", 1)
req.context["machine"] = username[:-1].lower()
req.context["user"] = None
else:
# Attempt to look up real user
req.context["user"] = User.objects.get(user)
req.context["user"] = User.objects.get(username)
try:
kerberos.authGSSServerClean(context)
@ -143,12 +149,8 @@ def authenticate(optional=False):
conn = ldap.initialize(config.LDAP_AUTHENTICATION_URI)
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:
conn.simple_bind_s(user, passwd)
conn.simple_bind_s("%s@%s" % (user, const.DOMAIN), passwd)
except ldap.STRONG_AUTH_REQUIRED:
logger.critical("LDAP server demands encryption, use ldaps:// instead of ldaps://")
raise
@ -160,8 +162,8 @@ def authenticate(optional=False):
logger.critical(u"LDAP bind authentication failed for user %s from %s",
repr(user), req.context.get("remote_addr"))
raise falcon.HTTPUnauthorized("Forbidden",
"Please authenticate with %s domain account or supply UPN" % const.DOMAIN,
("Basic",))
"Please authenticate with %s domain account username" % const.DOMAIN,
("Basic",))
req.context["ldap_conn"] = conn
req.context["user"] = User.objects.get(user)

View File

@ -4,15 +4,15 @@ import os
import random
import re
import requests
import hashlib
import socket
from datetime import datetime, timedelta
from cryptography.hazmat.backends import default_backend
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtensionOID, AuthorityInformationAccessOID
from cryptography.x509.oid import NameOID, ExtensionOID, ExtendedKeyUsageOID
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import hashes, serialization
from certidude import config, push, mailer, const
from certidude.wrappers import Certificate, Request
from certidude import errors
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
# 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):
if not re.match(RE_HOSTNAME, 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):
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(common_name):
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")))
path = os.path.join(config.SIGNED_DIR, common_name + ".pem")
with open(path) as fh:
buf = fh.read()
return path, buf, x509.load_pem_x509_certificate(buf, default_backend())
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):
"""
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())
for name in csr.subject:
if name.oid == NameOID.COMMON_NAME:
common_name = name.value
break
else:
raise ValueError("No common name in %s" % csr.subject)
common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
# TODO: validate common name again
request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
if not re.match(RE_HOSTNAME, common_name):
if not re.match(RE_HOSTNAME, common_name.value):
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 os.path.exists(request_path):
if open(request_path).read() == buf:
@ -99,9 +79,11 @@ def store_request(buf, overwrite=False):
fh.write(buf)
os.rename(request_path + ".part", request_path)
req = Request(open(request_path))
mailer.send("request-stored.md", attachments=(req,), request=req)
return req
attach_csr = buf, "application/x-pem-file", common_name.value + ".csr"
mailer.send("request-stored.md",
attachments=(attach_csr,),
common_name=common_name.value)
return csr
def signer_exec(cmd, *bits):
@ -118,14 +100,15 @@ def signer_exec(cmd, *bits):
return buf
def revoke_certificate(common_name):
def revoke(common_name):
"""
Revoke valid certificate
"""
cert = get_signed(common_name)
revoked_filename = os.path.join(config.REVOKED_DIR, "%s.pem" % cert.serial_number)
os.rename(cert.path, revoked_filename)
push.publish("certificate-revoked", cert.common_name)
path, buf, cert = get_signed(common_name)
revoked_path = os.path.join(config.REVOKED_DIR, "%x.pem" % cert.serial_number)
signed_path = os.path.join(config.SIGNED_DIR, "%s.pem" % common_name)
os.rename(signed_path, revoked_path)
push.publish("certificate-revoked", common_name)
# Publish CRL for long polls
if config.LONG_POLL_PUBLISH:
@ -134,26 +117,52 @@ def revoke_certificate(common_name):
requests.post(url, data=export_crl(),
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):
for filename in os.listdir(directory):
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_signed(directory=config.SIGNED_DIR):
def _list_certificates(directory):
for filename in os.listdir(directory):
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):
for filename in os.listdir(directory):
if filename.endswith(".pem"):
yield Certificate(open(os.path.join(directory, filename)))
def list_revoked():
return _list_certificates(config.REVOKED_DIR)
def export_crl():
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
@ -178,14 +187,15 @@ def delete_request(common_name):
raise ValueError("Invalid common name")
path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
request = Request(open(path))
_, buf, csr = get_request(common_name)
os.unlink(path)
# 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
requests.delete(config.LONG_POLL_PUBLISH % request.fingerprint(),
requests.delete(
config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest(),
headers={"User-Agent": "Certidude API"})
def generate_ovpn_bundle(common_name, owner=None):
@ -198,26 +208,26 @@ def generate_ovpn_bundle(common_name, owner=None):
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([
x509.NameAttribute(k, v) for k, v in (
(NameOID.COMMON_NAME, common_name),
) if v
]))
])).sign(key, hashes.SHA512(), default_backend())
buf = csr.public_bytes(serialization.Encoding.PEM)
# Sign CSR
cert = sign(Request(
csr.sign(key, hashes.SHA512(), default_backend()).public_bytes(serialization.Encoding.PEM)), overwrite=True)
cert, cert_buf = _sign(csr, buf, overwrite=True)
bundle = Template(open(config.OPENVPN_BUNDLE_TEMPLATE).read()).render(
ca = certificate.dump(),
key = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
),
cert = cert.dump(),
crl=export_crl(),
)
bundle = Template(open(config.OPENVPN_PROFILE_TEMPLATE).read()).render(
ca = ca_buf, key = key_buf, cert = cert_buf, crl=export_crl(),
servers = [cn for cn, path, buf, cert, server in list_signed() if server])
return bundle, cert
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([
x509.NameAttribute(NameOID.COMMON_NAME, common_name)
]))
])).sign(key, hashes.SHA512(), default_backend())
buf = csr.public_bytes(serialization.Encoding.PEM)
# Sign CSR
cert = sign(Request(
csr.sign(key, hashes.SHA512(), default_backend()).public_bytes(serialization.Encoding.PEM)), overwrite=True)
cert, cert_buf = _sign(csr, buf, overwrite=True)
# Generate P12, currently supported only by PyOpenSSL
try:
@ -256,131 +267,102 @@ def generate_pkcs12_bundle(common_name, key_size=4096, owner=None):
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
)
)
p12.set_certificate( cert._obj )
p12.set_ca_certificates([certificate._obj])
encryption_algorithm=serialization.NoEncryption())))
p12.set_certificate(
crypto.load_certificate(crypto.FILETYPE_PEM, cert_buf))
p12.set_ca_certificates([
crypto.load_certificate(crypto.FILETYPE_PEM, ca_buf)])
return p12.export("1234"), cert
@publish_certificate
def sign(req, overwrite=False, delete=True):
def sign(common_name, overwrite=False):
"""
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
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:
revoke_certificate(req.common_name)
elif req.pubkey == old_cert.pubkey:
return old_cert
if renew:
# TODO: is this the best approach?
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:
raise EnvironmentError("Will not overwrite existing certificate")
# 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:
fh.write(cert_buf)
os.rename(cert_path + ".part", cert_path)
return Certificate(open(cert_path))
# Send mail
recipient = None
@publish_certificate
def sign2(request, private_key, authority_certificate, overwrite=False, delete=True, lifetime=None):
"""
Sign directly using private key, this is usually done by root.
Basic constraints and certificate lifetime are copied from config,
lifetime may be overridden on the command line,
other extensions are copied as is.
"""
certificate_path = os.path.join(config.SIGNED_DIR, request.common_name + ".pem")
if os.path.exists(certificate_path):
if overwrite:
revoke_certificate(request.common_name)
else:
raise errors.DuplicateCommonNameError("Valid certificate with common name %s already exists" % request.common_name)
now = datetime.utcnow()
request_path = os.path.join(config.REQUESTS_DIR, request.common_name + ".pem")
request = x509.load_pem_x509_csr(open(request_path).read(), default_backend())
cert = x509.CertificateBuilder(
).subject_name(x509.Name([n for n in request.subject])
).serial_number(random.randint(
0x1000000000000000000000000000000000000000,
0xffffffffffffffffffffffffffffffffffffffff)
).issuer_name(authority_certificate.issuer
).public_key(request.public_key()
).not_valid_before(now - timedelta(hours=1)
).not_valid_after(now + timedelta(days=config.CERTIFICATE_LIFETIME)
).add_extension(x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False), critical=True
).add_extension(
x509.SubjectKeyIdentifier.from_public_key(request.public_key()),
critical=False
).add_extension(
x509.AuthorityInformationAccess([
x509.AccessDescription(
AuthorityInformationAccessOID.CA_ISSUERS,
x509.UniformResourceIdentifier(
config.CERTIFICATE_AUTHORITY_URL)
)
]),
critical=False
).add_extension(
x509.CRLDistributionPoints([
x509.DistributionPoint(
full_name=[
x509.UniformResourceIdentifier(
config.CERTIFICATE_CRL_URL)],
relative_name=None,
crl_issuer=None,
reasons=None)
]),
critical=False
).add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(
authority_certificate.public_key()),
critical=False
if renew:
mailer.send(
"certificate-renewed.md",
to=recipient,
attachments=(
(prev_buf, "application/x-pem-file", "deprecated.crt"),
(cert_buf, "application/x-pem-file", common_name.value + ".crt")
),
serial_number="%x" % cert.serial,
common_name=common_name.value,
certificate=cert,
)
else:
mailer.send(
"certificate-signed.md",
to=recipient,
attachments=(
(buf, "application/x-pem-file", common_name.value + ".csr"),
(cert_buf, "application/x-pem-file", common_name.value + ".crt")
),
serial_number="%x" % cert.serial,
common_name=common_name.value,
certificate=cert,
)
# Append subject alternative name, extended key usage flags etc
for extension in request.extensions:
if extension.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME:
click.echo("Appending subject alt name extension: %s" % extension)
cert = cert.add_extension(x509.SubjectAlternativeName(extension.value),
critical=extension.critical)
if extension.oid == ExtensionOID.EXTENDED_KEY_USAGE:
click.echo("Appending extended key usage flags extension: %s" % extension)
cert = cert.add_extension(x509.ExtendedKeyUsage(extension.value),
critical=extension.critical)
if config.LONG_POLL_PUBLISH:
url = config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest()
click.echo("Publishing certificate at %s ..." % url)
requests.post(url, data=cert_buf,
headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"})
if config.EVENT_SOURCE_PUBLISH: # TODO: handle renewal
push.publish("request-signed", common_name.value)
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 humanize import naturaltime
from jinja2 import Environment, PackageLoader
from time import sleep
from setproctitle import setproctitle
import const
@ -38,22 +37,29 @@ env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=Tr
# Parse command-line argument defaults from environment
USERNAME = os.environ.get("USER")
NOW = datetime.utcnow().replace(tzinfo=None)
FIRST_NAME = None
SURNAME = None
EMAIL = None
if USERNAME:
EMAIL = USERNAME + "@" + const.FQDN
CERTIDUDE_TIMER = """
[Unit]
Description=Run certidude service weekly
if os.getuid() >= 1000:
_, _, _, _, gecos, _, _ = pwd.getpwnam(USERNAME)
if " " in gecos:
FIRST_NAME, SURNAME = gecos.split(" ", 1)
else:
FIRST_NAME = gecos
[Timer]
OnCalendar=weekly
Persistent=true
Unit=certidude.service
[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.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)
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():
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:
endpoint_insecure = clients.getboolean(authority, "insecure")
except NoOptionError:
@ -111,22 +134,6 @@ def certidude_request(fork):
endpoint_revocations_path = "/var/lib/certidude/%s/ca_crl.pem" % authority
# 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 not os.path.exists("/etc/krb5.keytab"):
continue
@ -168,8 +175,6 @@ def certidude_request(fork):
endpoint_authority_path,
endpoint_revocations_path,
endpoint_common_name,
extended_key_usage_flags,
None,
insecure=endpoint_insecure,
autosign=True,
wait=True)
@ -229,6 +234,10 @@ def certidude_request(fork):
# OpenVPN set up with NetworkManager
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.add_section("connection")
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", "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", "port", "51900")
nm_config.set("vpn", "key", endpoint_key_path)
nm_config.set("vpn", "cert", endpoint_certificate_path)
nm_config.set("vpn", "ca", endpoint_authority_path)
@ -255,9 +265,9 @@ def certidude_request(fork):
os.umask(0o177)
# 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)
click.echo("Created %s" % fh.name)
click.echo("Created %s" % nm_config_path)
os.system("nmcli con reload")
continue
@ -302,50 +312,18 @@ def certidude_request(fork):
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.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("--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("--route", "-r", type=ip_network, multiple=True, help="Subnets to advertise via this connection, multiple allowed")
@click.option("--config", "-o",
default="/etc/openvpn/site-to-client.conf",
type=click.File(mode="w", atomic=True, lazy=True),
help="OpenVPN configuration file")
def certidude_setup_openvpn_server(authority, config, subnet, route, org_unit, 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
def certidude_setup_openvpn_server(authority, config, subnet, route, local, proto, port):
# Create corresponding section in Certidude client configuration file
client_config = ConfigParser()
@ -356,13 +334,12 @@ def certidude_setup_openvpn_server(authority, config, subnet, route, org_unit, l
else:
client_config.set(authority, "trigger", "interface up")
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, "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, "authority path", "/etc/openvpn/keys/ca.crt")
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:
client_config.write(fh)
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.set(endpoint, "authority", authority)
service_config.set(endpoint, "service", "init/openvpn")
with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh:
service_config.write(fh)
os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH)
click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH))
dhparam_path = "/etc/openvpn/keys/dhparam.pem"
if not os.path.exists(dhparam_path):
cmd = "openssl", "dhparam", "-out", dhparam_path, "2048"
subprocess.check_call(cmd)
config.write("mode server\n")
config.write("tls-server\n")
authority_hostname = authority.split(".")[0]
config.write("server %s %s\n" % (subnet.network_address, subnet.netmask))
config.write("dev tun-%s\n" % authority_hostname)
config.write("proto %s\n" % proto)
config.write("port %d\n" % port)
config.write("dev tap\n")
config.write("local %s\n" % local)
config.write("key %s\n" % client_config.get(authority, "key 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("dh %s\n" % dhparam_path)
config.write("dh %s\n" % client_config.get(authority, "dhparam path"))
config.write("comp-lzo\n")
config.write("user nobody\n")
config.write("group nogroup\n")
config.write("persist-tun\n")
config.write("persist-key\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("#ifconfig-pool-persist /tmp/openvpn-leases.txt\n")
config.write("#crl-verify %s\n" % client_config.get(authority, "revocations path"))
click.echo("Generated %s" % config.name)
click.echo("Inspect generated files and issue following to request certificate:")
click.echo()
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.argument("server")
@click.argument("authority")
@click.option("--common-name", "-cn", default=const.FQDN, help="Common name, %s by default" % const.FQDN)
@click.option("--tls-config",
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("--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("--verify-client", "-vc", type=click.Choice(['optional', 'on', 'off']))
@click.option("--verify-client", "-vc", default="optional", type=click.Choice(['optional', 'on', 'off']))
@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):
# TODO: Intelligent way of getting last IP address in the subnet
if not os.path.exists(certificate_path):
click.echo("As HTTPS 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" % common_name)
click.echo()
retval = certidude_request_certificate(authority, key_path, request_path,
certificate_path, authority_path, revocations_path, common_name, org_unit,
extended_key_usage_flags = [ExtendedKeyUsageOID.SERVER_AUTH],
dns = const.FQDN, wait=True, bundle=True)
if not os.path.exists(dhparam_path):
cmd = "openssl", "dhparam", "-out", dhparam_path, "2048"
subprocess.check_call(cmd)
if retval:
return retval
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):
if not os.path.exists("/etc/nginx"):
raise ValueError("nginx not installed")
if "." not in common_name:
raise ValueError("Fully qualified hostname not specified as common name, make sure hostname -f works")
client_config = ConfigParser()
if os.path.exists(const.CLIENT_CONFIG_PATH):
client_config.readfp(open(const.CLIENT_CONFIG_PATH))
if client_config.has_section(authority):
click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH))
else:
client_config.add_section(authority)
client_config.set(authority, "trigger", "interface up")
client_config.set(authority, "common name", common_name)
client_config.set(authority, "request path", request_path)
client_config.set(authority, "key path", key_path)
client_config.set(authority, "certificate path", certificate_path)
client_config.set(authority, "authority path", authority_path)
client_config.set(authority, "dhparam path", dhparam_path)
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.update(locals())
if os.path.exists(site_client_config.name):
click.echo("Configuration file %s already exists, not overwriting" % site_client_config.name)
if os.path.exists(site_config.name):
click.echo("Configuration file %s already exists, not overwriting" % site_config.name)
else:
site_client_config.write(env.get_template("nginx-https-site.conf").render(context))
click.echo("Generated %s" % site_client_config.name)
site_config.write(env.get_template("nginx-https-site.conf").render(context))
click.echo("Generated %s" % site_config.name)
if os.path.exists(tls_client_config.name):
click.echo("Configuration file %s already exists, not overwriting" % tls_client_config.name)
if os.path.exists(tls_config.name):
click.echo("Configuration file %s already exists, not overwriting" % tls_config.name)
else:
tls_client_config.write(env.get_template("nginx-tls.conf").render(context))
click.echo("Generated %s" % tls_client_config.name)
tls_config.write(env.get_template("nginx-tls.conf").render(context))
click.echo("Generated %s" % tls_config.name)
click.echo()
click.echo("Inspect configuration files, enable it and start nginx service:")
click.echo()
click.echo(" ln -s %s /etc/nginx/sites-enabled/%s" % (
os.path.relpath(site_client_config.name, "/etc/nginx/sites-enabled"),
os.path.basename(site_client_config.name)))
click.secho(" service nginx restart", bold=True)
os.path.relpath(site_config.name, "/etc/nginx/sites-enabled"),
os.path.basename(site_config.name)))
click.echo(" service nginx restart")
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",
type=click.File(mode="w", atomic=True, lazy=True),
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
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()
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.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("--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")
@ -580,8 +546,6 @@ def certidude_setup_strongswan_server(authority, config, secrets, subnet, route,
else:
client_config.set(authority, "trigger", "interface up")
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, "key path", "/etc/ipsec.d/private/%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.argument("server")
@click.argument("authority")
@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
client_config = ConfigParser()
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.argument("server") # Certidude server
@click.argument("authority") # Certidude server
@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
# 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.set(authority, "trigger", "interface up")
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, "key path", "/etc/ipsec.d/private/%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.argument("server") # Certidude server
@click.argument("authority")
@click.argument("remote") # OpenVPN gateway
@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
client_config = ConfigParser()
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.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("--nginx-config", "-n",
default="/etc/nginx/sites-available/certidude.conf",
type=click.File(mode="w", atomic=True, lazy=True),
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("--country", "-c", default=None, help="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("--authority-lifetime", default=20*365, help="Authority certificate lifetime in days, 7300 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("--authority-lifetime", default=20*365, help="Authority certificate lifetime in days, 20 years by default")
@click.option("--organization", "-o", default=None, help="Company or organization name")
@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("--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("--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 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)
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
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_crt = os.path.join(directory, "ca_crt.pem")
if not static_path.endswith("/"):
static_path += "/"
if os.getuid() == 0:
try:
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__))
certidude_path = sys.argv[0]
# Push server config generation
if not os.path.exists("/etc/nginx"):
click.echo("Directory /etc/nginx does not exist, hence not creating nginx configuration")
listen = "0.0.0.0"
@ -924,7 +878,6 @@ def certidude_setup_authority(username, static_path, kerberos_keytab, nginx_conf
).add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()),
critical=False
)
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 pycountry import countries
def dump_common(j):
person = [j for j in (j.given_name, j.surname) if j]
if person:
click.echo("Associated person: %s" % " ".join(person) + (" <%s>" % j.email_address if j.email_address else ""))
elif j.email_address:
click.echo("Associated e-mail: " + j.email_address)
bits = [j for j in (
countries.get(alpha2=j.country_code.upper()).name if
j.country_code else "",
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)
def dump_common(common_name, path, cert):
click.echo("certidude revoke %s" % common_name)
with open(path, "rb") as fh:
buf = fh.read()
click.echo("md5sum: %s" % hashlib.md5(buf).hexdigest())
click.echo("sha1sum: %s" % hashlib.sha1(buf).hexdigest())
click.echo("sha256sum: %s" % hashlib.sha256(buf).hexdigest())
click.echo()
for ext in cert.extensions:
print " -", ext.value
click.echo()
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:
click.echo("s " + j.path + " " + j.identity)
click.echo("s " + path)
continue
click.echo(click.style(j.common_name, fg="blue"))
click.echo("=" * len(j.common_name))
click.echo("State: ? " + click.style("submitted", fg="yellow") + " " + naturaltime(j.created) + click.style(", %s" %j.created, fg="white"))
click.echo(click.style(common_name, fg="blue"))
click.echo("=" * len(common_name))
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:
for j in authority.list_signed():
for common_name, path, buf, cert, server in authority.list_signed():
if not verbose:
if j.signed < NOW and j.expires > NOW:
click.echo("v " + j.path + " " + j.identity)
elif NOW > j.expires:
click.echo("e " + j.path + " " + j.identity)
if cert.not_valid_before < NOW and cert.not_valid_after > NOW:
click.echo("v " + path)
elif NOW > cert.not_valid_after:
click.echo("e " + path)
else:
click.echo("y " + j.path + " " + j.identity)
click.echo("y " + path)
continue
click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white"))
click.echo("="*(len(j.common_name)+60))
if j.signed < NOW and j.expires > NOW:
click.echo("Status: \u2713 " + click.style("valid", fg="green") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white"))
elif NOW > j.expires:
click.echo("Status: \u2717 " + click.style("expired", fg="red") + " " + naturaltime(j.expires) + click.style(", %s" %j.expires, fg="white"))
click.echo(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial_number, fg="white"))
click.echo("="*(len(common_name)+60))
expires = 0 # TODO
if cert.not_valid_before < NOW and cert.not_valid_after > NOW:
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 > cert.not_valid_after:
click.echo("Status: " + click.style("expired", fg="red") + " " + naturaltime(expires) + click.style(", %s" %expires, fg="white"))
else:
click.echo("Status: \u2717 " + click.style("not valid yet", fg="red") + click.style(", %s" %j.expires, fg="white"))
dump_common(j)
if show_path:
click.echo("Details: openssl x509 -in %s -text -noout" % j.path)
click.echo("Revoke: certidude revoke %s" % j.path)
click.echo("Status: " + click.style("not valid yet", fg="red") + click.style(", %s" %expires, fg="white"))
click.echo()
click.echo("openssl x509 -in %s -text -noout" % path)
dump_common(common_name, path, cert)
if show_revoked:
for j in authority.list_revoked():
for common_name, path, buf, cert, server in authority.list_revoked():
if not verbose:
click.echo("r " + j.path + " " + j.identity)
click.echo("r " + path)
continue
click.echo(click.style(j.common_name, fg="blue") + " " + click.style(j.serial_number_hex, fg="white"))
click.echo("="*(len(j.common_name)+60))
click.echo("Status: \u2717 " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-j.changed), click.style(", %s" % j.changed, fg="white")))
dump_common(j)
if show_path:
click.echo("Details: openssl x509 -in %s -text -noout" % j.path)
click.echo()
click.echo(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial, fg="white"))
click.echo("="*(len(common_name)+60))
_, _, _, _, _, _, _, _, mtime, _ = os.stat(path)
changed = datetime.fromtimestamp(mtime)
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.command("sign", help="Sign certificates")
@click.command("sign", help="Sign certificate")
@click.argument("common_name")
@click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN")
@click.option("--lifetime", "-l", help="Lifetime")
def certidude_sign(common_name, overwrite, lifetime):
from certidude import authority, config
request = authority.get_request(common_name)
cert = authority.sign(request)
def certidude_sign(common_name, overwrite):
from certidude import authority
cert = authority.sign(common_name, overwrite)
@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.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")
@ -1276,9 +1208,6 @@ def certidude_setup_openvpn(): pass
@click.group("setup", help="Getting started section")
def certidude_setup(): pass
@click.group("signer", help="Signer process management")
def certidude_signer(): pass
@click.group()
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_openvpn)
certidude_setup.add_command(certidude_setup_strongswan)
certidude_setup.add_command(certidude_setup_client)
certidude_setup.add_command(certidude_setup_nginx)
entry_point.add_command(certidude_setup)
entry_point.add_command(certidude_serve)
entry_point.add_command(certidude_signer)
entry_point.add_command(certidude_request)
entry_point.add_command(certidude_sign)
entry_point.add_command(certidude_revoke)
entry_point.add_command(certidude_list)
entry_point.add_command(certidude_users)
entry_point.add_command(certidude_cron)
if __name__ == "__main__":
entry_point()

View File

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

View File

@ -2,12 +2,9 @@ import falcon
import ipaddress
import json
import logging
import re
import types
from datetime import date, time, datetime
from OpenSSL import crypto
from certidude.auth import User
from certidude.wrappers import Request, Certificate
from urlparse import urlparse
logger = logging.getLogger("api")
@ -52,21 +49,7 @@ def event_source(func):
return wrapped
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):
if isinstance(obj, crypto.X509Name):
try:
return ", ".join(["%s=%s" % (k.decode("ascii"),v.decode("utf-8")) for k, v in obj.get_components()])
except UnicodeDecodeError: # Work around old buggy pyopenssl
return ", ".join(["%s=%s" % (k.decode("ascii"),v.decode("iso8859")) for k, v in obj.get_components()])
if isinstance(obj, ipaddress._IPAddressBase):
return str(obj)
if isinstance(obj, set):
@ -77,17 +60,9 @@ class MyEncoder(json.JSONEncoder):
return obj.strftime("%Y-%m-%d")
if isinstance(obj, types.GeneratorType):
return tuple(obj)
if isinstance(obj, Request):
return dict([(key, getattr(obj, key)) for key in self.REQUEST_ATTRIBUTES \
if hasattr(obj, key) and getattr(obj, key)])
if isinstance(obj, Certificate):
return dict([(key, getattr(obj, key)) for key in self.CERTIFICATE_ATTRIBUTES \
if hasattr(obj, key) and getattr(obj, key)])
if isinstance(obj, User):
return dict(name=obj.name, given_name=obj.given_name,
surname=obj.surname, mail=obj.mail)
if hasattr(obj, "serialize"):
return obj.serialize()
return json.JSONEncoder.default(self, obj)
@ -96,29 +71,13 @@ def serialize(func):
Falcon response serialization
"""
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("Pragma", "no-cache")
resp.set_header("Expires", "0")
r = func(instance, req, resp, **kwargs)
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
resp.body = json.dumps(func(instance, req, resp, **kwargs), cls=MyEncoder)
return wrapped

View File

@ -4,18 +4,20 @@ import os
import requests
import subprocess
import tempfile
from base64 import b64encode
from datetime import datetime, timedelta
from certidude import errors, const
from certidude.wrappers import Certificate, Request
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.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, AuthorityInformationAccessOID
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
"""
@ -26,6 +28,8 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
if wait:
request_params.add("wait=forever")
renew = False # Attempt to renew if certificate has expired
# Expand ca.example.com
scheme = "http" if insecure else "https" # TODO: Expose in CLI
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)
try:
r = requests.get(authority_url,
headers={"Accept": "application/x-x509-ca-cert,application/x-pem-file"})
cert = crypto.load_certificate(crypto.FILETYPE_PEM, r.text)
except crypto.Error:
raise ValueError("Failed to parse PEM: %s" % r.text)
headers={"Accept": "application/x-x509-ca-cert,application/x-pem-file"})
x509.load_pem_x509_certificate(r.content, default_backend())
except:
raise
# raise ValueError("Failed to parse PEM: %s" % r.text)
authority_partial = tempfile.mktemp(prefix=authority_path + ".part")
with open(authority_partial, "w") as oh:
oh.write(r.text)
oh.write(r.content)
click.echo("Writing authority certificate to: %s" % 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
if os.path.exists(certificate_path):
cert = crypto.load_certificate(crypto.FILETYPE_PEM, open(certificate_path).read())
revocation_list = crypto.load_crl(crypto.FILETYPE_PEM, open(revocations_path).read())
for revocation in revocation_list.get_revoked():
if int(revocation.get_serial(), 16) == cert.get_serial_number():
if revocation.get_reason() == "Certificate Hold": # TODO: 'Remove From CRL'
# TODO: Disable service for time being
click.echo("Certificate put on hold, doing nothing for now")
cert = x509.load_pem_x509_certificate(open(certificate_path).read(), default_backend())
for revocation in x509.load_pem_x509_crl(open(revocations_path).read(), default_backend()):
extension, = revocation.extensions
if revocation.serial_number == cert.serial_number:
if extension.value.reason == x509.ReasonFlags.certificate_hold:
# Don't do anything for now
# TODO: disable service
break
# Disable the client if operation has been ceased or
# the certificate has been superseded by other
if revocation.get_reason() in ("Cessation Of Operation", "Superseded"):
# Disable the client if operation has been ceased
if extension.value.reason == x509.ReasonFlags.cessation_of_operation:
if os.path.exists("/etc/certidude/client.conf"):
clients.readfp(open("/etc/certidude/client.conf"))
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"))
click.echo("Authority operation ceased, disabling in /etc/certidude/client.conf")
# TODO: Disable related services
if revocation.get_reason() in ("CA Compromise", "AA Compromise"):
if os.path.exists(authority_path):
os.remove(key_path)
return
click.echo("Certificate has been revoked, wiping keys and certificates!")
if os.path.exists(key_path):
@ -102,9 +106,16 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
else:
click.echo("Certificate does not seem to be revoked. Good!")
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)
with open(key_path) as fh:
key = serialization.load_pem_private_key(
fh.read(),
password=None,
backend=default_backend())
except EnvironmentError:
# 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?
if os.path.exists(certificate_path):
click.echo("Found certificate: %s" % certificate_path)
# TODO: Check certificate validity, download CRL?
return
cert_buf = open(certificate_path).read()
cert = x509.load_pem_x509_certificate(cert_buf, default_backend())
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 os.path.exists("/etc/krb5.keytab"):
@ -169,10 +187,25 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
auth = None
click.echo("Submitting to %s, waiting for response..." % request_url)
submission = requests.post(request_url,
auth=auth,
data=open(request_path),
headers={"Content-Type": "application/pkcs10", "Accept": "application/x-x509-user-cert,application/x-pem-file"})
headers={
"Content-Type": "application/pkcs10",
"Accept": "application/x-x509-user-cert,application/x-pem-file"
}
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
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()
try:
cert = crypto.load_certificate(crypto.FILETYPE_PEM, submission.text)
except crypto.Error:
cert = x509.load_pem_x509_certificate(submission.text.encode("ascii"), default_backend())
except: # TODO: catch correct exceptions
raise ValueError("Failed to parse PEM: %s" % submission.text)
os.umask(0o022)

View File

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

View File

@ -29,7 +29,8 @@ class SignHandler(asynchat.async_chat):
"""
builder = x509.CertificateRevocationListBuilder(
).last_update(now
).last_update(
now - timedelta(minutes=5)
).next_update(
now + timedelta(seconds=config.REVOCATION_LIST_LIFETIME)
).issuer_name(self.server.certificate.issuer
@ -89,9 +90,12 @@ class SignHandler(asynchat.async_chat):
).public_key(
request.public_key()
).not_valid_before(
now - timedelta(hours=1)
now
).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(
x509.BasicConstraints(
ca=False,
@ -122,7 +126,7 @@ class SignHandler(asynchat.async_chat):
x509.AccessDescription(
AuthorityInformationAccessOID.CA_ISSUERS,
x509.UniformResourceIdentifier(
config.CERTIFICATE_AUTHORITY_URL)
config.AUTHORITY_CERTIFICATE_URL)
)
]),
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) {
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(); });
$("#certificate-" + e.data.replace("@", "--").replace(".", "-")).slideUp("normal", function() { $(this).remove(); });
$("#request-" + slug).slideUp("normal", function() { $(this).remove(); });
$("#certificate-" + slug).slideUp("normal", function() { $(this).remove(); });
$.ajax({
method: "GET",
url: "/api/signed/" + e.data + "/",
dataType: "json",
success: function(certificate, status, xhr) {
console.info(certificate);
console.info("Retrieved certificate:", certificate);
$("#signed_certificates").prepend(
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() {
var q = $("#search").val();
$(".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();
} else {
$(e).hide();

View File

@ -2,12 +2,10 @@
<section id="about">
<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>You can click <a href="/api/bundle/">here</a> to generate bundle
for current user account.</p>
{% endif %}
<p>Mails will be sent to: {{ session.user.mail }}</p>
{% if session.authority %}
@ -28,9 +26,9 @@ as such require complete reset of X509 infrastructure if some of them needs to b
{% endif %}
<p>User certificate enrollment:
{% if session.authority.user_certificate_enrollment %}
{% if session.authority.user_mutliple_certificates %}
<p>User enrollment:
{% if session.authority.user_enrollment_allowed %}
{% if session.authority.user_multiple_certificates %}
multiple
{% else %}
single
@ -42,10 +40,20 @@ forbidden
</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>
<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>
</ul>
@ -134,13 +142,13 @@ cat example.csr
</pre>
<p>Paste the contents here and click submit:</p>
<textarea id="request_body" style="width:100%; min-height: 4em;" placeholder="-----BEGIN CERTIFICATE REQUEST-----
...
-----END CERTIFICATE REQUEST-----"></textarea>
<textarea id="request_body" style="width:100%; min-height: 4em;" placeholder="-----BEGIN CERTIFICATE REQUEST-----"></textarea>
<button class="icon upload" id="request_submit" style="float:none;">Submit</button>
{% else %}
<p>Submit a certificate signing request with Certidude:</p>
<pre>certidude setup client {{session.common_name}}</pre>
<p>Submit a certificate signing request from Mac OS X, Ubuntu or Fedora:</p>
<pre>easy_install pip
pip install certidude
certidude bootstrap {{session.authority.common_name}}</pre>
{% endif %}
<ul id="pending_requests">
@ -180,7 +188,8 @@ cat example.csr
<h1>Revoked certificates</h1>
<p>To fetch <a href="{{window.location.href}}api/revoked/">certificate revocation list</a>:</p>
<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>

View File

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

View File

@ -78,11 +78,33 @@ database = sqlite://{{ directory }}/db.sqlite
openvpn status uri = http://router.example.com/status.log
[signature]
certificate lifetime = {{ certificate_lifetime }}
revocation list lifetime = {{ revocation_list_lifetime }}
certificate url = {{ certificate_url }}
# Server certificate is granted to certificate with
# common name that includes period which translates to FQDN of the machine.
# 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 }}
# 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]
event source token = {{ push_token }}
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
[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
# request bundles. In case of 'single allowed' the common name of the
# certificate is set to username, this should work well with REMOTE_USER
# enabled web apps running behind Apache/nginx.
# In case of 'multiple allowed' the common name is set to username@device-identifier.
;user certificate enrollment = forbidden
;user certificate enrollment = single allowed
user certificate enrollment = multiple allowed
;user enrollment = forbidden
;user enrollment = single 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 }}
certificate path = {{ ca_crt }}
@ -112,8 +145,10 @@ outbox uri = {{ outbox }}
outbox sender name = Certificate management
outbox sender address = certificates@example.com
bundle format = p12
;bundle format = ovpn
openvpn bundle template = /etc/certidude/template.ovpn
[bundle]
format = p12
;format = 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.
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 %}.
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
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.

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;
ssl_prefer_server_ciphers on;
# Following are already enabled by /etc/nginx/nginx.conf
#ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
#ssl_prefer_server_ciphers on;
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_dhparam {{dhparam_path}};

View File

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

View File

@ -84,8 +84,6 @@ class DirectoryConnection(object):
class ActiveDirectoryUserManager(object):
def get(self, username):
# TODO: Sanitize username
if "@" in username:
username, _ = username.split("@", 1)
with DirectoryConnection() as conn:
ft = config.LDAP_USER_FILTER % username
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
humanize==0.5.1
ipaddress==1.0.18
Jinja2==2.8
Jinja2==2.9.5
Markdown==2.6.8
pyldap==2.4.28
requests==2.10.0