mirror of
https://github.com/laurivosandi/certidude
synced 2024-12-22 08:15:18 +00:00
Refactor
* 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:
parent
d1aa2f2073
commit
06010ceaf3
23
README.rst
23
README.rst
@ -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
|
||||
|
@ -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":
|
||||
|
@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
import logging
|
||||
import hashlib
|
||||
from certidude import config, authority
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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))
|
||||
|
||||
|
421
certidude/cli.py
421
certidude/cli.py
@ -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()
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
1
certidude/static/img/iconmonstr-server-1.svg
Normal file
1
certidude/static/img/iconmonstr-server-1.svg
Normal 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 |
@ -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();
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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 }}
|
||||
|
9
certidude/templates/mail/certificate-renewed.md
Normal file
9
certidude/templates/mail/certificate-renewed.md
Normal 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.
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
||||
|
5
certidude/templates/mail/token.md
Normal file
5
certidude/templates/mail/token.md
Normal 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.
|
||||
|
@ -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}};
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user