diff --git a/README.rst b/README.rst index 2a1a09d..cf08bdd 100644 --- a/README.rst +++ b/README.rst @@ -11,9 +11,8 @@ Certidude Introduction ------------ -Certidude is a novel X.509 Certificate Authority management tool -with privilege isolation mechanism and Kerberos authentication -mainly designed for OpenVPN gateway operators to make +Certidude is a minimalist X.509 Certificate Authority management tool +with Kerberos authentication mainly designed for OpenVPN gateway operators to make VPN client setup on laptops, desktops and mobile devices as painless as possible. .. figure:: doc/certidude.png @@ -54,13 +53,6 @@ Following usecases are covered: The user logs in using domain account in the web interface and can automatically retrieve a P12 bundle which can be installed on her Android device. -Future usecases: - -* I want to store the private key of my CA on a SmartCard. - I want to make use of it while I log in to my CA web interface. - When I am asked to sign a certificate I have to enter PIN code to unlock the - SmartCard. - Features -------- @@ -68,16 +60,14 @@ Features Common: * Standard request, sign, revoke workflow via web interface. -* Kerberos and basic auth based web interface authentication. -* Preliminary `OCSP `_ and `SCEP `_ support. +* `OCSP `_ and `SCEP `_ support. * PAM and Active Directory compliant authentication backends: Kerberos single sign-on, LDAP simple bind. * POSIX groups and Active Directory (LDAP) group membership based authorization. * Server-side command-line interface, check out ``certidude list``, ``certidude sign`` and ``certidude revoke``. -* Privilege isolation, separate signer process is spawned per private key isolating - private key use from the the web interface. * Certificate serial numbers are intentionally randomized to avoid leaking information about business practices. * Server-side events support via `nchan `_. -* E-mail notifications about pending, signed, revoked, renewed and overwritten certificates +* E-mail notifications about pending, signed, revoked, renewed and overwritten certificates. +* Built using compilation-free `oscrypto `_ library. Virtual private networking: @@ -95,9 +85,7 @@ HTTPS: TODO ---- -* WebCrypto support, meanwhile check out `hwcrypto.js `_. * Use `pki.js `_ for generating keypair in the browser when claiming a token. -* Signer process logging. Install @@ -110,7 +98,8 @@ System dependencies for Ubuntu 16.04: .. code:: bash - apt install -y python python-cffi python-click python-configparser \ + apt install -y + python-click python-configparser \ python-humanize \ python-ipaddress python-jinja2 python-ldap python-markdown \ python-mimeparse python-mysql.connector python-openssl python-pip \ @@ -124,7 +113,7 @@ System dependencies for Fedora 25+: yum install redhat-rpm-config python-devel openssl-devel openldap-devel At the moment package at PyPI is rather outdated. -Please proceed down to Development section to install Certidude from source. +Please proceed down to `Development <#development>`_ section to install Certidude from source. Setting up authority diff --git a/certidude/api/__init__.py b/certidude/api/__init__.py index 6710caf..f6488c8 100644 --- a/certidude/api/__init__.py +++ b/certidude/api/__init__.py @@ -13,7 +13,6 @@ from certidude import authority, mailer from certidude.auth import login_required, authorize_admin from certidude.user import User from certidude.decorators import serialize, csrf_protection -from cryptography.x509.oid import NameOID from certidude import const, config logger = logging.getLogger(__name__) @@ -82,8 +81,8 @@ class SessionResource(object): 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, + signed = obj["tbs_certificate"]["validity"]["not_before"].native, + expires = obj["tbs_certificate"]["validity"]["not_after"].native, sha256sum = hashlib.sha256(buf).hexdigest(), lease = lease, tags = tags, @@ -108,8 +107,7 @@ class SessionResource(object): offline = 600, # Seconds from last seen activity to consider lease offline, OpenVPN reneg-sec option dead = 604800 # Seconds from last activity to consider lease dead, X509 chain broken or machine discarded ), - common_name = authority.ca_cert.subject.get_attributes_for_oid( - NameOID.COMMON_NAME)[0].value, + common_name = authority.certificate.subject.native["common_name"], mailer = dict( name = config.MAILER_NAME, address = config.MAILER_ADDRESS diff --git a/certidude/api/lease.py b/certidude/api/lease.py index 7db525a..1bd6237 100644 --- a/certidude/api/lease.py +++ b/certidude/api/lease.py @@ -33,7 +33,7 @@ class LeaseResource(object): # TODO: verify signature common_name = req.get_param("client", required=True) path, buf, cert = authority.get_signed(common_name) # TODO: catch exceptions - if req.get_param("serial") and cert.serial != req.get_param_as_int("serial"): # OCSP-ish solution for OpenVPN, not exposed for StrongSwan + if req.get_param("serial") and cert.serial_number != req.get_param_as_int("serial"): # OCSP-ish solution for OpenVPN, not exposed for StrongSwan raise falcon.HTTPForbidden("Forbidden", "Invalid serial number supplied") xattr.setxattr(path, "user.lease.outer_address", req.get_param("outer_address", required=True).encode("ascii")) diff --git a/certidude/api/ocsp.py b/certidude/api/ocsp.py index 4196911..e88476c 100644 --- a/certidude/api/ocsp.py +++ b/certidude/api/ocsp.py @@ -52,7 +52,7 @@ class OCSPResource(object): assert link_target.startswith("../") assert link_target.endswith(".pem") path, buf, cert = authority.get_signed(link_target[3:-4]) - if serial != cert.serial: + if serial != cert.serial_number: raise EnvironmentError("integrity check failed") status = ocsp.CertStatus(name='good', value=None) except EnvironmentError: @@ -94,9 +94,13 @@ class OCSPResource(object): 'response_type': u"basic_ocsp_response", 'response': { 'tbs_response_data': response_data, + 'certs': [server_certificate.asn1], 'signature_algorithm': {'algorithm': u"sha1_rsa"}, - 'signature': b64decode(authority.signer_exec("sign-pkcs7", b64encode(response_data.dump()))), - 'certs': [server_certificate.asn1] + 'signature': asymmetric.rsa_pkcs1v15_sign( + authority.private_key, + response_data.dump(), + "sha1" + ) } } }).dump() diff --git a/certidude/api/request.py b/certidude/api/request.py index 155cbba..97d6ceb 100644 --- a/certidude/api/request.py +++ b/certidude/api/request.py @@ -6,18 +6,16 @@ import ipaddress import json import os import hashlib +from asn1crypto import pem +from asn1crypto.csr import CertificationRequest from base64 import b64decode from certidude import config, authority, push, errors from certidude.auth import login_required, login_optional, authorize_admin from certidude.decorators import serialize, csrf_protection 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 +from oscrypto import asymmetric +from oscrypto.errors import SignatureError from xattr import getxattr logger = logging.getLogger(__name__) @@ -35,19 +33,14 @@ class RequestListResource(object): @whitelist_content_types("application/pkcs10") def on_post(self, req, resp): """ - Validate and parse certificate signing request + Validate and parse certificate signing request, the RESTful way """ reasons = [] - body = req.stream.read(req.content_length) - 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!") + body = req.stream.read(req.content_length).encode("ascii") + + header, _, der_bytes = pem.unarmor(body) + csr = CertificationRequest.load(der_bytes) + common_name = csr["certification_request_info"]["subject"].native["common_name"] """ Handle domain computer automatic enrollment @@ -55,10 +48,10 @@ class RequestListResource(object): machine = req.context.get("machine") if machine: if config.MACHINE_ENROLLMENT_ALLOWED: - if common_name.value != machine: + if common_name != machine: raise falcon.HTTPBadRequest( "Bad request", - "Common name %s differs from Kerberos credential %s!" % (common_name.value, machine)) + "Common name %s differs from Kerberos credential %s!" % (common_name, machine)) # Automatic enroll with Kerberos machine cerdentials resp.set_header("Content-Type", "application/x-pem-file") @@ -73,52 +66,48 @@ class RequestListResource(object): Attempt to renew certificate using currently valid key pair """ try: - path, buf, cert = authority.get_signed(common_name.value) + path, buf, cert = authority.get_signed(common_name) except EnvironmentError: - pass + pass # No currently valid certificate for this common name else: - if cert.public_key().public_numbers() == csr.public_key().public_numbers(): + cert_pk = cert["tbs_certificate"]["subject_public_key_info"].native + csr_pk = csr["certification_request_info"]["subject_pk_info"].native + + if cert_pk == csr_pk: # Same public key, assume renewal + expires = cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None) renewal_header = req.get_header("X-Renewal-Signature") if not renewal_header: # 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) + resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", common_name) return try: renewal_signature = b64decode(renewal_header) except TypeError, ValueError: - logger.error(u"Renewal failed, bad signature supplied for %s", common_name.value) + logger.error(u"Renewal failed, bad signature supplied for %s", common_name) reasons.append("Renewal failed, bad signature supplied") 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(u"Renewal failed, invalid signature supplied for %s", common_name.value) + asymmetric.rsa_pss_verify( + asymmetric.load_certificate(cert), + renewal_signature, buf + body, "sha512") + except SignatureError: + logger.error(u"Renewal failed, invalid signature supplied for %s", common_name) reasons.append("Renewal failed, invalid signature supplied") 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(u"Renewal failed, current certificate for %s has expired", common_name.value) + if datetime.utcnow() > expires: + logger.error(u"Renewal failed, current certificate for %s has expired", common_name) reasons.append("Renewal failed, current certificate expired") elif not config.CERTIFICATE_RENEWAL_ALLOWED: - logger.error(u"Renewal requested for %s, but not allowed by authority settings", common_name.value) + logger.error(u"Renewal requested for %s, but not allowed by authority settings", common_name) reasons.append("Renewal requested, but not allowed by authority settings") else: resp.set_header("Content-Type", "application/x-x509-user-cert") _, resp.body = authority._sign(csr, body, overwrite=True) - logger.info(u"Renewed certificate for %s", common_name.value) + logger.info(u"Renewed certificate for %s", common_name) return @@ -127,17 +116,17 @@ class RequestListResource(object): autosigning was requested and certificate can be automatically signed """ if req.get_param_as_bool("autosign"): - if "." not in common_name.value: + if not authority.server_flags(common_name): for subnet in config.AUTOSIGN_SUBNETS: if req.context.get("remote_addr") in subnet: try: resp.set_header("Content-Type", "application/x-pem-file") _, resp.body = authority._sign(csr, body) - logger.info(u"Autosigned %s as %s is whitelisted", common_name.value, req.context.get("remote_addr")) + logger.info(u"Autosigned %s as %s is whitelisted", common_name, req.context.get("remote_addr")) return except EnvironmentError: logger.info(u"Autosign for %s from %s failed, signed certificate already exists", - common_name.value, req.context.get("remote_addr")) + common_name, req.context.get("remote_addr")) reasons.append("Autosign failed, signed certificate already exists") break else: @@ -147,7 +136,7 @@ class RequestListResource(object): # Attempt to save the request otherwise try: - request_path, _, _ = authority.store_request(body.decode("ascii"), + request_path, _, _ = authority.store_request(body, address=str(req.context.get("remote_addr"))) except errors.RequestExists: reasons.append("Same request already uploaded exists") @@ -160,10 +149,10 @@ 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", common_name.value) + push.publish("request-submitted", common_name) # 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")) + logger.info(u"Stored signing request %s from %s", common_name, req.context.get("remote_addr")) if req.get_param("wait"): # Redirect to nginx pub/sub url = config.LONG_POLL_SUBSCRIBE % hashlib.sha256(body).hexdigest() @@ -221,7 +210,7 @@ class RequestDetailResource(object): @csrf_protection @login_required @authorize_admin - def on_patch(self, req, resp, cn): + def on_post(self, req, resp, cn): """ Sign a certificate signing request """ diff --git a/certidude/api/revoked.py b/certidude/api/revoked.py index 0389af0..ee18dd0 100644 --- a/certidude/api/revoked.py +++ b/certidude/api/revoked.py @@ -6,9 +6,6 @@ import logging from certidude import const, config from certidude.authority import export_crl, list_revoked from certidude.firewall import whitelist_subnets -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.serialization import Encoding logger = logging.getLogger(__name__) @@ -23,9 +20,8 @@ class RevocationListResource(object): "Content-Disposition", ("attachment; filename=%s.crl" % const.HOSTNAME).encode("ascii")) # Convert PEM to DER - logger.debug(u"Serving revocation list to %s in DER format", req.context.get("remote_addr")) - resp.body = x509.load_pem_x509_crl(export_crl(), - default_backend()).public_bytes(Encoding.DER) + logger.debug(u"Serving revocation list (DER) to %s", req.context.get("remote_addr")) + resp.body = export_crl(pem=False) elif req.client_accepts("application/x-pem-file"): if req.get_param_as_bool("wait"): url = config.LONG_POLL_SUBSCRIBE % "crl" @@ -38,7 +34,7 @@ class RevocationListResource(object): resp.append_header( "Content-Disposition", ("attachment; filename=%s-crl.pem" % const.HOSTNAME).encode("ascii")) - logger.debug(u"Serving revocation list to %s in PEM format", req.context.get("remote_addr")) + logger.debug(u"Serving revocation list (PEM) to %s", req.context.get("remote_addr")) resp.body = export_crl() else: logger.debug(u"Client %s asked revocation list in unsupported format" % req.context.get("remote_addr")) diff --git a/certidude/api/scep.py b/certidude/api/scep.py index 5b66e77..cf92f58 100644 --- a/certidude/api/scep.py +++ b/certidude/api/scep.py @@ -41,7 +41,7 @@ class SCEPResource(object): def on_get(self, req, resp): operation = req.get_param("operation") if operation.lower() == "getcacert": - resp.stream = keys.parse_certificate(authority.ca_buf).dump() + resp.stream = keys.parse_certificate(authority.certificate_buf).dump() resp.append_header("Content-Type", "application/x-x509-ca-cert") return @@ -125,14 +125,16 @@ class SCEPResource(object): encrypted_content = encrypted_content_info['encrypted_content'].native recipient, = encrypted_envelope['recipient_infos'] - if recipient.native["rid"]["serial_number"] != authority.ca_cert.serial: + if recipient.native["rid"]["serial_number"] != authority.certificate.serial_number: raise SCEPBadCertId() # Since CA private key is not directly readable here, we'll redirect it to signer socket - key = b64decode(authority.signer_exec("decrypt-pkcs7", b64encode(recipient.native["encrypted_key"]))) + key = asymmetric.rsa_pkcs1v15_decrypt( + authority.private_key, + recipient.native["encrypted_key"]) if len(key) == 8: key = key * 3 # Convert DES to 3DES buf = symmetric.tripledes_cbc_pkcs5_decrypt(key, encrypted_content, iv) - _, common_name = authority.store_request(buf, overwrite=True) + _, _, common_name = authority.store_request(buf, overwrite=True) cert, buf = authority.sign(common_name, overwrite=True) signed_certificate = asymmetric.load_certificate(buf) content = signed_certificate.asn1.dump() @@ -251,7 +253,11 @@ class SCEPResource(object): }), 'digest_algorithm': algos.DigestAlgorithm({'algorithm': u"sha1"}), 'signature_algorithm': algos.SignedDigestAlgorithm({'algorithm': u"rsassa_pkcs1v15"}), - 'signature': b64decode(authority.signer_exec("sign-pkcs7", b64encode(b"\x31" + attrs.dump()[1:]))) + 'signature': asymmetric.rsa_pkcs1v15_sign( + authority.private_key, + b"\x31" + attrs.dump()[1:], + "sha1" + ) }) resp.append_header("Content-Type", "application/x-pki-message") diff --git a/certidude/api/signed.py b/certidude/api/signed.py index a72810f..69859b9 100644 --- a/certidude/api/signed.py +++ b/certidude/api/signed.py @@ -31,9 +31,9 @@ class SignedCertificateDetailResource(object): resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % cn)) resp.body = json.dumps(dict( common_name = cn, - serial_number = "%x" % cert.serial, - 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", + serial_number = "%x" % cert.serial_number, + signed = cert["tbs_certificate"]["validity"]["not_before"].native.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", + expires = cert["tbs_certificate"]["validity"]["not_after"].native.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")) diff --git a/certidude/authority.py b/certidude/authority.py index 4103085..22d2983 100644 --- a/certidude/authority.py +++ b/certidude/authority.py @@ -1,23 +1,25 @@ from __future__ import division, absolute_import, print_function import click 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, ExtendedKeyUsageOID -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.serialization import Encoding +from oscrypto import asymmetric +from asn1crypto import pem, x509 +from asn1crypto.csr import CertificationRequest +from certbuilder import CertificateBuilder from certidude import config, push, mailer, const from certidude import errors +from crlbuilder import CertificateListBuilder, pem_armor_crl +from csrbuilder import CSRBuilder, pem_armor_csr +from datetime import datetime, timedelta from jinja2 import Template +from random import SystemRandom from xattr import getxattr, listxattr, setxattr +random = SystemRandom() + RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])(@(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]))?$" # https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/ @@ -27,8 +29,14 @@ RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z # Cache CA certificate with open(config.AUTHORITY_CERTIFICATE_PATH) as fh: - ca_buf = fh.read() - ca_cert = x509.load_pem_x509_certificate(ca_buf, default_backend()) + certificate_buf = fh.read() + header, _, certificate_der_bytes = pem.unarmor(certificate_buf) + certificate = x509.Certificate.load(certificate_der_bytes) + public_key = asymmetric.load_public_key(certificate["tbs_certificate"]["subject_public_key_info"]) +with open(config.AUTHORITY_PRIVATE_KEY_PATH) as fh: + key_buf = fh.read() + header, _, key_der_bytes = pem.unarmor(key_buf) + private_key = asymmetric.load_private_key(key_der_bytes) def get_request(common_name): if not re.match(RE_HOSTNAME, common_name): @@ -37,7 +45,8 @@ def get_request(common_name): try: with open(path) as fh: buf = fh.read() - return path, buf, x509.load_pem_x509_csr(buf, default_backend()) + header, _, der_bytes = pem.unarmor(buf) + return path, buf, CertificationRequest.load(der_bytes) except EnvironmentError: raise errors.RequestDoesNotExist("Certificate signing request file %s does not exist" % path) @@ -47,13 +56,15 @@ def get_signed(common_name): 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()) + header, _, der_bytes = pem.unarmor(buf) + return path, buf, x509.Certificate.load(der_bytes) def get_revoked(serial): path = os.path.join(config.REVOKED_DIR, "%x.pem" % serial) with open(path) as fh: buf = fh.read() - return path, buf, x509.load_pem_x509_certificate(buf, default_backend()), \ + header, _, der_bytes = pem.unarmor(buf) + return path, buf, x509.Certificate.load(der_bytes), \ datetime.utcfromtimestamp(os.stat(path).st_ctime) @@ -85,20 +96,19 @@ def store_request(buf, overwrite=False, address="", user=""): if not buf: raise ValueError("No signing request supplied") - if isinstance(buf, unicode): - csr = x509.load_pem_x509_csr(buf.encode("ascii"), backend=default_backend()) - elif isinstance(buf, str): - csr = x509.load_der_x509_csr(buf, backend=default_backend()) - buf = csr.public_bytes(Encoding.PEM) + if pem.detect(buf): + header, _, der_bytes = pem.unarmor(buf) + csr = CertificationRequest.load(der_bytes) else: - raise ValueError("Invalid type, expected str for PEM and bytes for DER") - common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) - # TODO: validate common name again + csr = CertificationRequest.load(buf) + buf = pem_armor_csr(csr) - if not re.match(RE_HOSTNAME, common_name.value): + common_name = csr["certification_request_info"]["subject"].native["common_name"] + + if not re.match(RE_HOSTNAME, common_name): raise ValueError("Invalid common name") - request_path = os.path.join(config.REQUESTS_DIR, common_name.value + ".pem") + request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") # If there is cert, check if it's the same @@ -112,27 +122,13 @@ def store_request(buf, overwrite=False, address="", user=""): fh.write(buf) os.rename(request_path + ".part", request_path) - attach_csr = buf, "application/x-pem-file", common_name.value + ".csr" + attach_csr = buf, "application/x-pem-file", common_name + ".csr" mailer.send("request-stored.md", attachments=(attach_csr,), - common_name=common_name.value) + common_name=common_name) setxattr(request_path, "user.request.address", address) setxattr(request_path, "user.request.user", user) - return request_path, csr, common_name.value - - -def signer_exec(cmd, *bits): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(const.SIGNER_SOCKET_PATH) - sock.send(cmd.encode("ascii")) - sock.send(b"\n") - for bit in bits: - sock.send(bit.encode("ascii")) - sock.sendall(b"\n\n") - buf = sock.recv(8192) - if not buf: - raise Exception("Connection lost") - return buf + return request_path, csr, common_name def revoke(common_name): @@ -140,9 +136,9 @@ def revoke(common_name): Revoke valid certificate """ signed_path, buf, cert = get_signed(common_name) - revoked_path = os.path.join(config.REVOKED_DIR, "%x.pem" % cert.serial) + revoked_path = os.path.join(config.REVOKED_DIR, "%x.pem" % cert.serial_number) os.rename(signed_path, revoked_path) - os.unlink(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial)) + os.unlink(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial_number)) push.publish("certificate-revoked", common_name) @@ -155,7 +151,7 @@ def revoke(common_name): attach_cert = buf, "application/x-pem-file", common_name + ".crt" mailer.send("certificate-revoked.md", attachments=(attach_cert,), - serial_hex="%x" % cert.serial, + serial_hex="%x" % cert.serial_number, common_name=common_name) return revoked_path @@ -186,12 +182,13 @@ def _list_certificates(directory): path = os.path.join(directory, filename) with open(path) as fh: buf = fh.read() - cert = x509.load_pem_x509_certificate(buf, default_backend()) + header, _, der_bytes = pem.unarmor(buf) + cert = x509.Certificate.load(der_bytes) 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 + for extension in cert["tbs_certificate"]["extensions"]: + if extension["extn_id"].native == u"extended_key_usage": + if u"server_auth" in extension["extn_value"].native: + server = True yield common_name, path, buf, cert, server def list_signed(): @@ -203,10 +200,13 @@ def list_revoked(): def list_server_names(): return [cn for cn, path, buf, cert, server in list_signed() if server] -def export_crl(): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(const.SIGNER_SOCKET_PATH) - sock.send(b"export-crl\n") +def export_crl(pem=True): + builder = CertificateListBuilder( + config.AUTHORITY_CRL_URL, + certificate, + 1 # TODO: monotonically increasing + ) + for filename in os.listdir(config.REVOKED_DIR): if not filename.endswith(".pem"): continue @@ -215,9 +215,15 @@ def export_crl(): revoked_path = os.path.join(config.REVOKED_DIR, filename) # TODO: Skip expired certificates s = os.stat(revoked_path) - sock.send(("%s:%d\n" % (serial_number, s.st_ctime)).encode("ascii")) - sock.sendall(b"\n") - return sock.recv(32*1024*1024) + builder.add_certificate( + int(filename[:-4], 16), + datetime.utcfromtimestamp(s.st_ctime), + u"key_compromise") + + certificate_list = builder.build(private_key) + if pem: + return pem_armor_crl(certificate_list) + return certificate_list.dump() def delete_request(common_name): @@ -236,88 +242,17 @@ def delete_request(common_name): config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest(), headers={"User-Agent": "Certidude API"}) -def generate_ovpn_bundle(common_name, owner=None): - # Construct private key - click.echo("Generating %d-bit RSA key for OpenVPN profile..." % const.KEY_SIZE) - - key = rsa.generate_private_key( - public_exponent=65537, - key_size=const.KEY_SIZE, - 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, cert_buf = _sign(csr, buf, overwrite=True) - - bundle = Template(open(config.OPENVPN_PROFILE_TEMPLATE).read()).render( - ca = ca_buf, key = key_buf, cert = cert_buf, crl=export_crl(), - servers = list_server_names()) - return bundle, cert - -def generate_pkcs12_bundle(common_name, owner=None): - """ - Generate private key, sign certificate and return PKCS#12 bundle - """ - - # Construct private key - click.echo("Generating %d-bit RSA key for PKCS#12 bundle..." % const.KEY_SIZE) - - key = rsa.generate_private_key( - public_exponent=65537, - key_size=const.KEY_SIZE, - backend=default_backend() - ) - - 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, cert_buf = _sign(csr, buf, overwrite=True) - - # Generate P12, currently supported only by PyOpenSSL - from OpenSSL import crypto - p12 = crypto.PKCS12() - p12.set_privatekey( - crypto.load_privatekey( - crypto.FILETYPE_PEM, - key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption()))) - p12.set_certificate( - 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 - - def sign(common_name, overwrite=False): """ - Sign certificate signing request via signer process + Sign certificate signing request by it's common name """ 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) + header, _, der_bytes = pem.unarmor(csr_buf) + csr = CertificationRequest.load(der_bytes) + # Sign with function below cert, buf = _sign(csr, csr_buf, overwrite) @@ -326,15 +261,17 @@ def sign(common_name, overwrite=False): return cert, buf def _sign(csr, buf, overwrite=False): - assert buf.startswith("-----BEGIN CERTIFICATE REQUEST-----\n") - assert isinstance(csr, x509.CertificateSigningRequest) + # TODO: CRLDistributionPoints, OCSP URL, Certificate URL - common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) - cert_path = os.path.join(config.SIGNED_DIR, "%s.pem" % common_name.value) + assert buf.startswith("-----BEGIN CERTIFICATE REQUEST-----\n") + assert isinstance(csr, CertificationRequest) + csr_pubkey = asymmetric.load_public_key(csr["certification_request_info"]["subject_pk_info"]) + common_name = csr["certification_request_info"]["subject"].native["common_name"] + cert_path = os.path.join(config.SIGNED_DIR, "%s.pem" % common_name) renew = False attachments = [ - (buf, "application/x-pem-file", common_name.value + ".csr"), + (buf, "application/x-pem-file", common_name + ".csr"), ] revoked_path = None @@ -344,13 +281,18 @@ def _sign(csr, buf, overwrite=False): if os.path.exists(cert_path): with open(cert_path) as fh: prev_buf = fh.read() - prev = x509.load_pem_x509_certificate(prev_buf, default_backend()) + header, _, der_bytes = pem.unarmor(prev_buf) + prev = x509.Certificate.load(der_bytes) + # TODO: assert validity here again? - renew = prev.public_key().public_numbers() == csr.public_key().public_numbers() + renew = \ + asymmetric.load_public_key(prev["tbs_certificate"]["subject_public_key_info"]) == \ + csr_pubkey + # BUGBUG: is this enough? if overwrite: # TODO: is this the best approach? - prev_serial_hex = "%x" % prev.serial + prev_serial_hex = "%x" % prev.serial_number revoked_path = os.path.join(config.REVOKED_DIR, "%s.pem" % prev_serial_hex) os.rename(cert_path, revoked_path) attachments += [(prev_buf, "application/x-pem-file", "deprecated.crt" if renew else "overwritten.crt")] @@ -359,18 +301,40 @@ def _sign(csr, buf, overwrite=False): raise EnvironmentError("Will not overwrite existing certificate") # Sign via signer process - cert_buf = signer_exec("sign-request", buf) - cert = x509.load_pem_x509_certificate(cert_buf, default_backend()) + builder = CertificateBuilder({u'common_name': common_name }, csr_pubkey) + builder.serial_number = random.randint( + 0x1000000000000000000000000000000000000000, + 0xffffffffffffffffffffffffffffffffffffffff) + + now = datetime.utcnow() + builder.begin_date = now - timedelta(minutes=5) + builder.end_date = now + timedelta(days=config.SERVER_CERTIFICATE_LIFETIME + if server_flags(common_name) + else config.CLIENT_CERTIFICATE_LIFETIME) + builder.issuer = certificate + builder.ca = False + builder.key_usage = set([u"digital_signature", u"key_encipherment"]) + + # OpenVPN uses CN while StrongSwan uses SAN + if server_flags(common_name): + builder.subject_alt_domains = [common_name] + builder.extended_key_usage = set([u"server_auth", u"1.3.6.1.5.5.8.2.2", u"client_auth"]) + else: + builder.extended_key_usage = set([u"client_auth"]) + + end_entity_cert = builder.build(private_key) + end_entity_cert_buf = asymmetric.dump_certificate(end_entity_cert) with open(cert_path + ".part", "wb") as fh: - fh.write(cert_buf) + fh.write(end_entity_cert_buf) + os.rename(cert_path + ".part", cert_path) - attachments.append((cert_buf, "application/x-pem-file", common_name.value + ".crt")) - cert_serial_hex = "%x" % cert.serial + attachments.append((end_entity_cert_buf, "application/x-pem-file", common_name + ".crt")) + cert_serial_hex = "%x" % end_entity_cert.serial_number # Create symlink - os.symlink( - "../%s.pem" % common_name.value, - os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial)) + link_name = os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % end_entity_cert.serial_number) + assert not os.path.exists(link_name), "Certificate with same serial number already exists: %s" % link_name + os.symlink("../%s.pem" % common_name, link_name) # Copy filesystem attributes to newly signed certificate if revoked_path: @@ -387,8 +351,8 @@ def _sign(csr, buf, overwrite=False): url = config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest() click.echo("Publishing certificate at %s ..." % url) - requests.post(url, data=cert_buf, + requests.post(url, data=end_entity_cert_buf, headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"}) - push.publish("request-signed", common_name.value) - return cert, cert_buf + push.publish("request-signed", common_name) + return end_entity_cert, end_entity_cert_buf diff --git a/certidude/cli.py b/certidude/cli.py index 9ed08d6..63070f3 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -12,6 +12,7 @@ import socket import string import subprocess import sys +from asn1crypto.util import timezone from base64 import b64encode from configparser import ConfigParser, NoOptionError, NoSectionError from certidude.common import ip_address, ip_network, apt, rpm, pip, drop_privileges, selinux_fixup @@ -26,9 +27,7 @@ logger = logging.getLogger(__name__) # keyUsage, extendedKeyUsage - https://www.openssl.org/docs/apps/x509v3_client_config.html # strongSwan key paths - https://wiki.strongswan.org/projects/1/wiki/SimpleCA -# Parse command-line argument defaults from environment - -NOW = datetime.utcnow().replace(tzinfo=None) +NOW = datetime.utcnow() def fqdn_required(func): def wrapped(**args): @@ -321,8 +320,8 @@ def certidude_request(fork, renew, no_wait, kerberos): with open(certificate_path, "rb") as ch, open(request_path, "rb") as rh, open(key_path, "rb") as kh: cert_buf = ch.read() cert = asymmetric.load_certificate(cert_buf) - expires = cert.asn1["tbs_certificate"]["validity"]["not_after"].native - if renewal_overlap and datetime.now() > expires - timedelta(days=renewal_overlap): + expires = cert.asn1["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None) + if renewal_overlap and NOW > expires - timedelta(days=renewal_overlap): click.echo("Certificate will expire %s, will attempt to renew" % expires) renew = True headers["X-Renewal-Signature"] = b64encode( @@ -931,15 +930,13 @@ def certidude_setup_openvpn_networkmanager(authority, remote, common_name, **pat @fqdn_required 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): # Install only rarely changing stuff from OS package management - apt("python-setproctitle cython python-dev libkrb5-dev libffi-dev libssl-dev") - apt("python-mimeparse python-markdown python-xattr python-jinja2 python-cffi") - apt("python-ldap software-properties-common libsasl2-modules-gssapi-mit") - pip("gssapi falcon humanize ipaddress simplepam humanize requests pyopenssl") + apt("cython python-dev python-mimeparse python-markdown python-xattr python-jinja2 python-cffi python-ldap software-properties-common libsasl2-modules-gssapi-mit") + pip("gssapi falcon humanize ipaddress simplepam humanize requests") click.echo("Software dependencies installed") - os.system("add-apt-repository -y ppa:nginx/stable") - os.system("apt-get update") if not os.path.exists("/usr/lib/nginx/modules/ngx_nchan_module.so"): + os.system("add-apt-repository -y ppa:nginx/stable") + os.system("apt-get update") os.system("apt-get install -y libnginx-mod-nchan") if not os.path.exists("/usr/sbin/nginx"): os.system("apt-get install -y nginx") @@ -1091,13 +1088,13 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, builder.serial_number = random.randint( 0x100000000000000000000000000000000000000, 0xfffffffffffffffffffffffffffffffffffffff) - now = datetime.utcnow() - builder.begin_date = now - timedelta(minutes=5) - builder.end_date = now + timedelta(days=authority_lifetime) + + builder.begin_date = NOW - timedelta(minutes=5) + builder.end_date = NOW + timedelta(days=authority_lifetime) if server_flags: - builder.key_usage(set(['digital_signature', 'key_encipherment', 'key_cert_sign', 'crl_sign'])) - builder.extended_key_usage(['server_auth', "1.3.6.1.5.5.8.2.2"]) + builder.key_usage = set(['digital_signature', 'key_encipherment', 'key_cert_sign', 'crl_sign']) + builder.extended_key_usage = set(['server_auth', "1.3.6.1.5.5.8.2.2"]) certificate = builder.build(private_key) @@ -1162,9 +1159,6 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign 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 common_name, path, buf, csr, server in authority.list_requests(): @@ -1172,6 +1166,7 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign if not verbose: click.echo("s " + path) continue + click.echo() 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")) @@ -1181,35 +1176,39 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign if show_signed: for common_name, path, buf, cert, server in authority.list_signed(): + signed = cert["tbs_certificate"]["validity"]["not_before"].native.replace(tzinfo=None) + expires = cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None) if not verbose: - if cert.not_valid_before < NOW and cert.not_valid_after > NOW: + if signed < NOW and NOW < expires: click.echo("v " + path) - elif NOW > cert.not_valid_after: + elif expires < NOW: click.echo("e " + path) else: click.echo("y " + path) continue - - click.echo(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial, fg="white")) + click.echo() + 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")) + + if signed < NOW and NOW < expires: + click.echo("Status: " + click.style("valid", fg="green") + " until " + naturaltime(expires) + click.style(", %s" % expires, fg="white")) + elif NOW > expires: + click.echo("Status: " + click.style("expired", fg="red") + " " + naturaltime(expires) + click.style(", %s" % expires, fg="white")) else: - click.echo("Status: " + click.style("not valid yet", fg="red") + click.style(", %s" %expires, fg="white")) + 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) + for ext in cert["tbs_certificate"]["extensions"]: + print " - %s: %s" % (ext["extn_id"].native, repr(ext["extn_value"].native)) if show_revoked: for common_name, path, buf, cert, server in authority.list_revoked(): if not verbose: click.echo("r " + path) continue - - click.echo(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial, fg="white")) + click.echo() + click.echo(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial_number, fg="white")) click.echo("="*(len(common_name)+60)) _, _, _, _, _, _, _, _, mtime, _ = os.stat(path) @@ -1217,24 +1216,24 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign 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() + for ext in cert["tbs_certificate"]["extensions"]: + print " - %s: %s" % (ext["extn_id"].native, repr(ext["extn_value"].native)) @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") def certidude_sign(common_name, overwrite): - drop_privileges() from certidude import authority + drop_privileges() cert = authority.sign(common_name, overwrite) @click.command("revoke", help="Revoke certificate") @click.argument("common_name") def certidude_revoke(common_name): - drop_privileges() from certidude import authority + drop_privileges() authority.revoke(common_name) @@ -1242,10 +1241,10 @@ def certidude_revoke(common_name): 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) + expires = cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None) + if expires < NOW: + expired_path = os.path.join(config.EXPIRED_DIR, "%x.pem" % cert.serial_number) assert not os.path.exists(expired_path) os.rename(path, expired_path) click.echo("Moved %s to %s" % (path, expired_path)) @@ -1258,7 +1257,6 @@ def certidude_cron(): def certidude_serve(port, listen, fork): import pwd from setproctitle import setproctitle - from certidude.signer import SignServer from certidude import authority, const, push if port == 80: @@ -1272,7 +1270,7 @@ def certidude_serve(port, listen, fork): # Rebuild reverse mapping for cn, path, buf, cert, server in authority.list_signed(): - by_serial = os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial) + by_serial = os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial_number) if not os.path.exists(by_serial): click.echo("Linking %s to ../%s.pem" % (by_serial, cn)) os.symlink("../%s.pem" % cn, by_serial) @@ -1291,55 +1289,6 @@ def certidude_serve(port, listen, fork): rh.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) log_handlers.append(rh) - - """ - Spawn signer process - """ - - if os.path.exists(const.SIGNER_SOCKET_PATH): - os.unlink(const.SIGNER_SOCKET_PATH) - - signer_pid = os.fork() - if not signer_pid: - click.echo("Signer process spawned with PID %d at %s" % (os.getpid(), const.SIGNER_SOCKET_PATH)) - setproctitle("[signer]") - - with open(const.SIGNER_PID_PATH, "w") as fh: - fh.write("%d\n" % os.getpid()) - - logging.basicConfig( - filename=const.SIGNER_LOG_PATH, - level=logging.INFO) - - os.umask(0o007) - server = SignServer() - - # Drop privileges - _, _, uid, gid, gecos, root, shell = pwd.getpwnam("certidude") - os.chown(const.SIGNER_SOCKET_PATH, uid, gid) - os.chmod(const.SIGNER_SOCKET_PATH, 0770) - - click.echo("Dropping privileges of signer") - _, _, uid, gid, gecos, root, shell = pwd.getpwnam("nobody") - os.setgroups([]) - os.setgid(gid) - os.setuid(uid) - - try: - asyncore.loop() - except asyncore.ExitNow: - pass - click.echo("Signer was shut down") - return - click.echo("Waiting for signer to start up") - time_left = 2.0 - delay = 0.1 - while not os.path.exists(const.SIGNER_SOCKET_PATH) and time_left > 0: - sleep(delay) - time_left -= delay - assert authority.signer_exec("ping") == "pong" - click.echo("Signer alive") - click.echo("Users subnets: %s" % ", ".join([str(j) for j in config.USER_SUBNETS])) click.echo("Administrative subnets: %s" % @@ -1392,7 +1341,6 @@ def certidude_serve(port, listen, fork): def cleanup_handler(*args): push.publish("server-stopped") logger.debug(u"Shutting down Certidude") - assert authority.signer_exec("exit") == "ok" sys.exit(0) # TODO: use another code, needs test refactor import signal diff --git a/certidude/const.py b/certidude/const.py index 13b24af..5f36510 100644 --- a/certidude/const.py +++ b/certidude/const.py @@ -12,9 +12,6 @@ CLIENT_CONFIG_PATH = os.path.join(CONFIG_DIR, "client.conf") SERVICES_CONFIG_PATH = os.path.join(CONFIG_DIR, "services.conf") SERVER_PID_PATH = os.path.join(RUN_DIR, "server.pid") SERVER_LOG_PATH = "/var/log/certidude-server.log" -SIGNER_SOCKET_PATH = "/run/certidude/signer.sock" -SIGNER_PID_PATH = os.path.join(RUN_DIR, "signer.pid") -SIGNER_LOG_PATH = "/var/log/certidude-signer.log" STORAGE_PATH = "/var/lib/certidude/" try: diff --git a/certidude/signer.py b/certidude/signer.py deleted file mode 100644 index 3fc1666..0000000 --- a/certidude/signer.py +++ /dev/null @@ -1,235 +0,0 @@ - - -import random -import socket -import os -import asyncore -import asynchat -from base64 import b64decode, b64encode -from certidude import const, config -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes, padding, serialization -from cryptography.hazmat.primitives.serialization import Encoding -from cryptography.hazmat.primitives.asymmetric import padding -from datetime import datetime, timedelta -from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, AuthorityInformationAccessOID -import random - -class SignHandler(asynchat.async_chat): - def __init__(self, sock, server): - asynchat.async_chat.__init__(self, sock=sock) - self.buffer = [] - self.set_terminator(b"\n\n") - self.server = server - - def parse_command(self, cmd, body=""): - now = datetime.utcnow() - if cmd == "export-crl": - """ - Generate CRL object based on certificate serial number and revocation timestamp - """ - - builder = x509.CertificateRevocationListBuilder( - ).last_update( - now - timedelta(minutes=5) - ).next_update( - now + timedelta(seconds=config.REVOCATION_LIST_LIFETIME) - ).issuer_name(self.server.certificate.issuer - ).add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key( - self.server.certificate.public_key()), False) - - if body: - for line in body.split("\n"): - serial_number, timestamp = line.split(":") - revocation = x509.RevokedCertificateBuilder( - ).serial_number(int(serial_number, 16) - ).revocation_date(datetime.utcfromtimestamp(int(timestamp)) - ).add_extension(x509.CRLReason(x509.ReasonFlags.key_compromise), False - ).build(default_backend()) - builder = builder.add_revoked_certificate(revocation) - - crl = builder.sign( - self.server.private_key, - hashes.SHA512(), - default_backend()) - - self.send(crl.public_bytes(Encoding.PEM)) - - elif cmd == "ping": - self.send("pong") - self.close() - - elif cmd == "exit": - self.send("ok") - self.close() - raise asyncore.ExitNow() - - elif cmd == "sign-pkcs7": - signer = self.server.private_key.signer( - padding.PKCS1v15(), - hashes.SHA1() - ) - signer.update(b64decode(body)) - self.send(b64encode(signer.finalize())) - - elif cmd == "decrypt-pkcs7": - self.send(b64encode(self.server.private_key.decrypt(b64decode(body), padding.PKCS1v15()))) - self.close() - - elif cmd == "sign-request": - # Only common name and public key are used from request - request = x509.load_pem_x509_csr(body, default_backend()) - common_name, = request.subject.get_attributes_for_oid(NameOID.COMMON_NAME) - - # If common name is a fully qualified name assume it has to be signed - # with server certificate flags - server_flags = "." in common_name.value - - # TODO: For fqdn allow autosign with validation - - extended_key_usage_flags = [] - if server_flags: - extended_key_usage_flags.append( # IKE intermediate for IPSec - x509.ObjectIdentifier("1.3.6.1.5.5.8.2.2")) - extended_key_usage_flags.append( # OpenVPN server - ExtendedKeyUsageOID.SERVER_AUTH) - else: - extended_key_usage_flags.append( # OpenVPN client - ExtendedKeyUsageOID.CLIENT_AUTH) - - aia = [ - x509.AccessDescription( - AuthorityInformationAccessOID.CA_ISSUERS, - x509.UniformResourceIdentifier(config.AUTHORITY_CERTIFICATE_URL)) - ] - - if config.AUTHORITY_OCSP_URL: - aia.append( - x509.AccessDescription( - AuthorityInformationAccessOID.OCSP, - x509.UniformResourceIdentifier(config.AUTHORITY_OCSP_URL))) - - builder = x509.CertificateBuilder( - ).subject_name( - x509.Name([common_name]) - ).serial_number(random.randint( - 0x1000000000000000000000000000000000000000, - 0x7fffffffffffffffffffffffffffffffffffffff) - ).issuer_name( - self.server.certificate.issuer - ).public_key( - request.public_key() - ).not_valid_before( - now - timedelta(minutes=5) - ).not_valid_after( - now + timedelta(days= - config.SERVER_CERTIFICATE_LIFETIME - if server_flags - else config.CLIENT_CERTIFICATE_LIFETIME) - ).add_extension( - x509.BasicConstraints( - ca=False, - path_length=None), - critical=True, - ).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.ExtendedKeyUsage( - extended_key_usage_flags), - critical=True, - ).add_extension( - x509.SubjectKeyIdentifier.from_public_key( - request.public_key()), - critical=False - ).add_extension( - x509.AuthorityInformationAccess(aia), - critical=False - ).add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key( - self.server.certificate.public_key()), - critical=False - ) - - if config.AUTHORITY_CRL_URL: - builder = builder.add_extension( - x509.CRLDistributionPoints([ - x509.DistributionPoint( - full_name=[ - x509.UniformResourceIdentifier( - config.AUTHORITY_CRL_URL)], - relative_name=None, - crl_issuer=None, - reasons=None) - ]), - critical=False - ) - - # OpenVPN uses CN while StrongSwan uses SAN - if server_flags: - builder = builder.add_extension( - x509.SubjectAlternativeName( - [x509.DNSName(common_name.value)] - ), - critical=False - ) - - cert = builder.sign(self.server.private_key, hashes.SHA512(), default_backend()) - - self.send(cert.public_bytes(serialization.Encoding.PEM)) - else: - raise NotImplementedError("Unknown command: %s" % cmd) - - self.close_when_done() - - def found_terminator(self): - args = (b"".join(self.buffer)).split("\n", 1) - self.parse_command(*args) - self.buffer = [] - - def collect_incoming_data(self, data): - self.buffer.append(data) - -import signal -import click - -class SignServer(asyncore.dispatcher): - def __init__(self): - asyncore.dispatcher.__init__(self) - - if os.path.exists(const.SIGNER_SOCKET_PATH): - os.unlink(const.SIGNER_SOCKET_PATH) - - self.create_socket(socket.AF_UNIX, socket.SOCK_STREAM) - self.bind(const.SIGNER_SOCKET_PATH) - self.listen(5) - - # Load CA private key and certificate - click.echo("Signer reading private key from %s" % config.AUTHORITY_PRIVATE_KEY_PATH) - self.private_key = serialization.load_pem_private_key( - open(config.AUTHORITY_PRIVATE_KEY_PATH).read(), - password=None, # TODO: Ask password for private key? - backend=default_backend()) - click.echo("Signer reading certificate from %s" % config.AUTHORITY_CERTIFICATE_PATH) - self.certificate = x509.load_pem_x509_certificate( - open(config.AUTHORITY_CERTIFICATE_PATH).read(), - backend=default_backend()) - - - def handle_accept(self): - pair = self.accept() - if pair is not None: - sock, addr = pair - handler = SignHandler(sock, self) - diff --git a/certidude/static/views/request.html b/certidude/static/views/request.html index ea37fa6..aae4697 100644 --- a/certidude/static/views/request.html +++ b/certidude/static/views/request.html @@ -1,7 +1,7 @@
  • Fetch - + diff --git a/certidude/templates/mail/certificate-renewed.md b/certidude/templates/mail/certificate-renewed.md index 817379d..2ffc271 100644 --- a/certidude/templates/mail/certificate-renewed.md +++ b/certidude/templates/mail/certificate-renewed.md @@ -1,9 +1,9 @@ -Renewed {{ common_name.value }} ({{ cert_serial_hex }}) +Renewed {{ common_name }} ({{ cert_serial_hex }}) -This is simply to notify that certificate for {{ common_name.value }} +This is simply to notify that certificate for {{ common_name }} was renewed and the serial number of the new certificate is {{ cert_serial_hex }}. -The new certificate is valid from {{ cert.not_valid_before }} until -{{ cert.not_valid_after }}. +The new certificate is valid from {{ builder.begin_date }} until +{{ builder.end_date }}. Services making use of those certificates should continue working as expected. diff --git a/certidude/templates/mail/certificate-signed.md b/certidude/templates/mail/certificate-signed.md index e2abdab..86454a0 100644 --- a/certidude/templates/mail/certificate-signed.md +++ b/certidude/templates/mail/certificate-signed.md @@ -1,11 +1,11 @@ -Signed {{ common_name.value }} ({{ cert_serial_hex }}) +Signed {{ common_name }} ({{ cert_serial_hex }}) -This is simply to notify that certificate {{ common_name.value }} +This is simply to notify that certificate {{ common_name }} with serial number {{ cert_serial_hex }} was signed{% if signer %} by {{ signer }}{% endif %}. -The certificate is valid from {{ cert.not_valid_before }} until -{{ cert.not_valid_after }}. +The certificate is valid from {{ builder.begin_date }} until +{{ builder.end_date }}. {% if overwritten %} By doing so existing certificate with the same common name diff --git a/certidude/templates/server/nginx.conf b/certidude/templates/server/nginx.conf index 41785a0..a8dab77 100644 --- a/certidude/templates/server/nginx.conf +++ b/certidude/templates/server/nginx.conf @@ -1,69 +1,136 @@ # To set up SSL certificates using Let's Encrypt run: # -# apt install letsencrypt -# certbot certonly -d {{common_name}} --webroot /var/www/html/ + # # Also uncomment URL rewriting and SSL configuration below +# Basic DoS prevention measures +limit_conn addr 10; +client_body_timeout 5s; +client_header_timeout 5s; limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m; limit_conn_zone $binary_remote_addr zone=addr:10m; +# Backend configuration +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-SSL-CERT $ssl_client_cert; +proxy_connect_timeout 600; +proxy_send_timeout 600; +proxy_read_timeout 600; +proxy_set_header Host $host; +send_timeout 600; + +# Don't buffer any messages +nchan_message_buffer_length 0; + +# To use CA-s own certificate for HTTPS +ssl_certificate /var/lib/certidude/{{common_name}}/ca_crt.pem; +ssl_certificate_key /var/lib/certidude/{{common_name}}/ca_key.pem; + +# To use Let's Encrypt certificates +#ssl_certificate /etc/letsencrypt/live/{{common_name}}/fullchain.pem; +#ssl_certificate_key /etc/letsencrypt/live/{{common_name}}/privkey.pem; + +# Also run the following to set up Let's Encrypt certificates: +# +# apt install letsencrypt +# certbot certonly -d {{common_name}} --webroot /var/www/html/ + server { + # Section for serving insecure HTTP, note that this is suitable for + # OCSP, SCEP, CRL-s etc which is already covered by PKI protection mechanisms. + # This also solves the chicken-and-egg problem of deploying the certificates + server_name {{ common_name }}; listen 80 default_server; -# rewrite ^ https://$server_name$request_uri? permanent; -#} - -#server { -# server_name {{ common_name }}; -# listen 443 ssl http2 default_server; -# add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload;"; -# ssl_certificate /etc/letsencrypt/live/{{common_name}}/fullchain.pem; -# ssl_certificate_key /etc/letsencrypt/live/{{common_name}}/privkey.pem; - - root {{static_path}}; - - # Basic DoS prevention measures - limit_conn addr 10; - client_body_timeout 5s; - client_header_timeout 5s; + # Proxy pass to backend location /api/ { proxy_pass http://127.0.1.1:8080/api/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_connect_timeout 600; - proxy_send_timeout 600; - proxy_read_timeout 600; - send_timeout 600; limit_req zone=api burst=5; } - # This is for Let's Encrypt - location /.well-known/ { - alias /var/www/html/.well-known/; - } + # Path to static files + root {{static_path}}; # Rewrite /cgi-bin/pkiclient.exe to /api/scep for SCEP protocol location /cgi-bin/pkiclient.exe { rewrite /cgi-bin/pkiclient.exe /api/scep/ last; } -{% if not push_server %} - # This only works with nchan, for Debian 9 just apt install libnginx-mod-nchan - # For Ubuntu and older Debian releases install nchan from https://nchan.io/ - + # Long poll for CSR submission location ~ "^/lp/sub/(.*)" { nchan_channel_id $1; nchan_subscriber longpoll; } + # Comment everything below in this server definition if you're using HTTPS + + # Event source for web interface location ~ "^/ev/sub/(.*)" { nchan_channel_id $1; nchan_subscriber eventsource; } -{% endif %} + # Uncomment following to enable HTTPS + #rewrite ^/$ https://$server_name$request_uri? permanent; +} + +server { + # Section for accessing web interface over HTTPS + listen 443 ssl http2 default_server; + server_name {{ common_name }}; + + # HSTS header below should make sure web interface will be accessed over HTTPS only + # once it has been configured + add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload;"; + + # Proxy pass to backend + location /api/ { + proxy_pass http://127.0.1.1:8080/api/; + limit_req zone=api burst=5; + } + + # Path to static files + root {{static_path}}; + + # This is for Let's Encrypt enroll/renewal + location /.well-known/ { + alias /var/www/html/.well-known/; + } + + # Event stream for pushinge events to web browsers + location ~ "^/ev/sub/(.*)" { + nchan_channel_id $1; + nchan_subscriber eventsource; + } +} + + +server { + # Section for certificate authenticated HTTPS clients, + # for submitting information to CA eg. leases + # and for delivering scripts to clients + + server_name {{ common_name }}; + listen 8443 ssl http2; + + # Require client authentication with certificate + ssl_verify_client on; + ssl_client_certificate /var/lib/certidude/{{ common_name }}/ca_crt.pem; + + # Proxy pass to backend + location /api/ { + proxy_pass http://127.0.1.1:8080/api/; + limit_req zone=api burst=5; + } + + # Long poll + location ~ "^/lp/sub/(.*)" { + nchan_channel_id $1; + nchan_subscriber longpoll; + } } {% if not push_server %} @@ -75,13 +142,11 @@ server { location ~ "^/lp/pub/(.*)" { nchan_publisher; nchan_channel_id $1; - nchan_message_buffer_length 0; } location ~ "^/ev/pub/(.*)" { nchan_publisher; nchan_channel_id $1; - nchan_message_buffer_length 0; } } {% endif %} diff --git a/requirements.txt b/requirements.txt index 3fb7729..5e7a566 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ click>=6.7 configparser>=3.5.0 certbuilder +crlbuilder +oscrypto diff --git a/tests/test_cli.py b/tests/test_cli.py index 8a275ab..b892c15 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -80,12 +80,6 @@ def clean_client(): def clean_server(): - if os.path.exists("/run/certidude/signer.pid"): - with open("/run/certidude/signer.pid") as fh: - try: - os.kill(int(fh.read()), 15) - except OSError: - pass if os.path.exists("/run/certidude/server.pid"): with open("/run/certidude/server.pid") as fh: try: @@ -239,16 +233,18 @@ def test_cli_setup_authority(): os.setgid(0) # Restore GID os.umask(0022) + # Make sure nginx is running assert not result.exception, result.output assert os.getuid() == 0 and os.getgid() == 0, "Serve dropped permissions incorrectly!" assert os.system("nginx -t") == 0, "invalid nginx configuration" + os.system("service nginx restart") assert os.path.exists("/run/nginx.pid"), "nginx wasn't started up properly" from certidude import config, authority, auth, user - assert authority.ca_cert.serial_number >= 0x100000000000000000000000000000000000000 - assert authority.ca_cert.serial_number <= 0xfffffffffffffffffffffffffffffffffffffff - assert authority.ca_cert.not_valid_before < datetime.now() - assert authority.ca_cert.not_valid_after > datetime.now() + timedelta(days=7000) + assert authority.certificate.serial_number >= 0x100000000000000000000000000000000000000 + assert authority.certificate.serial_number <= 0xfffffffffffffffffffffffffffffffffffffff + assert authority.certificate["tbs_certificate"]["validity"]["not_before"].native.replace(tzinfo=None) < datetime.utcnow() + assert authority.certificate["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None) > datetime.utcnow() + timedelta(days=7000) assert authority.server_flags("lauri@fedora-123") == False assert authority.server_flags("fedora-123") == False assert authority.server_flags("vpn.example.lan") == True @@ -412,12 +408,12 @@ def test_cli_setup_authority(): assert not result.exception, result.output # Test sign API call - r = client().simulate_patch("/api/request/test/") + r = client().simulate_post("/api/request/test/") assert r.status_code == 401, r.text - r = client().simulate_patch("/api/request/test/", + r = client().simulate_post("/api/request/test/", headers={"Authorization":usertoken}) assert r.status_code == 403, r.text - r = client().simulate_patch("/api/request/test/", + r = client().simulate_post("/api/request/test/", headers={"Authorization":admintoken}) assert r.status_code == 201, r.text assert "Signed " in inbox.pop(), inbox @@ -476,7 +472,7 @@ def test_cli_setup_authority(): # Test revocations API call r = client().simulate_get("/api/revoked/", headers={"Accept":"application/x-pem-file"}) - assert r.status_code == 200, r.text # if this breaks certidude serve has no access to signer socket + assert r.status_code == 200, r.text assert r.headers.get('content-type') == "application/x-pem-file" r = client().simulate_get("/api/revoked/") @@ -672,29 +668,11 @@ def test_cli_setup_authority(): assert "/ev/sub/" in r.text, r.text assert r.json, r.text assert r.json.get("authority"), r.text - assert r.json.get("authority").get("events"), r.text - - - ################################# - ### Subscribe to event source ### - ################################# - - ev_pid = os.fork() - if not ev_pid: - url = r.json.get("authority").get("events") - if url.startswith("/"): # Expand URL - url = "http://ca.example.lan" + url - r = requests.get(url, headers={"Accept": "text/event-stream"}, stream=True) - lines = ["data: userbot@fedora-15417dc5", "event: request-signed"] # In reverse order! - assert r.status_code == 200, r.text - for line in r.iter_lines(): - if not line or line.startswith("id:") or line.startswith(":"): - continue - assert line == lines.pop(), line - if not lines: - return - assert False, r.text # This should not happen - return + ev_url = r.json.get("authority").get("events") + assert ev_url, r.text + if ev_url.startswith("/"): # Expand URL + ev_url = "http://ca.example.lan" + ev_url + assert ev_url.startswith("http://ca.example.lan/ev/sub/") ####################### @@ -704,6 +682,7 @@ def test_cli_setup_authority(): r = client().simulate_post("/api/token/") assert r.status_code == 404, r.text + """ config.BUNDLE_FORMAT = "ovpn" config.USER_ENROLLMENT_ALLOWED = True @@ -734,6 +713,7 @@ def test_cli_setup_authority(): assert r2.status_code == 200 # token consumed by anyone on unknown device assert r2.headers.get('content-type') == "application/x-pkcs12" assert "Signed " in inbox.pop(), inbox + """ # Beyond this point don't use client() const.STORAGE_PATH = "/tmp/" @@ -765,12 +745,13 @@ def test_cli_setup_authority(): result = runner.invoke(cli, ["request", "--no-wait"]) assert not result.exception, result.output assert not os.path.exists("/run/certidude/ca.example.lan.pid"), result.output - assert "refused to sign" in result.output, result.output + assert "(Autosign failed, only client certificates allowed to be signed automatically)" in result.output, result.output child_pid = os.fork() if not child_pid: - result = runner.invoke(cli, ['sign', 'www.example.lan']) + result = runner.invoke(cli, ["sign", "www.example.lan"]) assert not result.exception, result.output + assert "Publishing request-signed event 'www.example.lan' on http://localhost/ev/pub/" in result.output, result.output return else: os.waitpid(child_pid, 0) @@ -785,13 +766,10 @@ def test_cli_setup_authority(): assert not os.path.exists("/run/certidude/ca.example.lan.pid"), result.output #assert "Writing certificate to:" in result.output, result.output assert "Attached renewal signature" in result.output, result.output - #assert "refused to sign immideately" not in result.output, result.output # Test nginx setup assert os.system("nginx -t") == 0, "Generated nginx config was invalid" - # TODO: test client verification with curl - ############### ### OpenVPN ### @@ -818,13 +796,17 @@ def test_cli_setup_authority(): result = runner.invoke(cli, ["request", "--no-wait"]) assert not result.exception, result.output + assert "(Autosign failed, only client certificates allowed to be signed automatically)" in result.output, result.output assert not os.path.exists("/run/certidude/ca.example.lan.pid"), result.output - + assert not os.path.exists("/var/lib/certidude/ca.example.lan/signed/vpn.example.lan.pem") child_pid = os.fork() if not child_pid: - result = runner.invoke(cli, ['sign', 'vpn.example.lan']) + assert not os.path.exists("/var/lib/certidude/ca.example.lan/signed/vpn.example.lan.pem") + result = runner.invoke(cli, ["sign", "vpn.example.lan"]) assert not result.exception, result.output + assert "overwrit" not in result.output, result.output + assert "Publishing request-signed event 'vpn.example.lan' on http://localhost/ev/pub/" in result.output, result.output return else: os.waitpid(child_pid, 0) @@ -859,10 +841,164 @@ def test_cli_setup_authority(): # TODO: Check that tunnel interfaces came up, perhaps try to ping? # TODO: assert key, req, cert paths were included correctly in OpenVPN config + clean_client() - ############### + result = runner.invoke(cli, ['setup', 'openvpn', 'networkmanager', "-cn", "roadwarrior3", "ca.example.lan", "vpn.example.lan"]) + assert not result.exception, result.output + + with open("/etc/certidude/client.conf", "a") as fh: + fh.write("insecure = true\n") + + result = runner.invoke(cli, ["request", "--no-wait"]) + assert not result.exception, result.output + assert not os.path.exists("/run/certidude/ca.example.lan.pid"), result.output + assert "Writing certificate to:" in result.output, result.output + + + ################################# + ### Subscribe to event source ### + ################################# + + ev_pid = os.fork() + if not ev_pid: + r = requests.get(ev_url, headers={"Accept": "text/event-stream"}, stream=True) + assert r.status_code == 200, r.text + i = r.iter_lines() + assert i.next() == ": hi" + assert not i.next() + + # IPSec gateway below + assert i.next() == "event: log-entry", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Served CA certificate ') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Serving revocation list (PEM)') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() # FIXME + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Serving revocation list (PEM)') + assert not i.next() + + assert i.next() == "event: request-submitted", "%s; %s" % (i.next(), i.next()) + assert i.next().startswith("id:") + assert i.next() == "data: ipsec.example.lan" + assert not i.next() + + assert i.next() == "event: log-entry", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Stored signing request ipsec.example.lan ') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() # FIXME + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Stored signing request ipsec.example.lan ') + assert not i.next() + + assert i.next() == "event: request-signed" + assert i.next().startswith("id:") + assert i.next().startswith('data: ipsec.example.lan') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Serving revocation list (PEM)') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() # FIXME + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Serving revocation list (PEM)') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Served certificate ipsec.example.lan') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() # FIXME + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Served certificate ipsec.example.lan') + assert not i.next() + + # IPsec client as service enroll + assert i.next() == "event: log-entry", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Serving revocation list (PEM)') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() # FIXME + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Serving revocation list (PEM)') + assert not i.next() + + assert i.next() == "event: request-signed", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: roadwarrior2') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Autosigned roadwarrior2') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() # FIXME + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Autosigned roadwarrior2') + assert not i.next() + + + + # IPSec client using Networkmanger enroll + assert i.next() == "event: log-entry", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Served CA certificate ') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Serving revocation list (PEM)') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() # FIXME + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Serving revocation list (PEM)') + assert not i.next() + + assert i.next() == "event: request-signed", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: roadwarrior4') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Autosigned roadwarrior4') + assert not i.next() + + assert i.next() == "event: log-entry", i.next() # FIXME + assert i.next().startswith("id:") + assert i.next().startswith('data: {"message": "Autosigned roadwarrior4') + assert not i.next() + + + # Revoke + + assert i.next() == "event: certificate-revoked", i.next() # why?! + assert i.next().startswith("id:") + assert i.next().startswith('data: roadwarrior4') + assert not i.next() + + + return + + + ############# ### IPSec ### - ############### + ############# + + # Setup gateway clean_client() @@ -882,11 +1018,15 @@ def test_cli_setup_authority(): result = runner.invoke(cli, ["request", "--no-wait"]) assert not result.exception, result.output assert not os.path.exists("/run/certidude/ca.example.lan.pid"), result.output + assert not os.path.exists("/var/lib/certidude/ca.example.lan/signed/ipsec.example.lan.pem") child_pid = os.fork() if not child_pid: - result = runner.invoke(cli, ['sign', 'ipsec.example.lan']) + assert not os.path.exists("/var/lib/certidude/ca.example.lan/signed/ipsec.example.lan.pem") + result = runner.invoke(cli, ["sign", "ipsec.example.lan"]) assert not result.exception, result.output + assert "overwrit" not in result.output, result.output + assert "Publishing request-signed event 'ipsec.example.lan' on http://localhost/ev/pub/" in result.output, result.output return else: os.waitpid(child_pid, 0) @@ -898,7 +1038,8 @@ def test_cli_setup_authority(): assert "Writing certificate to:" in result.output, result.output assert os.path.exists("/tmp/ca.example.lan/server_cert.pem") - # Reset config + # IPSec client as service + os.unlink("/etc/certidude/client.conf") os.unlink("/etc/certidude/services.conf") @@ -917,23 +1058,7 @@ def test_cli_setup_authority(): assert "Writing certificate to:" in result.output, result.output - - ###################### - ### NetworkManager ### - ###################### - - clean_client() - - result = runner.invoke(cli, ['setup', 'openvpn', 'networkmanager', "-cn", "roadwarrior3", "ca.example.lan", "vpn.example.lan"]) - assert not result.exception, result.output - - with open("/etc/certidude/client.conf", "a") as fh: - fh.write("insecure = true\n") - - result = runner.invoke(cli, ["request", "--no-wait"]) - assert not result.exception, result.output - assert not os.path.exists("/run/certidude/ca.example.lan.pid"), result.output - assert "Writing certificate to:" in result.output, result.output + # IPSec using NetworkManager clean_client() @@ -1158,11 +1283,15 @@ def test_cli_setup_authority(): ################## os.umask(0022) - assert not os.system("git clone https://github.com/certnanny/sscep /tmp/sscep") - assert not os.system("cd /tmp/sscep && ./Configure && make sscep_dyn") + if not os.path.exists("/tmp/sscep"): + assert not os.system("git clone https://github.com/certnanny/sscep /tmp/sscep") + if not os.path.exists("/tmp/sscep/sscep_dyn"): + assert not os.system("cd /tmp/sscep && ./Configure && make sscep_dyn") assert not os.system("/tmp/sscep/sscep_dyn getca -c /tmp/sscep/ca.pem -u http://ca.example.lan/cgi-bin/pkiclient.exe") - assert not os.system("openssl genrsa -out /tmp/key.pem 1024") - assert not os.system("echo '.\n.\n.\n.\n.\ntest8\n\n\n\n' | openssl req -new -sha256 -key /tmp/key.pem -out /tmp/req.pem") + if not os.path.exists("/tmp/key.pem"): + assert not os.system("openssl genrsa -out /tmp/key.pem 1024") + if not os.path.exists("/tmp/req.pem"): + assert not os.system("echo '.\n.\n.\n.\n.\ntest8\n\n\n\n' | openssl req -new -sha256 -key /tmp/key.pem -out /tmp/req.pem") assert not os.system("/tmp/sscep/sscep_dyn enroll -c /tmp/sscep/ca.pem -u http://ca.example.lan/cgi-bin/pkiclient.exe -k /tmp/key.pem -r /tmp/req.pem -l /tmp/cert.pem") # TODO: test e-mails at this point