From 5e9251f36590df3608b252cb7c15dcdbb1915329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lauri=20V=C3=B5sandi?= Date: Fri, 27 Apr 2018 07:48:15 +0000 Subject: [PATCH] Several updates * Subnets configuration option for Kerberos machine enrollment * Configurable script snippets via [service] configuration section * Preliminary revocation reason support * Improved signature profile support * Add domain components to DN to distinguish certificate CN's namespace * Image builder improvements, add Elliptic Curve support * Added GetCACaps operation and more digest algorithms for SCEP * Generate certificate and CRL serial from timestamp (64+32bits) and random bytes (56bits) * Move client storage pool to /etc/certidude/authority/ * Cleanups & bugfixes --- .gitignore | 4 + certidude/api/__init__.py | 21 +- certidude/api/attrib.py | 7 +- certidude/api/builder.py | 10 +- certidude/api/lease.py | 8 +- certidude/api/ocsp.py | 13 +- certidude/api/request.py | 101 +++--- certidude/api/scep.py | 91 +++-- certidude/api/signed.py | 8 +- certidude/auth.py | 17 +- certidude/authority.py | 127 ++++--- certidude/cli.py | 130 ++++---- certidude/common.py | 59 ++++ certidude/config.py | 11 +- certidude/const.py | 8 +- certidude/static/index.html | 4 +- certidude/static/js/certidude.js | 11 +- certidude/static/views/authority.html | 334 +++++++++++++------ certidude/static/views/lease.html | 2 +- certidude/static/views/signed.html | 17 +- certidude/templates/script/default.sh | 32 +- certidude/templates/server/builder.conf | 36 +- certidude/templates/server/nginx.conf | 5 +- certidude/templates/server/profile.conf | 20 +- certidude/templates/server/server.conf | 99 +++++- doc/build-ap.sh | 58 ---- doc/builder/ap.sh | 7 +- doc/builder/common.sh | 92 +++-- doc/builder/ipcam.sh | 7 +- doc/builder/mfp.sh | 10 +- doc/overlay/etc/hotplug.d/iface/50-certidude | 10 + doc/overlay/etc/uci-defaults/60-cron | 9 + doc/overlay/usr/bin/certidude-enroll | 123 +++++++ doc/overlay/usr/bin/certidude-enroll-renew | 25 ++ tests/test_cli.py | 256 +++++++------- 35 files changed, 1192 insertions(+), 580 deletions(-) delete mode 100644 doc/build-ap.sh create mode 100644 doc/overlay/etc/hotplug.d/iface/50-certidude create mode 100644 doc/overlay/etc/uci-defaults/60-cron create mode 100755 doc/overlay/usr/bin/certidude-enroll create mode 100755 doc/overlay/usr/bin/certidude-enroll-renew diff --git a/.gitignore b/.gitignore index d80a151..b5feee3 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,7 @@ node_modules/ # Ignore patch *.orig *.rej + +lextab.py +yacctab.py +.pytest_cache diff --git a/certidude/api/__init__.py b/certidude/api/__init__.py index ab8a705..444808b 100644 --- a/certidude/api/__init__.py +++ b/certidude/api/__init__.py @@ -8,6 +8,7 @@ import hashlib from datetime import datetime from xattr import listxattr, getxattr from certidude.auth import login_required +from certidude.common import cert_to_dn from certidude.user import User from certidude.decorators import serialize, csrf_protection from certidude import const, config, authority @@ -54,7 +55,7 @@ class SessionResource(AuthorityHandler): ) def serialize_revoked(g): - for common_name, path, buf, cert, signed, expired, revoked in g(): + for common_name, path, buf, cert, signed, expired, revoked, reason in g(): yield dict( serial = "%x" % cert.serial_number, common_name = common_name, @@ -62,6 +63,7 @@ class SessionResource(AuthorityHandler): signed = signed, expired = expired, revoked = revoked, + reason = reason, sha256sum = hashlib.sha256(buf).hexdigest()) def serialize_certificates(g): @@ -69,7 +71,7 @@ class SessionResource(AuthorityHandler): # Extract certificate tags from filesystem try: tags = [] - for tag in getxattr(path, "user.xdg.tags").decode("ascii").split(","): + for tag in getxattr(path, "user.xdg.tags").decode("utf-8").split(","): if "=" in tag: k, v = tag.split("=", 1) else: @@ -116,7 +118,7 @@ class SessionResource(AuthorityHandler): extensions = dict([ (e["extn_id"].native, e["extn_value"].native) for e in cert["tbs_certificate"]["extensions"] - if e["extn_value"] in ("extended_key_usage",)]) + if e["extn_id"].native in ("extended_key_usage",)]) ) if req.context.get("user").is_admin(): @@ -131,6 +133,11 @@ class SessionResource(AuthorityHandler): mail=req.context.get("user").mail ), request_submission_allowed = config.REQUEST_SUBMISSION_ALLOWED, + service = dict( + protocols = config.SERVICE_PROTOCOLS, + routers = [j[0] for j in authority.list_signed( + common_name=config.SERVICE_ROUTERS)] + ), authority = dict( builder = dict( profiles = config.IMAGE_BUILDER_PROFILES @@ -143,13 +150,15 @@ class SessionResource(AuthorityHandler): certificate = dict( algorithm = authority.public_key.algorithm, common_name = self.authority.certificate.subject.native["common_name"], + distinguished_name = cert_to_dn(self.authority.certificate), + md5sum = hashlib.md5(self.authority.certificate_buf).hexdigest(), blob = self.authority.certificate_buf.decode("ascii"), ), mailer = dict( name = config.MAILER_NAME, address = config.MAILER_ADDRESS ) if config.MAILER_ADDRESS else None, - machine_enrollment_allowed=config.MACHINE_ENROLLMENT_ALLOWED, + machine_enrollment_subnets=config.MACHINE_ENROLLMENT_SUBNETS, user_enrollment_allowed=config.USER_ENROLLMENT_ALLOWED, user_multiple_certificates=config.USER_MULTIPLE_CERTIFICATES, events = config.EVENT_SOURCE_SUBSCRIBE % config.EVENT_SOURCE_TOKEN, @@ -278,8 +287,8 @@ def certidude_app(log_handlers=[]): log_handlers.append(LogHandler(uri)) app.add_route("/api/log/", LogResource(uri)) elif config.LOGGING_BACKEND == "syslog": - from logging.handlers import SyslogHandler - log_handlers.append(SyslogHandler()) + from logging.handlers import SysLogHandler + log_handlers.append(SysLogHandler()) # Browsing syslog via HTTP is obviously not possible out of the box elif config.LOGGING_BACKEND: raise ValueError("Invalid logging.backend = %s" % config.LOGGING_BACKEND) diff --git a/certidude/api/attrib.py b/certidude/api/attrib.py index fde9931..bc34045 100644 --- a/certidude/api/attrib.py +++ b/certidude/api/attrib.py @@ -26,14 +26,15 @@ class AttributeResource(object): Results made available only to lease IP address. """ try: - path, buf, cert, attribs = self.authority.get_attributes(cn, namespace=self.namespace) + path, buf, cert, attribs = self.authority.get_attributes(cn, + namespace=self.namespace, flat=True) except IOError: raise falcon.HTTPNotFound() else: return attribs @csrf_protection - @whitelist_subject # TODO: sign instead + @whitelist_subject def on_post(self, req, resp, cn): namespace = ("user.%s." % self.namespace).encode("ascii") try: @@ -43,7 +44,7 @@ class AttributeResource(object): else: for key in req.params: if not re.match("[a-z0-9_\.]+$", key): - raise falcon.HTTPBadRequest("Invalid key") + raise falcon.HTTPBadRequest("Invalid key %s" % key) valid = set() for key, value in req.params.items(): identifier = ("user.%s.%s" % (self.namespace, key)).encode("ascii") diff --git a/certidude/api/builder.py b/certidude/api/builder.py index 76d58a6..bcf23bc 100644 --- a/certidude/api/builder.py +++ b/certidude/api/builder.py @@ -4,8 +4,9 @@ import falcon import logging import os import subprocess -from certidude import config, const +from certidude import config, const, authority from certidude.auth import login_required, authorize_admin +from certidude.common import cert_to_dn from jinja2 import Template logger = logging.getLogger(__name__) @@ -14,6 +15,8 @@ class ImageBuilderResource(object): @login_required @authorize_admin def on_get(self, req, resp, profile, suggested_filename): + router = [j[0] for j in authority.list_signed( + common_name=config.cp2.get(profile, "router"))][0] model = config.cp2.get(profile, "model") build_script_path = config.cp2.get(profile, "command") overlay_path = config.cp2.get(profile, "overlay") @@ -35,7 +38,10 @@ class ImageBuilderResource(object): stdout=open(log_path, "w"), stderr=subprocess.STDOUT, close_fds=True, shell=False, cwd=os.path.dirname(os.path.realpath(build_script_path)), - env={"PROFILE":model, "PATH":"/usr/sbin:/usr/bin:/sbin:/bin", + env={"PROFILE": model, "PATH":"/usr/sbin:/usr/bin:/sbin:/bin", + "ROUTER": router, + "AUTHORITY_CERTIFICATE_ALGORITHM": authority.public_key.algorithm, + "AUTHORITY_CERTIFICATE_DISTINGUISHED_NAME": cert_to_dn(authority.certificate), "BUILD":build, "OVERLAY":build + "/overlay/"}, startupinfo=None, creationflags=0) proc.communicate() diff --git a/certidude/api/lease.py b/certidude/api/lease.py index 89336a8..0bfe0aa 100644 --- a/certidude/api/lease.py +++ b/certidude/api/lease.py @@ -1,6 +1,7 @@ import falcon import logging import os +import re import xattr from datetime import datetime from certidude import config, push @@ -32,10 +33,9 @@ class LeaseResource(AuthorityHandler): @authorize_server def on_post(self, req, resp): client_common_name = req.get_param("client", required=True) - if "=" in client_common_name: # It's actually DN, resolve it to CN - _, client_common_name = client_common_name.split(" CN=", 1) - if "," in client_common_name: - client_common_name, _ = client_common_name.split(",", 1) + m = re.match("CN=(.+?),", client_common_name) # It's actually DN, resolve it to CN + if m: + client_common_name, = m.groups() path, buf, cert, signed, expires = self.authority.get_signed(client_common_name) # TODO: catch exceptions if req.get_param("serial") and cert.serial_number != req.get_param_as_int("serial"): # OCSP-ish solution for OpenVPN, not exposed for StrongSwan diff --git a/certidude/api/ocsp.py b/certidude/api/ocsp.py index 179fc06..a277139 100644 --- a/certidude/api/ocsp.py +++ b/certidude/api/ocsp.py @@ -53,24 +53,29 @@ class OCSPResource(AuthorityHandler): assert serial > 0, "Serial number correctness check failed" try: - link_target = os.readlink(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % serial)) + link_target = os.readlink(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%040x.pem" % serial)) assert link_target.startswith("../") assert link_target.endswith(".pem") path, buf, cert, signed, expires = self.authority.get_signed(link_target[3:-4]) if serial != cert.serial_number: - logger.error("Certificate store integrity check failed, %s refers to certificate with serial %x" % (link_target, cert.serial_number)) + logger.error("Certificate store integrity check failed, %s refers to certificate with serial %040x", link_target, cert.serial_number) raise EnvironmentError("Integrity check failed") + logger.debug("OCSP responder queried from %s for %s with serial %040x, returned status 'good'", + req.context.get("remote_addr"), cert.subject.native["common_name"], serial) status = ocsp.CertStatus(name='good', value=None) except EnvironmentError: try: - path, buf, cert, signed, expires, revoked = self.authority.get_revoked(serial) + path, buf, cert, signed, expires, revoked, reason = self.authority.get_revoked(serial) + logger.debug("OCSP responder queried from %s for %s with serial %040x, returned status 'revoked' due to %s", + req.context.get("remote_addr"), cert.subject.native["common_name"], serial, reason) status = ocsp.CertStatus( name='revoked', value={ 'revocation_time': revoked, - 'revocation_reason': "key_compromise", + 'revocation_reason': reason, }) except EnvironmentError: + logger.info("OCSP responder queried for unknown serial %040x from %s", serial, req.context.get("remote_addr")) status = ocsp.CertStatus(name="unknown", value=None) responses.append({ diff --git a/certidude/api/request.py b/certidude/api/request.py index 0f3197f..79b968a 100644 --- a/certidude/api/request.py +++ b/certidude/api/request.py @@ -14,7 +14,7 @@ from certidude.profile import SignatureProfile from datetime import datetime from oscrypto import asymmetric from oscrypto.errors import SignatureError -from xattr import getxattr +from xattr import getxattr, setxattr from .utils import AuthorityHandler from .utils.firewall import whitelist_subnets, whitelist_content_types @@ -59,26 +59,39 @@ class RequestListResource(AuthorityHandler): common_name = csr["certification_request_info"]["subject"].native["common_name"] + """ + Determine whether autosign is allowed to overwrite already issued + certificates automatically + """ + + overwrite_allowed = False + for subnet in config.OVERWRITE_SUBNETS: + if req.context.get("remote_addr") in subnet: + overwrite_allowed = True + break + + """ Handle domain computer automatic enrollment """ machine = req.context.get("machine") if machine: - if config.MACHINE_ENROLLMENT_ALLOWED: - if common_name != machine: - raise falcon.HTTPBadRequest( - "Bad request", - "Common name %s differs from Kerberos credential %s!" % (common_name, machine)) + reasons.append("machine enrollment not allowed from %s" % req.context.get("remote_addr")) + for subnet in config.MACHINE_ENROLLMENT_SUBNETS: + if req.context.get("remote_addr") in subnet: + if common_name != machine: + raise falcon.HTTPBadRequest( + "Bad request", + "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") + cert, resp.body = self.authority._sign(csr, body, + profile=config.PROFILES["rw"], overwrite=overwrite_allowed) + logger.info("Automatically enrolled Kerberos authenticated machine %s from %s", + machine, req.context.get("remote_addr")) + return - # Automatic enroll with Kerberos machine cerdentials - resp.set_header("Content-Type", "application/x-pem-file") - cert, resp.body = self.authority._sign(csr, body, - profile=config.PROFILES["rw"], overwrite=True) - logger.info("Automatically enrolled Kerberos authenticated machine %s from %s", - machine, req.context.get("remote_addr")) - return - else: - reasons.append("Machine enrollment not allowed") """ Attempt to renew certificate using currently valid key pair @@ -94,58 +107,61 @@ class RequestListResource(AuthorityHandler): # Same public key if cert_pk == csr_pk: buf = req.get_header("X-SSL-CERT") - # Used mutually authenticated TLS handshake, assume renewal if buf: - header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii")) + # Used mutually authenticated TLS handshake, assume renewal + header, _, der_bytes = pem.unarmor(buf.replace("\t", "\n").replace("\n\n", "\n").encode("ascii")) handshake_cert = x509.Certificate.load(der_bytes) if handshake_cert.native == cert.native: for subnet in config.RENEWAL_SUBNETS: if req.context.get("remote_addr") in subnet: resp.set_header("Content-Type", "application/x-x509-user-cert") + setxattr(path, "user.revocation.reason", "superseded") _, resp.body = self.authority._sign(csr, body, overwrite=True, profile=SignatureProfile.from_cert(cert)) logger.info("Renewing certificate for %s as %s is whitelisted", common_name, req.context.get("remote_addr")) return - - # 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) - return + reasons.append("renewal failed") + else: + # No renewal requested, 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) + return """ Process automatic signing if the IP address is whitelisted, autosigning was requested and certificate can be automatically signed """ + if req.get_param_as_bool("autosign"): - if not self.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 = self.authority._sign(csr, body, profile=config.PROFILES["rw"]) - logger.info("Autosigned %s as %s is whitelisted", common_name, req.context.get("remote_addr")) - return - except EnvironmentError: - logger.info("Autosign for %s from %s failed, signed certificate already exists", - common_name, req.context.get("remote_addr")) - reasons.append("Autosign failed, signed certificate already exists") - break - else: - reasons.append("Autosign failed, IP address not whitelisted") + 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 = self.authority._sign(csr, body, + overwrite=overwrite_allowed, profile=config.PROFILES["rw"]) + logger.info("Autosigned %s as %s is whitelisted", common_name, req.context.get("remote_addr")) + return + except EnvironmentError: + logger.info("Autosign for %s from %s failed, signed certificate already exists", + common_name, req.context.get("remote_addr")) + reasons.append("autosign failed, signed certificate already exists") + break else: - reasons.append("Autosign failed, only client certificates allowed to be signed automatically") + reasons.append("autosign failed, IP address not whitelisted") + else: + reasons.append("autosign not requested") # Attempt to save the request otherwise try: request_path, _, _ = self.authority.store_request(body, address=str(req.context.get("remote_addr"))) except errors.RequestExists: - reasons.append("Same request already uploaded exists") + reasons.append("same request already uploaded exists") # We should still redirect client to long poll URL below except errors.DuplicateCommonNameError: # TODO: Certificate renewal - logger.warning("Rejected signing request with overlapping common name from %s", + logger.warning("rejected signing request with overlapping common name from %s", req.context.get("remote_addr")) raise falcon.HTTPConflict( "CSR with such CN already exists", @@ -154,14 +170,15 @@ class RequestListResource(AuthorityHandler): push.publish("request-submitted", common_name) # Wait the certificate to be signed if waiting is requested - logger.info("Stored signing request %s from %s", common_name, req.context.get("remote_addr")) + logger.info("Stored signing request %s from %s, reasons: %s", common_name, req.context.get("remote_addr"), reasons) + if req.get_param("wait"): # Redirect to nginx pub/sub 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) - logger.debug("Redirecting signing request from %s to %s", req.context.get("remote_addr"), url) + logger.debug("Redirecting signing request from %s to %s, reasons: %s", req.context.get("remote_addr"), url, ", ".join(reasons)) else: # Request was accepted, but not processed resp.status = falcon.HTTP_202 diff --git a/certidude/api/scep.py b/certidude/api/scep.py index dee63ba..d8b465c 100644 --- a/certidude/api/scep.py +++ b/certidude/api/scep.py @@ -1,4 +1,7 @@ +import click +import falcon import hashlib +import logging import os from asn1crypto import cms, algos from asn1crypto.core import SetOf, PrintableString @@ -9,6 +12,8 @@ from oscrypto.errors import SignatureError from .utils import AuthorityHandler from .utils.firewall import whitelist_subnets +logger = logging.getLogger(__name__) + # Monkey patch asn1crypto class SetOfPrintableString(SetOf): @@ -28,21 +33,54 @@ cms.CMSAttribute._oid_specs['sender_nonce'] = cms.SetOfOctetString cms.CMSAttribute._oid_specs['recipient_nonce'] = cms.SetOfOctetString cms.CMSAttribute._oid_specs['trans_id'] = SetOfPrintableString -class SCEPError(Exception): code = 25 # system failure -class SCEPBadAlgo(SCEPError): code = 0 -class SCEPBadMessageCheck(SCEPError): code = 1 -class SCEPBadRequest(SCEPError): code = 2 -class SCEPBadTime(SCEPError): code = 3 -class SCEPBadCertId(SCEPError): code = 4 +class SCEPError(Exception): + code = 25 # system failure + explaination = "General system failure" + +class SCEPBadAlgo(SCEPError): + code = 0 + explaination = "Unsupported algorithm in SCEP request" + +class SCEPBadMessageCheck(SCEPError): + code = 1 + explaination = "Integrity check failed for SCEP request" + +class SCEPBadRequest(SCEPError): + code = 2 + explaination = "Bad request" + +class SCEPBadTime(SCEPError): + code = 3 + explaination = "Bad time" + +class SCEPBadCertId(SCEPError): + code = 4 + explaination = "Certificate authority mismatch" + +class SCEPDigestMismatch(SCEPBadMessageCheck): + explaination = "Digest mismatch" + +class SCEPSignatureMismatch(SCEPBadMessageCheck): + explaination = "Signature mismatch" class SCEPResource(AuthorityHandler): @whitelist_subnets(config.SCEP_SUBNETS) def on_get(self, req, resp): operation = req.get_param("operation", required=True) - if operation.lower() == "getcacert": + if operation == "GetCACert": resp.body = keys.parse_certificate(self.authority.certificate_buf).dump() resp.append_header("Content-Type", "application/x-x509-ca-cert") return + elif operation == "GetCACaps": + # TODO: return renewal flag based on renewal subnets config option + resp.body = "Renewal\nMD5\nSHA-1\nSHA-256\nSHA-512\nDES3\n" + return + elif operation == "PKIOperation": + pass + else: + raise falcon.HTTPBadRequest( + "Bad request", + "Unknown operation %s" % operation) # If we bump into exceptions later encrypted_container = b"" @@ -74,8 +112,14 @@ class SCEPResource(AuthorityHandler): # TODO: compare cert to current one if we are renewing - assert signer["digest_algorithm"]["algorithm"].native == "md5" - assert signer["signature_algorithm"]["algorithm"].native == "rsassa_pkcs1v15" + digest_algorithm = signer["digest_algorithm"]["algorithm"].native + signature_algorithm = signer["signature_algorithm"]["algorithm"].native + + if digest_algorithm not in ("md5", "sha1", "sha256", "sha512"): + raise SCEPBadAlgo() + if signature_algorithm != "rsassa_pkcs1v15": + raise SCEPBadAlgo() + message_digest = None transaction_id = None sender_nonce = None @@ -87,8 +131,13 @@ class SCEPResource(AuthorityHandler): transaction_id, = attr["values"] elif attr["type"].native == "message_digest": message_digest, = attr["values"] - if hashlib.md5(encap_content.native).digest() != message_digest.native: - raise SCEPBadMessageCheck() + if getattr(hashlib, digest_algorithm)(encap_content.native).digest() != message_digest.native: + raise SCEPDigestMismatch() + + if not sender_nonce: + raise SCEPBadRequest() + if not transaction_id: + raise SCEPBadRequest() assert message_digest msg = signer["signed_attrs"].dump(force=True) @@ -102,7 +151,8 @@ class SCEPResource(AuthorityHandler): b"\x31" + msg[1:], # wtf?! "md5") except SignatureError: - raise SCEPBadMessageCheck() + raise SCEPSignatureMismatch() + ############################### ### Decrypt inner container ### @@ -122,14 +172,15 @@ class SCEPResource(AuthorityHandler): if recipient.native["rid"]["serial_number"] != self.authority.certificate.serial_number: raise SCEPBadCertId() - # Since CA private key is not directly readable here, we'll redirect it to signer socket key = asymmetric.rsa_pkcs1v15_decrypt( self.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 = self.authority.store_request(buf, overwrite=True) - cert, buf = self.authority.sign(common_name, overwrite=True) + logger.info("SCEP client from %s requested with %s digest algorithm, %s signature", + req.context["remote_addr"], digest_algorithm, signature_algorithm) + cert, buf = self.authority.sign(common_name, profile=config.PROFILES["gw"], overwrite=True) signed_certificate = asymmetric.load_certificate(buf) content = signed_certificate.asn1.dump() @@ -138,6 +189,7 @@ class SCEPResource(AuthorityHandler): 'type': "fail_info", 'values': ["%d" % e.code] })) + logger.info("Failed to sign SCEP request due to: %s" % e.explaination) else: ################################## @@ -150,7 +202,8 @@ class SCEPResource(AuthorityHandler): 'version': "v1", 'certificates': [signed_certificate.asn1], 'digest_algorithms': [cms.DigestAlgorithm({ - 'algorithm': "md5" + 'algorithm': digest_algorithm + })], 'encap_content_info': { 'content_type': "data", @@ -208,7 +261,7 @@ class SCEPResource(AuthorityHandler): attr_list = [ cms.CMSAttribute({ 'type': "message_digest", - 'values': [hashlib.sha1(encrypted_container).digest()] + 'values': [getattr(hashlib, digest_algorithm)(encrypted_container).digest()] }), cms.CMSAttribute({ 'type': "message_type", @@ -245,12 +298,12 @@ class SCEPResource(AuthorityHandler): 'serial_number': self.authority.certificate.serial_number, }), }), - 'digest_algorithm': algos.DigestAlgorithm({'algorithm': "sha1"}), + 'digest_algorithm': algos.DigestAlgorithm({'algorithm': digest_algorithm}), 'signature_algorithm': algos.SignedDigestAlgorithm({'algorithm': "rsassa_pkcs1v15"}), 'signature': asymmetric.rsa_pkcs1v15_sign( self.authority.private_key, b"\x31" + attrs.dump()[1:], - "sha1" + digest_algorithm ) }) @@ -261,7 +314,7 @@ class SCEPResource(AuthorityHandler): 'version': "v1", 'certificates': [self.authority.certificate], 'digest_algorithms': [cms.DigestAlgorithm({ - 'algorithm': "sha1" + 'algorithm': digest_algorithm })], 'encap_content_info': { 'content_type': "data", diff --git a/certidude/api/signed.py b/certidude/api/signed.py index 65bc5cc..72a43f5 100644 --- a/certidude/api/signed.py +++ b/certidude/api/signed.py @@ -44,7 +44,7 @@ class SignedCertificateDetailResource(AuthorityHandler): resp.body = json.dumps(dict( common_name = cn, signer = signer_username, - serial = "%x" % cert.serial_number, + serial = "%040x" % cert.serial_number, organizational_unit = cert.subject.native.get("organizational_unit_name"), 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", @@ -54,7 +54,8 @@ class SignedCertificateDetailResource(AuthorityHandler): extensions = dict([ (e["extn_id"].native, e["extn_value"].native) for e in cert["tbs_certificate"]["extensions"] - if e["extn_value"] in ("extended_key_usage",)]) + if e["extn_id"].native in ("extended_key_usage",)]) + )) logger.debug("Served certificate %s to %s as application/json", cn, req.context.get("remote_addr")) @@ -69,5 +70,6 @@ class SignedCertificateDetailResource(AuthorityHandler): def on_delete(self, req, resp, cn): logger.info("Revoked certificate %s by %s from %s", cn, req.context.get("user"), req.context.get("remote_addr")) - self.authority.revoke(cn) + self.authority.revoke(cn, + reason=req.get_param("reason", default="key_compromise")) diff --git a/certidude/auth.py b/certidude/auth.py index d4c9a1c..0daecfb 100644 --- a/certidude/auth.py +++ b/certidude/auth.py @@ -30,9 +30,14 @@ def authenticate(optional=False): os.environ["KRB5_KTNAME"] = config.KERBEROS_KEYTAB - server_creds = gssapi.creds.Credentials( - usage='accept', - name=gssapi.names.Name('HTTP/%s'% const.FQDN)) + try: + server_creds = gssapi.creds.Credentials( + usage='accept', + name=gssapi.names.Name('HTTP/%s'% const.FQDN)) + except gssapi.raw.exceptions.BadNameError: + logger.error("Failed initialize HTTP service principal, possibly bad permissions for %s or /etc/krb5.conf" % + config.KERBEROS_KEYTAB) + raise context = gssapi.sec_contexts.SecurityContext(creds=server_creds) @@ -49,13 +54,13 @@ def authenticate(optional=False): raise falcon.HTTPBadRequest("Bad request", "Unsupported authentication mechanism (NTLM?) was offered. Please make sure you've logged into the computer with domain user account. The web interface should not prompt for username or password.") try: - username, domain = str(context.initiator_name).split("@") + username, realm = str(context.initiator_name).split("@") except AttributeError: # TODO: Better exception raise falcon.HTTPForbidden("Failed to determine username, are you trying to log in with correct domain account?") - if domain.lower() != const.DOMAIN.lower(): + if realm != config.KERBEROS_REALM: raise falcon.HTTPForbidden("Forbidden", - "Invalid realm supplied") + "Cross-realm trust not supported") if username.endswith("$") and optional: # Extract machine hostname diff --git a/certidude/authority.py b/certidude/authority.py index 363406c..e66ad63 100644 --- a/certidude/authority.py +++ b/certidude/authority.py @@ -12,6 +12,7 @@ from asn1crypto.csr import CertificationRequest from certbuilder import CertificateBuilder from certidude import config, push, mailer, const from certidude import errors +from certidude.common import cn_to_dn from crlbuilder import CertificateListBuilder, pem_armor_crl from csrbuilder import CSRBuilder, pem_armor_csr from datetime import datetime, timedelta @@ -21,6 +22,16 @@ from xattr import getxattr, listxattr, setxattr random = SystemRandom() +try: + from time import time_ns +except ImportError: + from time import time + def time_ns(): + return int(time() * 10**9) # 64 bits integer, 32 ns bits + +def generate_serial(): + return time_ns() << 56 | random.randint(0, 2**56-1) + # https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/ # https://jamielinux.com/docs/openssl-certificate-authority/ # http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py @@ -38,6 +49,8 @@ with open(config.AUTHORITY_PRIVATE_KEY_PATH, "rb") as fh: private_key = asymmetric.load_private_key(key_der_bytes) def self_enroll(): + assert os.getuid() == 0 and os.getgid() == 0, "Can self-enroll only as root" + from certidude import const common_name = const.FQDN directory = os.path.join("/var/lib/certidude", const.FQDN) @@ -64,13 +77,16 @@ def self_enroll(): builder = CSRBuilder({"common_name": common_name}, self_public_key) request = builder.build(private_key) - with open(os.path.join(directory, "requests", common_name + ".pem"), "wb") as fh: - fh.write(pem_armor_csr(request)) pid = os.fork() if not pid: from certidude import authority from certidude.common import drop_privileges drop_privileges() + assert os.getuid() != 0 and os.getgid() != 0 + path = os.path.join(directory, "requests", common_name + ".pem") + click.echo("Writing request to %s" % path) + with open(path, "wb") as fh: + fh.write(pem_armor_csr(request)) # Write CSR with certidude permissions authority.sign(common_name, skip_push=True, overwrite=True, profile=config.PROFILES["srv"]) sys.exit(0) else: @@ -109,18 +125,23 @@ def get_signed(common_name): def get_revoked(serial): if isinstance(serial, str): serial = int(serial, 16) - path = os.path.join(config.REVOKED_DIR, "%x.pem" % serial) + path = os.path.join(config.REVOKED_DIR, "%040x.pem" % serial) with open(path, "rb") as fh: buf = fh.read() header, _, der_bytes = pem.unarmor(buf) cert = x509.Certificate.load(der_bytes) + try: + reason = getxattr(path, "user.revocation.reason").decode("ascii") + except IOError: # TODO: make sure it's not required + reason = "key_compromise" return path, buf, cert, \ cert["tbs_certificate"]["validity"]["not_before"].native.replace(tzinfo=None), \ cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None), \ - datetime.utcfromtimestamp(os.stat(path).st_ctime) + datetime.utcfromtimestamp(os.stat(path).st_ctime), \ + reason -def get_attributes(cn, namespace=None): +def get_attributes(cn, namespace=None, flat=False): path, buf, cert, signed, expires = get_signed(cn) attribs = dict() for key in listxattr(path): @@ -129,15 +150,18 @@ def get_attributes(cn, namespace=None): continue if namespace and not key.startswith("user.%s." % namespace): continue - value = getxattr(path, key) - current = attribs - if "." in key: - prefix, key = key.rsplit(".", 1) - for component in prefix.split("."): - if component not in current: - current[component] = dict() - current = current[component] - current[key] = value.decode("utf-8") + value = getxattr(path, key).decode("utf-8") + if flat: + attribs[key[len("user.%s." % namespace):]] = value + else: + current = attribs + if "." in key: + prefix, key = key.rsplit(".", 1) + for component in prefix.split("."): + if component not in current: + current[component] = dict() + current = current[component] + current[key] = value return path, buf, cert, attribs @@ -159,7 +183,7 @@ def store_request(buf, overwrite=False, address="", user=""): common_name = csr["certification_request_info"]["subject"].native["common_name"] if not re.match(const.RE_COMMON_NAME, common_name): - raise ValueError("Invalid common name") + raise ValueError("Invalid common name %s" % repr(common_name)) request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem") @@ -190,14 +214,21 @@ def store_request(buf, overwrite=False, address="", user=""): return request_path, csr, common_name -def revoke(common_name): +def revoke(common_name, reason): """ Revoke valid certificate """ signed_path, buf, cert, signed, expires = get_signed(common_name) - revoked_path = os.path.join(config.REVOKED_DIR, "%x.pem" % cert.serial_number) - os.unlink(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial_number)) + if reason not in ("key_compromise", "ca_compromise", "affiliation_changed", + "superseded", "cessation_of_operation", "certificate_hold", + "remove_from_crl", "privilege_withdrawn"): + raise ValueError("Invalid revocation reason %s" % reason) + + setxattr(signed_path, "user.revocation.reason", reason) + revoked_path = os.path.join(config.REVOKED_DIR, "%040x.pem" % cert.serial_number) + + os.unlink(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%040x.pem" % cert.serial_number)) os.rename(signed_path, revoked_path) @@ -212,7 +243,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_number, + serial_hex="%040x" % cert.serial_number, common_name=common_name) return revoked_path @@ -251,28 +282,40 @@ def _list_certificates(directory): server = True yield cert.subject.native["common_name"], path, buf, cert, server -def list_signed(directory=config.SIGNED_DIR): +def list_signed(directory=config.SIGNED_DIR, common_name=None): for filename in os.listdir(directory): - if filename.endswith(".pem"): - common_name = filename[:-4] - path, buf, cert, signed, expires = get_signed(common_name) - yield common_name, path, buf, cert, signed, expires + if not filename.endswith(".pem"): + continue + basename = filename[:-4] + if common_name: + if common_name.startswith("^"): + if not re.match(common_name, basename): + continue + else: + if common_name != basename: + continue + path, buf, cert, signed, expires = get_signed(basename) + yield basename, path, buf, cert, signed, expires def list_revoked(directory=config.REVOKED_DIR): for filename in os.listdir(directory): if filename.endswith(".pem"): common_name = filename[:-4] - path, buf, cert, signed, expired, revoked = get_revoked(common_name) - yield cert.subject.native["common_name"], path, buf, cert, signed, expired, revoked + path, buf, cert, signed, expired, revoked, reason = get_revoked(common_name) + yield cert.subject.native["common_name"], path, buf, cert, signed, expired, revoked, reason + def list_server_names(): return [cn for cn, path, buf, cert, server in list_signed() if server] + def export_crl(pem=True): + # To migrate older installations run following: + # for j in /var/lib/certidude/*/revoked/*.pem; do echo $(attr -s 'revocation.reason' -V key_compromise $j); done builder = CertificateListBuilder( config.AUTHORITY_CRL_URL, certificate, - 1 # TODO: monotonically increasing + generate_serial() ) for filename in os.listdir(config.REVOKED_DIR): @@ -281,12 +324,14 @@ def export_crl(pem=True): serial_number = filename[:-4] # TODO: Assert serial against regex revoked_path = os.path.join(config.REVOKED_DIR, filename) + reason = getxattr(revoked_path, "user.revocation.reason").decode("ascii") # TODO: dedup + # TODO: Skip expired certificates s = os.stat(revoked_path) builder.add_certificate( int(filename[:-4], 16), datetime.utcfromtimestamp(s.st_ctime), - "key_compromise") + reason) certificate_list = builder.build(private_key) if pem: @@ -359,7 +404,7 @@ def _sign(csr, buf, profile, skip_notify=False, skip_push=False, overwrite=False if overwrite: # TODO: is this the best approach? - prev_serial_hex = "%x" % prev.serial_number + prev_serial_hex = "%040x" % 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")] @@ -367,14 +412,10 @@ def _sign(csr, buf, profile, skip_notify=False, skip_push=False, overwrite=False else: raise FileExistsError("Will not overwrite existing certificate") - dn = {u'common_name': common_name } - if profile.ou: - dn["organizational_unit_name"] = profile.ou - - builder = CertificateBuilder(dn, csr_pubkey) - builder.serial_number = random.randint( - 0x1000000000000000000000000000000000000000, - 0x7fffffffffffffffffffffffffffffffffffffff) + builder = CertificateBuilder(cn_to_dn(common_name, const.FQDN, + o=certificate["tbs_certificate"]["subject"].native.get("organization_name"), + ou=profile.ou), csr_pubkey) + builder.serial_number = generate_serial() now = datetime.utcnow() builder.begin_date = now - timedelta(minutes=5) @@ -392,10 +433,10 @@ def _sign(csr, buf, profile, skip_notify=False, skip_push=False, overwrite=False os.rename(cert_path + ".part", cert_path) attachments.append((end_entity_cert_buf, "application/x-pem-file", common_name + ".crt")) - cert_serial_hex = "%x" % end_entity_cert.serial_number + cert_serial_hex = "%040x" % end_entity_cert.serial_number # Create symlink - link_name = os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % end_entity_cert.serial_number) + link_name = os.path.join(config.SIGNED_BY_SERIAL_DIR, "%040x.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) @@ -422,6 +463,10 @@ def _sign(csr, buf, profile, skip_notify=False, skip_push=False, overwrite=False click.echo("Publishing certificate at %s ..." % url) 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) + if renew: + # TODO: certificate-renewed event + push.publish("certificate-revoked", common_name) + push.publish("request-signed", common_name) + else: + 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 87c023f..e72e838 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -12,12 +12,13 @@ import subprocess import sys from asn1crypto import pem, x509 from asn1crypto.csr import CertificationRequest +from asn1crypto.crl import CertificateList from base64 import b64encode from certbuilder import CertificateBuilder, pem_armor_certificate from certidude import const from csrbuilder import CSRBuilder, pem_armor_csr from configparser import ConfigParser, NoOptionError -from certidude.common import apt, rpm, drop_privileges, selinux_fixup +from certidude.common import apt, rpm, drop_privileges, selinux_fixup, cn_to_dn from datetime import datetime, timedelta from glob import glob from ipaddress import ip_network @@ -49,7 +50,7 @@ def setup_client(prefix="client_", dh=False): def wrapped(**arguments): common_name = arguments.get("common_name") authority = arguments.get("authority") - b = os.path.join(const.STORAGE_PATH, authority) + b = os.path.join("/etc/certidude/authority", authority) if dh: path = os.path.join(const.STORAGE_PATH, "dh.pem") if not os.path.exists(path): @@ -94,6 +95,8 @@ def setup_client(prefix="client_", dh=False): @click.option("-s", "--skip-self", default=False, is_flag=True, help="Skip self enroll") @click.option("-nw", "--no-wait", default=False, is_flag=True, help="Return immideately if server doesn't autosign") def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): + assert os.getuid() == 0 and os.getgid() == 0, "Can enroll only as root" + if not skip_self and os.path.exists(const.SERVER_CONFIG_PATH): click.echo("Self-enrolling authority's web interface certificate") from certidude import authority @@ -182,7 +185,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): try: authority_path = clients.get(authority_name, "authority path") except NoOptionError: - authority_path = "/var/lib/certidude/%s/ca_cert.pem" % authority_name + authority_path = "/etc/certidude/authority/%s/ca_cert.pem" % authority_name finally: if os.path.exists(authority_path): click.echo("Found authority certificate in: %s" % authority_path) @@ -233,7 +236,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): # pip # Firefox (?) on Debian, Ubuntu - if os.path.exists("/usr/bin/update-ca-certificates"): + if os.path.exists("/usr/bin/update-ca-certificates") or os.path.exists("/usr/sbin/update-ca-certificates"): link_path = "/usr/local/share/ca-certificates/%s" % authority_name if not os.path.lexists(link_path): os.symlink(authority_path, link_path) @@ -257,11 +260,13 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): r = requests.get(revoked_url, headers={'accept': 'application/x-pem-file'}) if r.status_code == 200: - revocations = crl.CertificateList.load(pem.unarmor(r.content)) + header, _, crl_der_bytes = pem.unarmor(r.content) + revocations = CertificateList.load(crl_der_bytes) # TODO: check signature, parse reasons, remove keys if revoked revocations_partial = revocations_path + ".part" with open(revocations_partial, 'wb') as f: f.write(r.content) + os.rename(revocations_partial, revocations_path) elif r.status_code == 404: click.echo("CRL disabled, server said 404") else: @@ -293,8 +298,8 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): key_path = clients.get(authority_name, "key path") request_path = clients.get(authority_name, "request path") except NoOptionError: - key_path = "/var/lib/certidude/%s/client_key.pem" % authority_name - request_path = "/var/lib/certidude/%s/client_csr.pem" % authority_name + key_path = "/etc/certidude/authority/%s/host_key.pem" % authority_name + request_path = "/etc/certidude/authority/%s/host_csr.pem" % authority_name if os.path.exists(request_path): with open(request_path, "rb") as fh: @@ -334,7 +339,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): try: certificate_path = clients.get(authority_name, "certificate path") except NoOptionError: - certificate_path = "/var/lib/certidude/%s/client_cert.pem" % authority_name + certificate_path = "/etc/certidude/authority/%s/host_cert.pem" % authority_name try: renewal_overlap = clients.getint(authority_name, "renewal overlap") @@ -352,10 +357,15 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): except EnvironmentError: # Certificate missing, can't renew pass + try: + autosign = clients.getboolean(authority_name, "autosign") + except NoOptionError: + autosign = True + if not os.path.exists(certificate_path) or renew: # Set up URL-s request_params = set() - request_params.add("autosign=true") + request_params.add("autosign=%s" % ("yes" if autosign else "no")) if not no_wait: request_params.add("wait=forever") @@ -371,6 +381,7 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): if renew: # Do mutually authenticated TLS handshake request_url = "https://%s:8443/api/request/" % authority_name kwargs["cert"] = certificate_path, key_path + click.echo("Renewing using current keypair at %s %s" % kwargs["cert"]) else: # If machine is joined to domain attempt to present machine credentials for authentication if kerberos: @@ -416,6 +427,8 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): elif submission.status_code == requests.codes.gone: # Should the client retry or disable request submission? raise ValueError("Server refused to sign the request") # TODO: Raise proper exception + elif submission.status_code == requests.codes.bad_request: + raise ValueError("Server said following, likely current certificate expired/revoked? %s" % submission.text) else: submission.raise_for_status() @@ -964,12 +977,9 @@ def certidude_setup_openvpn_networkmanager(authority, remote, common_name, **pat help="nginx site config for serving Certidude, /etc/nginx/sites-available/certidude by default") @click.option("--common-name", "-cn", default=const.FQDN, help="Common name of the server, %s by default" % const.FQDN) @click.option("--title", "-t", default="Certidude at %s" % const.FQDN, help="Common name of the certificate authority, 'Certidude at %s' by default" % const.FQDN) -@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, 20 years by default") @click.option("--organization", "-o", default=None, help="Company or organization name") -@click.option("--organizational-unit", "-o", default=None) +@click.option("--organizational-unit", "-ou", default="Certificate Authority") @click.option("--push-server", 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") @@ -977,7 +987,8 @@ def certidude_setup_openvpn_networkmanager(authority, remote, common_name, **pat @click.option("--skip-packages", is_flag=True, help="Don't attempt to install apt/pip/npm packages") @click.option("--elliptic-curve", "-e", is_flag=True, help="Generate EC instead of RSA keypair") @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, title, skip_packages, elliptic_curve): +def certidude_setup_authority(username, kerberos_keytab, nginx_config, organization, organizational_unit, common_name, directory, authority_lifetime, push_server, outbox, server_flags, title, skip_packages, elliptic_curve): + assert subprocess.check_output(["/usr/bin/lsb_release", "-cs"]) == b"xenial\n", "Only Ubuntu 16.04 supported at the moment" assert os.getuid() == 0 and os.getgid() == 0, "Authority can be set up only by root" import pwd @@ -992,7 +1003,8 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, cython3 python3-dev python3-mimeparse \ python3-markdown python3-pyxattr python3-jinja2 python3-cffi \ software-properties-common libsasl2-modules-gssapi-mit npm nodejs \ - libkrb5-dev libldap2-dev libsasl2-dev gawk libncurses5-dev rsync attr") + libkrb5-dev libldap2-dev libsasl2-dev gawk libncurses5-dev \ + rsync attr wget unzip") os.system("pip3 install -q --upgrade gssapi falcon humanize ipaddress simplepam") os.system("pip3 install -q --pre --upgrade python-ldap") @@ -1096,9 +1108,18 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, else: click.echo("Not systemd based OS, don't know how to set up initscripts") + # Set umask to 0022 + os.umask(0o022) assert os.getuid() == 0 and os.getgid() == 0 + bootstrap_pid = os.fork() if not bootstrap_pid: + + # Create what's usually /var/lib/certidude + if not os.path.exists(directory): + os.makedirs(directory) + assert os.stat(directory).st_mode == 0o40755 + # Create bundle directories bundle_js = os.path.join(assets_dir, "js", "bundle.js") bundle_css = os.path.join(assets_dir, "css", "bundle.css") @@ -1108,6 +1129,10 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, click.echo("Creating directory %s" % subdir) os.makedirs(subdir) + # Copy fonts + click.echo("Copying fonts...") + os.system("rsync -avq /usr/local/lib/node_modules/font-awesome/fonts/ %s/fonts/" % assets_dir) + # Install JavaScript pacakges if skip_packages: click.echo("Not attempting to install packages from NPM as requested...") @@ -1140,10 +1165,6 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, os.rename(bundle_css + ".part", bundle_css) os.rename(bundle_js + ".part", bundle_js) - # Copy fonts - click.echo("Copying fonts...") - os.system("rsync -avq /usr/local/lib/node_modules/font-awesome/fonts/ %s/fonts/" % assets_dir) - assert os.getuid() == 0 and os.getgid() == 0 _, _, uid, gid, gecos, root, shell = pwd.getpwnam("certidude") os.setgid(gid) @@ -1152,10 +1173,11 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, if not os.path.exists(const.CONFIG_DIR): click.echo("Creating %s" % const.CONFIG_DIR) os.makedirs(const.CONFIG_DIR) + + os.umask(0o137) # 640 if os.path.exists(const.SERVER_CONFIG_PATH): click.echo("Configuration file %s already exists, remove to regenerate" % const.SERVER_CONFIG_PATH) else: - os.umask(0o137) push_token = "".join([random.choice(string.ascii_letters + string.digits) for j in range(0,32)]) with open(const.SERVER_CONFIG_PATH, "w") as fh: fh.write(env.get_template("server/server.conf").render(vars())) @@ -1169,7 +1191,7 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, fh.write(env.get_template("server/builder.conf").render(vars())) click.echo("File %s created" % const.BUILDER_CONFIG_PATH) - # Create image builder config + # Create signature profile config if os.path.exists(const.PROFILE_CONFIG_PATH): click.echo("Signature profile config %s already exists, remove to regenerate" % const.PROFILE_CONFIG_PATH) else: @@ -1177,10 +1199,9 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, fh.write(env.get_template("server/profile.conf").render(vars())) click.echo("File %s created" % const.PROFILE_CONFIG_PATH) - # Create directory with 755 permissions - os.umask(0o022) - if not os.path.exists(directory): - os.makedirs(directory) + if not os.path.exists("/var/lib/certidude/builder"): + click.echo("Creating %s" % "/var/lib/certidude/builder") + os.makedirs("/var/lib/certidude/builder") # Create subdirectories with 770 permissions os.umask(0o007) @@ -1191,10 +1212,11 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, os.mkdir(path) else: click.echo("Directory already exists %s" % path) + assert os.stat(path).st_mode == 0o40770 # Create SQLite database file with correct permissions + os.umask(0o117) if not os.path.exists(sqlite_path): - os.umask(0o117) with open(sqlite_path, "wb") as fh: pass @@ -1207,16 +1229,10 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, click.echo("Generating %d-bit RSA key for CA ..." % const.KEY_SIZE) public_key, private_key = asymmetric.generate_pair("rsa", bit_size=const.KEY_SIZE) - names = ( - ("country_name", country), - ("state_or_province_name", state), - ("locality_name", locality), - ("organization_name", organization), - ("common_name", title) - ) - + # https://technet.microsoft.com/en-us/library/aa998840(v=exchg.141).aspx builder = CertificateBuilder( - dict([(k,v) for (k,v) in names if v]), + cn_to_dn("Certidude at %s" % common_name, common_name, + o=organization, ou=organizational_unit), public_key ) builder.self_signed = True @@ -1239,7 +1255,13 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, os.umask(0o177) with open(ca_key, 'wb') as f: f.write(asymmetric.dump_private_key(private_key, None)) + sys.exit(0) # stop this fork here + + assert os.stat(sqlite_path).st_mode == 0o100640 + assert os.stat(ca_cert).st_mode == 0o100640 + assert os.stat(ca_key).st_mode == 0o100600 + assert os.stat("/etc/nginx/sites-available/certidude.conf").st_mode == 0o100640 else: os.waitpid(bootstrap_pid, 0) from certidude import authority @@ -1322,7 +1344,7 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign click.echo("y " + path) continue click.echo() - click.echo(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial_number, fg="white")) + click.echo(click.style(common_name, fg="blue") + " " + click.style("%040x" % cert.serial_number, fg="white")) click.echo("="*(len(common_name)+60)) if signed < NOW and NOW < expires: @@ -1338,15 +1360,15 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign click.echo(" - %s: %s" % (ext["extn_id"].native, repr(ext["extn_value"].native))) if show_revoked: - for common_name, path, buf, cert, signed, expires, revoked in authority.list_revoked(): + for common_name, path, buf, cert, signed, expires, revoked, reason in authority.list_revoked(): if not verbose: click.echo("r " + path) continue click.echo() - click.echo(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial_number, fg="white")) + click.echo(click.style(common_name, fg="blue") + " " + click.style("%040x" % cert.serial_number, fg="white")) click.echo("="*(len(common_name)+60)) - click.echo("Status: " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-revoked), click.style(", %s" % revoked, fg="white"))) + click.echo("Status: " + click.style("revoked", fg="red") + " due to " + reason + " %s%s" % (naturaltime(NOW-revoked), click.style(", %s" % revoked, fg="white"))) click.echo("openssl x509 -in %s -text -noout" % path) dump_common(common_name, path, cert) for ext in cert["tbs_certificate"]["extensions"]: @@ -1358,17 +1380,18 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign @click.option("--profile", "-p", default="rw", help="Profile") @click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN") def certidude_sign(common_name, overwrite, profile): - from certidude import authority + from certidude import authority, config drop_privileges() cert = authority.sign(common_name, overwrite=overwrite, profile=config.PROFILES[profile]) @click.command("revoke", help="Revoke certificate") +@click.option("--reason", "-r", default="key_compromise", help="Revocation reason, one of: key_compromise affiliation_changed superseded cessation_of_operation privilege_withdrawn") @click.argument("common_name") -def certidude_revoke(common_name): +def certidude_revoke(common_name, reason): from certidude import authority drop_privileges() - authority.revoke(common_name) + authority.revoke(common_name, reason) @click.command("expire", help="Move expired certificates") @@ -1377,13 +1400,13 @@ def certidude_expire(): threshold = datetime.utcnow() - timedelta(minutes=5) # Kerberos tolerance for common_name, path, buf, cert, signed, expires in authority.list_signed(): if expires < threshold: - expired_path = os.path.join(config.EXPIRED_DIR, "%x.pem" % cert.serial_number) + expired_path = os.path.join(config.EXPIRED_DIR, "%040x.pem" % cert.serial_number) click.echo("Moving %s to %s" % (path, expired_path)) os.rename(path, expired_path) - os.remove(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial_number)) - for common_name, path, buf, cert, signed, expires, revoked in authority.list_revoked(): + os.remove(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%040x.pem" % cert.serial_number)) + for common_name, path, buf, cert, signed, expires, revoked, reason in authority.list_revoked(): if expires < threshold: - expired_path = os.path.join(config.EXPIRED_DIR, "%x.pem" % cert.serial_number) + expired_path = os.path.join(config.EXPIRED_DIR, "%040x.pem" % cert.serial_number) click.echo("Moving %s to %s" % (path, expired_path)) os.rename(path, expired_path) # TODO: Send e-mail @@ -1412,7 +1435,7 @@ def certidude_serve(port, listen, fork): # Rebuild reverse mapping for cn, path, buf, cert, signed, expires in authority.list_signed(): - by_serial = os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial_number) + by_serial = os.path.join(config.SIGNED_BY_SERIAL_DIR, "%040x.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) @@ -1423,14 +1446,6 @@ def certidude_serve(port, listen, fork): os.makedirs(const.RUN_DIR) os.chmod(const.RUN_DIR, 0o755) - # TODO: umask! - - - from logging.handlers import RotatingFileHandler - rh = RotatingFileHandler("/var/log/certidude.log", maxBytes=1048576*5, backupCount=5) - rh.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) - log_handlers.append(rh) - click.echo("Users subnets: %s" % ", ".join([str(j) for j in config.USER_SUBNETS])) click.echo("Administrative subnets: %s" % @@ -1440,10 +1455,6 @@ def certidude_serve(port, listen, fork): click.echo("Request submissions allowed from following subnets: %s" % ", ".join([str(j) for j in config.REQUEST_SUBNETS])) - logging.basicConfig( - filename=const.SERVER_LOG_PATH, - level=logging.DEBUG) - click.echo("Serving API at %s:%d" % (listen, port)) from wsgiref.simple_server import make_server, WSGIServer from certidude.api import certidude_app @@ -1474,7 +1485,6 @@ def certidude_serve(port, listen, fork): for handler in log_handlers: j.addHandler(handler) - if not fork or not os.fork(): pid = os.getpid() with open(const.SERVER_PID_PATH, "w") as pidfile: diff --git a/certidude/common.py b/certidude/common.py index bacb955..01b1d10 100644 --- a/certidude/common.py +++ b/certidude/common.py @@ -3,6 +3,65 @@ import os import click import subprocess +MAPPING = dict( + common_name="CN", + organizational_unit_name="OU", + organization_name="O", + domain_component="DC" +) + +def cert_to_dn(cert): + d = [] + for key, value in cert["tbs_certificate"]["subject"].native.items(): + if not isinstance(value, list): + value = [value] + for comp in value: + d.append("%s=%s" % (MAPPING[key], comp)) + return ", ".join(d) + +def cn_to_dn(common_name, namespace, o=None, ou=None): + from asn1crypto.x509 import Name, RelativeDistinguishedName, NameType, DirectoryString, RDNSequence, NameTypeAndValue, UTF8String, DNSName + + rdns = [] + rdns.append(RelativeDistinguishedName([ + NameTypeAndValue({ + 'type': NameType.map("common_name"), + 'value': DirectoryString( + name="utf8_string", + value=UTF8String(common_name)) + }) + ])) + + if ou: + rdns.append(RelativeDistinguishedName([ + NameTypeAndValue({ + 'type': NameType.map("organizational_unit_name"), + 'value': DirectoryString( + name="utf8_string", + value=UTF8String(ou)) + }) + ])) + + if o: + rdns.append(RelativeDistinguishedName([ + NameTypeAndValue({ + 'type': NameType.map("organization_name"), + 'value': DirectoryString( + name="utf8_string", + value=UTF8String(o)) + }) + ])) + + for dc in namespace.split("."): + rdns.append(RelativeDistinguishedName([ + NameTypeAndValue({ + 'type': NameType.map("domain_component"), + 'value': DNSName(value=dc) + }) + ])) + + return Name(name='', value=RDNSequence(rdns)) + def selinux_fixup(path): """ Fix OpenVPN credential store security context on Fedora diff --git a/certidude/config.py b/certidude/config.py index 25a113e..6e26130 100644 --- a/certidude/config.py +++ b/certidude/config.py @@ -18,6 +18,7 @@ ACCOUNTS_BACKEND = cp.get("accounts", "backend") # posix, ldap MAIL_SUFFIX = cp.get("accounts", "mail suffix") KERBEROS_KEYTAB = cp.get("authentication", "kerberos keytab") +KERBEROS_REALM = cp.get("authentication", "kerberos realm") LDAP_AUTHENTICATION_URI = cp.get("authentication", "ldap uri") LDAP_GSSAPI_CRED_CACHE = cp.get("accounts", "ldap gssapi credential cache") LDAP_ACCOUNTS_URI = cp.get("accounts", "ldap uri") @@ -39,6 +40,10 @@ CRL_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "crl subnets").split(" ") if j]) RENEWAL_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "renewal subnets").split(" ") if j]) +OVERWRITE_SUBNETS = set([ipaddress.ip_network(j) for j in + cp.get("authorization", "overwrite subnets").split(" ") if j]) +MACHINE_ENROLLMENT_SUBNETS = set([ipaddress.ip_network(j) for j in + cp.get("authorization", "machine enrollment subnets").split(" ") if j]) AUTHORITY_DIR = "/var/lib/certidude" AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path") @@ -54,9 +59,6 @@ MAILER_ADDRESS = cp.get("mailer", "address") BOOTSTRAP_TEMPLATE = cp.get("bootstrap", "services template") -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 enrollment")] @@ -117,3 +119,6 @@ cp2.readfp(open(const.BUILDER_CONFIG_PATH, "r")) IMAGE_BUILDER_PROFILES = [(j, cp2.get(j, "title"), cp2.get(j, "rename")) for j in cp2.sections()] TOKEN_OVERWRITE_PERMITTED=True + +SERVICE_PROTOCOLS = set([j.lower() for j in cp.get("service", "protocols").split(" ") if j]) +SERVICE_ROUTERS = cp.get("service", "routers") diff --git a/certidude/const.py b/certidude/const.py index 1ee95df..9a4c435 100644 --- a/certidude/const.py +++ b/certidude/const.py @@ -6,9 +6,9 @@ import sys KEY_SIZE = 1024 if os.getenv("TRAVIS") else 4096 CURVE_NAME = "secp384r1" -RE_FQDN = "^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)+([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])?$" -RE_HOSTNAME = "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$" -RE_COMMON_NAME = "^[A-Za-z0-9\-\.\@]+$" +RE_FQDN = "^(([a-z0-9]|[a-z0-9][a-z0-9\-_]*[a-z0-9])\.)+([a-z0-9]|[a-z0-9][a-z0-9\-_]*[a-z0-9])?$" +RE_HOSTNAME = "^[a-z0-9]([a-z0-9\-_]{0,61}[a-z0-9])?$" +RE_COMMON_NAME = "^[A-Za-z0-9\-\.\_@]+$" RUN_DIR = "/run/certidude" CONFIG_DIR = "/etc/certidude" @@ -25,6 +25,8 @@ try: FQDN = socket.getaddrinfo(socket.gethostname(), 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME)[0][3] except socket.gaierror: FQDN = socket.gethostname() +if hasattr(FQDN, "decode"): # Keep client backwards compatible with Python 2.x + FQDN = FQDN.decode("ascii") try: HOSTNAME, DOMAIN = FQDN.split(".", 1) diff --git a/certidude/static/index.html b/certidude/static/index.html index 8826184..726cabb 100644 --- a/certidude/static/index.html +++ b/certidude/static/index.html @@ -56,8 +56,8 @@ diff --git a/certidude/static/js/certidude.js b/certidude/static/js/certidude.js index fb07465..665dad8 100644 --- a/certidude/static/js/certidude.js +++ b/certidude/static/js/certidude.js @@ -305,7 +305,8 @@ function loadAuthority() { /** * Render authority views **/ - $("#view").html(env.render('views/authority.html', { session: session, window: window })); + $("#view").html(env.render('views/authority.html', { session: session, window: window, + authority_name: window.location.hostname })); $("time").timeago(); if (session.authority) { $("#log input").each(function(i, e) { @@ -462,12 +463,8 @@ function datetimeFilter(s) { } function serialFilter(s) { - return s.substring(0,8) + " " + - s.substring(8,12) + " " + - s.substring(12,16) + " " + - s.substring(16,28) + " " + - s.substring(28,32) + " " + - s.substring(32); + return s.substring(0,s.length-14) + " " + + s.substring(s.length-14); } $(document).ready(function() { diff --git a/certidude/static/views/authority.html b/certidude/static/views/authority.html index a967617..ccbb01e 100644 --- a/certidude/static/views/authority.html +++ b/certidude/static/views/authority.html @@ -7,26 +7,25 @@
@@ -88,8 +88,10 @@ openssl ocsp -issuer session.pem -CAfile session.pem \ {% endif %}

To fetch script:

-
cd /var/lib/certidude/{{ window.location.hostname }}/
-curl --cert client_cert.pem https://{{ window.location.hostname }}:8443/api/signed/{{ certificate.common_name }}/script/
+
curl https://{{ window.location.hostname }}:8443/api/signed/{{ certificate.common_name }}/script/ \
+    --cacert /etc/certidude/authority/{{ window.location.hostname }}/ca_cert.pem \
+    --key /etc/certidude/authority/{{ window.location.hostname }}/host_key.pem \
+    --cert /etc/certidude/authority/{{ window.location.hostname }}/host_cert.pem
@@ -110,6 +112,9 @@ curl --cert client_cert.pem https://{{ window.location.hostname }}:8443/api/sign --> + {% if certificate.extensions.extended_key_usage %} + + {% endif %}
SHA1{{ certificate.sha1sum }}
SHA256{{ certificate.sha256sum }}
Extended key usage{{ certificate.extensions.extended_key_usage | join(", ") }}
diff --git a/certidude/templates/script/default.sh b/certidude/templates/script/default.sh index 17bb870..c4eb671 100644 --- a/certidude/templates/script/default.sh +++ b/certidude/templates/script/default.sh @@ -12,12 +12,28 @@ # No tags {% endif %} -# Submit some stats to CA -curl http://{{ authority_name }}/api/signed/{{ common_name }}/attr -X POST -d "\ -dmi.product_name=$(cat /sys/class/dmi/id/product_name)&\ -dmi.product_serial=$(cat /sys/class/dmi/id/product_serial)&\ -kernel=$(uname -sr)&\ -dist=$(lsb_release -si) $(lsb_release -sr)&\ +ARGS="kernel=$(uname -sr)&\ cpu=$(cat /proc/cpuinfo | grep '^model name' | head -n1 | cut -d ":" -f2 | xargs)&\ -mem=$(dmidecode -t 17 | grep Size | cut -d ":" -f 2 | cut -d " " -f 2 | paste -sd+ | bc) MB&\ -$(for j in /sys/class/net/[we]*; do echo -en if.$(basename $j).ether=$(cat $j/address)\&; done)" +$(for j in /sys/class/net/[we]*[a-z][0-9]; do echo -en if.$(basename $j).ether=$(cat $j/address)\&; done)" + +if [ -e /etc/openwrt_release ]; then + . /etc/openwrt_release + ARGS="$ARGS&dist=$DISTRIB_ID $DISTRIB_RELEASE" +else + ARGS="$ARGS&dist=$(lsb_release -si) $(lsb_release -sr)" +fi + +if [ -e /sys/class/dmi ]; then + ARGS="$ARGS&dmi.product_name=$(cat /sys/class/dmi/id/product_name)&dmi.product_serial=$(cat /sys/class/dmi/id/product_serial)" + ARGS="$ARGS&&mem=$(dmidecode -t 17 | grep Size | cut -d ":" -f 2 | cut -d " " -f 2 | paste -sd+ | bc) MB" +else + ARGS="$ARGS&dmi.product_name=$(cat /proc/cpuinfo | grep '^machine' | head -n 1 | cut -d ":" -f 2 | xargs)" + ARGS="$ARGS&mem=$(echo $(cat /proc/meminfo | grep MemTotal | cut -d ":" -f 2 | xargs | cut -d " " -f 1)/1000+1 | bc) MB" +fi + +# Submit some stats to CA +curl https://{{ authority_name }}:8443/api/signed/{{ common_name }}/attr \ +--cacert /etc/certidude/authority/{{ authority_name }}/ca_cert.pem \ +--key /etc/certidude/authority/{{ authority_name }}/host_key.pem \ +--cert /etc/certidude/authority/{{ authority_name }}/host_cert.pem \ +-X POST \-d "$ARGS" diff --git a/certidude/templates/server/builder.conf b/certidude/templates/server/builder.conf index 58008b6..c691dae 100644 --- a/certidude/templates/server/builder.conf +++ b/certidude/templates/server/builder.conf @@ -1,15 +1,20 @@ +[DEFAULT] +# Path to filesystem overlay used +overlay = {{ doc_path }}/overlay + +# Hostname or regex to match the IPSec gateway included in the image +router = ^router\d?\. + +# Site specific script to be copied to /etc/uci-defaults/99-site-script +# use it to include SSH keys, set passwords, etc +script = + [tpl-archer-c7] # Title shown in the UI title = TP-Link Archer C7 (Access Point) # Script to build the image, copy file to /etc/certidude/ and make modifications as necessary -command = {{ doc_path }}/build-ap.sh - -# Path to filesystem overlay, used -overlay = {{ doc_path }}/overlay - -# Site specific script to be copied to /etc/uci-defaults/99-site-script -script = +command = {{ doc_path }}/builder/ap.sh # Device/model/profile selection model = archer-c7-v2 @@ -22,10 +27,21 @@ rename = ArcherC7v2_tp_recovery.bin [cf-e380ac] title = Comfast E380AC (Access Point) -command = {{ doc_path }}/build-ap.sh -overlay = {{ doc_path }}/overlay -script = +command = {{ doc_path }}/builder/ap.sh model = cf-e380ac-v2 filename = cf-e380ac-v2-squashfs-factory.bin rename = firmware_auto.bin +[ar150-mfp] +title = GL.iNet GL-AR150 (MFP) +command = {{ doc_path }}/builder/mfp.sh +model = gl-ar150 +filename = ar71xx-generic-gl-ar150-squashfs-sysupgrade.bin +rename = mfp-gl-ar150-squashfs-sysupgrade.bin + +[ar150-cam] +title = GL.iNet GL-AR150 (IP Camera) +command = {{ doc_path }}/builder/ipcam.sh +model = gl-ar150 +filename = ar71xx-generic-gl-ar150-squashfs-sysupgrade.bin +rename = cam-gl-ar150-squashfs-sysupgrade.bin diff --git a/certidude/templates/server/nginx.conf b/certidude/templates/server/nginx.conf index d0f7c7f..8884949 100644 --- a/certidude/templates/server/nginx.conf +++ b/certidude/templates/server/nginx.conf @@ -138,8 +138,9 @@ server { server_name {{ common_name }}; listen 8443 ssl http2; - # Require client authentication with certificate - ssl_verify_client on; + # Allow client authentication with certificate, + # backend must still check if certificate was used for TLS handshake + ssl_verify_client optional; ssl_client_certificate /var/lib/certidude/{{ common_name }}/ca_cert.pem; # Proxy pass to backend diff --git a/certidude/templates/server/profile.conf b/certidude/templates/server/profile.conf index 059835a..c65da4c 100644 --- a/certidude/templates/server/profile.conf +++ b/certidude/templates/server/profile.conf @@ -22,31 +22,37 @@ extended key usage = client_auth [srv] title = Server -;ou = Server +ou = Server common name = RE_FQDN -lifetime = 365 -extended key usage = server_auth +lifetime = 120 +extended key usage = server_auth client_auth [gw] title = Gateway ou = Gateway common name = RE_FQDN renewable = true -lifetime = 30 +lifetime = 120 extended key usage = server_auth 1.3.6.1.5.5.8.2.2 client_auth [ap] title = Access Point ou = Access Point common name = RE_HOSTNAME -lifetime = 1825 +lifetime = 120 extended key usage = client_auth [mfp] title = Printers -ou = Printers +ou = MFP common name = ^mfp\- -lifetime = 30 +lifetime = 120 extended key usage = client_auth +[cam] +title = Camera +ou = IP Camera +common name = ^cam\- +lifetime = 120 +extended key usage = client_auth diff --git a/certidude/templates/server/server.conf b/certidude/templates/server/server.conf index edf354f..736e7a9 100644 --- a/certidude/templates/server/server.conf +++ b/certidude/templates/server/server.conf @@ -4,13 +4,31 @@ # sshd PAM service. In case of 'kerberos' SPNEGO is used to authenticate # user against eg. Active Directory or Samba4. +{% if realm %} +;backends = pam +backends = kerberos +{% else %} backends = pam ;backends = kerberos +{% endif %} ;backends = ldap -;backends = kerberos ldap -;backends = kerberos pam -ldap uri = ldaps://dc.example.lan + kerberos keytab = FILE:{{ kerberos_keytab }} +{% if realm %} +# Kerberos realm derived from /etc/samba/smb.conf +kerberos realm = {{ realm }} +{% else %} +# Kerberos realm +kerberos realm = EXAMPLE.LAN +{% endif %} + +{% if domain %} +# LDAP URI derived from /etc/samba/smb.conf +ldap uri = ldap://dc1.{{ domain }} +{% else %} +# LDAP URI +ldap uri = ldaps://dc1.example.lan +{% endif %} [accounts] # The accounts backend specifies how the user's given name, surname and e-mail @@ -20,27 +38,63 @@ kerberos keytab = FILE:{{ kerberos_keytab }} # If certidude setup authority was performed correctly the credential cache should be # updated automatically by /etc/cron.hourly/certidude +{% if not realm %} backend = posix +{% else %} +;backend = posix +{% endif %} mail suffix = example.lan +{% if realm %} +backend = ldap +{% else %} ;backend = ldap +{% endif %} ldap gssapi credential cache = /run/certidude/krb5cc -ldap uri = ldap://dc.example.lan -ldap base = {% if base %}{{ base }}{% else %}dc=example,dc=lan{% endif %} + +{% if domain %} +# LDAP URI derived from /etc/samba/smb.conf +ldap uri = ldap://dc1.{{ domain }} +{% else %} +# LDAP URI +ldap uri = ldaps://dc1.example.lan +{% endif %} + +{% if base %} +# LDAP base derived from /etc/samba/smb.conf +ldap base = {{ base }} +{% else %} +ldap base = dc=example,dc=lan +{% endif %} [authorization] # The authorization backend specifies how the users are authorized. # In case of 'posix' simply group membership is asserted, # in case of 'ldap' search filter with username as placeholder is applied. +{% if realm %} +;backend = posix +{% else %} backend = posix +{% endif %} posix user group = users posix admin group = sudo +{% if realm %} +backend = ldap +{% else %} ;backend = ldap +{% endif %} ldap computer filter = (&(objectclass=user)(objectclass=computer)(samaccountname=%s)) ldap user filter = (&(objectclass=user)(objectcategory=person)(samaccountname=%s)) -ldap admin filter = (&(memberOf=cn=Domain Admins,cn=Users,{% if base %}{{ base }}{% else %}dc=example,dc=lan{% endif %})(samaccountname=%s)) +{% if base %} +# LDAP user filter for administrative accounts, derived from /etc/samba/smb.conf +ldap admin filter = (&(memberOf=cn=Domain Admins,cn=Users,{{ base }})(samaccountname=%s)) +{% else %} +# LDAP user filter for administrative accounts +ldap admin filter = (&(memberOf=cn=Domain Admins,cn=Users,dc=example,dc=lan)(samaccountname=%s)) +{% endif %} +;ldap admin filter = (&(samaccountname=lauri)(samaccountname=%s)) ;backend = whitelist user whitelist = @@ -62,9 +116,9 @@ autosign subnets = 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 scep subnets = ;scep subnets = 0.0.0.0/0 -# Online Certificate Status Protocol enabled subnets -ocsp subnets = -;ocsp subnets = 0.0.0.0/0 +# Online Certificate Status Protocol enabled subnets, anywhere by default +;ocsp subnets = +ocsp subnets = 0.0.0.0/0 # Certificate Revocation lists can be accessed from anywhere by default ;crl subnets = @@ -76,12 +130,24 @@ crl subnets = 0.0.0.0/0 renewal subnets = ;renewal subnets = 0.0.0.0/0 +# From which subnets autosign and SCEP requests are allowed to overwrite +# already existing certificate with same CN +overwrite subnets = +;overwrite subnets = 0.0.0.0/0 + +# Source subnets of Kerberos authenticated machines which are automatically +# allowed to enroll with CSR whose common name is set to machine's account name. +# Note that overwriting is not allowed by default, see 'overwrite subnets' +# option above +machine enrollment subnets = +;machine enrollment subnets = 0.0.0.0/0 + [logging] # Disable logging -;backend = +backend = # Use SQLite backend -backend = sql +;backend = sql database = sqlite://{{ directory }}/meta/db.sqlite [signature] @@ -144,13 +210,6 @@ request submission allowed = false ;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_cert }} @@ -199,3 +258,7 @@ secret = {{ token_secret }} path = {{ script_dir }} ;path = /etc/certidude/script ;path = + +[service] +protocols = ikev2 https openvpn +routers = ^router\d?\. diff --git a/doc/build-ap.sh b/doc/build-ap.sh deleted file mode 100644 index d18c320..0000000 --- a/doc/build-ap.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash - -set -e -set -x -umask 022 - -VERSION=17.01.4 -BASENAME=lede-imagebuilder-$VERSION-ar71xx-generic.Linux-x86_64 -FILENAME=$BASENAME.tar.xz -URL=http://downloads.lede-project.org/releases/$VERSION/targets/ar71xx/generic/$FILENAME - -PACKAGES="luci luci-app-commands \ - collectd collectd-mod-conntrack collectd-mod-interface \ - collectd-mod-iwinfo collectd-mod-load collectd-mod-memory \ - collectd-mod-network collectd-mod-protocols collectd-mod-tcpconns \ - collectd-mod-uptime \ - openssl-util openvpn-openssl curl ca-certificates \ - htop iftop tcpdump nmap nano -odhcp6c -odhcpd -dnsmasq \ - -luci-app-firewall \ - -pppd -luci-proto-ppp -kmod-ppp -ppp -ppp-mod-pppoe \ - -kmod-ip6tables -ip6tables -luci-proto-ipv6 -kmod-iptunnel6 -kmod-ipsec6" - - -if [ ! -e $FILENAME ]; then - wget -q $URL -fi - -if [ ! -e $BASENAME ]; then - tar xf $FILENAME -fi - -cd $BASENAME - -# Copy CA certificate -AUTHORITY=$(hostname -f) -CERTIDUDE_DIR=/var/lib/certidude/$AUTHORITY -if [ -d "$CERTIDUDE_DIR" ]; then - mkdir -p overlay/$CERTIDUDE_DIR - cp $CERTIDUDE_DIR/ca_cert.pem overlay/$CERTIDUDE_DIR -fi - -cat < EOF > overlay/etc/config/certidude - -config authority - option url http://$AUTHORITY - option authority_path /var/lib/certidude/$AUTHORITY/ca_cert.pem - option request_path /var/lib/certidude/$AUTHORITY/client_req.pem - option certificate_path /var/lib/certidude/$AUTHORITY/client_cert.pem - option key_path /var/lib/certidude/$AUTHORITY/client_key.pem - option key_type rsa - option key_length 1024 - option red_led gl-connect:red:wlan - option green_led gl-connect:green:lan - -EOF - -make image FILES=../overlay/ PACKAGES="$PACKAGES" PROFILE="$PROFILE" - diff --git a/doc/builder/ap.sh b/doc/builder/ap.sh index ca6799a..026de61 100644 --- a/doc/builder/ap.sh +++ b/doc/builder/ap.sh @@ -109,11 +109,10 @@ esac EOF make -C $BUILD/$BASENAME image FILES=$OVERLAY PROFILE=$PROFILE PACKAGES="luci luci-app-commands \ - openssl-util curl ca-certificates \ - strongswan-mod-kernel-libipsec kmod-tun ip-full strongswan-full \ + openssl-util curl ca-certificates dropbear \ + strongswan-mod-kernel-libipsec kmod-tun strongswan-default strongswan-mod-openssl strongswan-mod-curl strongswan-mod-ccm strongswan-mod-gcm \ htop iftop tcpdump nmap nano -odhcp6c -odhcpd -dnsmasq \ -luci-app-firewall \ -pppd -luci-proto-ppp -kmod-ppp -ppp -ppp-mod-pppoe \ - -kmod-ip6tables -ip6tables -luci-proto-ipv6 -kmod-iptunnel6 -kmod-ipsec6" - + -kmod-ip6tables -ip6tables -luci-proto-ipv6 -kmod-iptunnel6 -kmod-ipsec6 bc" diff --git a/doc/builder/common.sh b/doc/builder/common.sh index cec3ef9..0aaf6ee 100644 --- a/doc/builder/common.sh +++ b/doc/builder/common.sh @@ -9,6 +9,12 @@ BASENAME=lede-imagebuilder-$VERSION-ar71xx-generic.Linux-x86_64 FILENAME=$BASENAME.tar.xz URL=http://downloads.lede-project.org/releases/$VERSION/targets/ar71xx/generic/$FILENAME +# curl of vanilla LEDE doesn't support ECDSA at the moment +BASENAME=lede-imagebuilder-ar71xx-generic.Linux-x86_64 +FILENAME=$BASENAME.tar.xz +URL=https://www.koodur.com/$FILENAME + + if [ ! -e $BUILD/$FILENAME ]; then wget -q $URL -O $BUILD/$FILENAME fi @@ -19,58 +25,94 @@ fi # Copy CA certificate AUTHORITY=$(hostname -f) -CERTIDUDE_DIR=/var/lib/certidude/$AUTHORITY mkdir -p $OVERLAY/etc/config mkdir -p $OVERLAY/etc/uci-defaults -mkdir -p $OVERLAY/etc/certidude/authority/$AUTHORITY +mkdir -p $OVERLAY/etc/certidude/authority/$AUTHORITY/ cp /var/lib/certidude/$AUTHORITY/ca_cert.pem $OVERLAY/etc/certidude/authority/$AUTHORITY/ +echo /etc/certidude >> $OVERLAY/etc/sysupgrade.conf + cat < $OVERLAY/etc/config/certidude config authority - option gateway router.k-space.ee - option url http://$AUTHORITY + option gateway "$ROUTER" + option hostname "$AUTHORITY" option trigger wan - option authority_path /etc/certidude/authority/$AUTHORITY/ca_cert.pem - option request_path /etc/certidude/authority/$AUTHORITY/client_req.pem - option certificate_path /etc/certidude/authority/$AUTHORITY/client_cert.pem - option key_path /etc/certidude/authority/$AUTHORITY/client_key.pem - option key_type rsa + option key_type $AUTHORITY_CERTIFICATE_ALGORITHM option key_length 2048 + option key_curve secp384r1 EOF + cat << EOF > $OVERLAY/etc/uci-defaults/40-disable-ipsec /etc/init.d/ipsec disable EOF +case $AUTHORITY_CERTIFICATE_ALGORITHM in + rsa) + echo ": RSA /etc/certidude/authority/$AUTHORITY/host_key.pem" >> $OVERLAY/etc/ipsec.secrets + DHGROUP=modp2048 + ;; + ec) + echo ": ECDSA /etc/certidude/authority/$AUTHORITY/host_key.pem" >> $OVERLAY/etc/ipsec.secrets + DHGROUP=ecp384 + ;; + *) + echo "Unknown algorithm $AUTHORITY_CERTIFICATE_ALGORITHM" + exit 1 + ;; +esac +cat << EOF > $OVERLAY/etc/certidude/authority/$AUTHORITY/updown +#!/bin/sh -cat << EOF > $OVERLAY/etc/ipsec.secrets -: RSA /etc/certidude/authority/$AUTHORITY/client_key.pem +CURL="curl -m 3 -f --key /etc/certidude/authority/$AUTHORITY/host_key.pem --cert /etc/certidude/authority/$AUTHORITY/host_cert.pem --cacert /etc/certidude/authority/$AUTHORITY/ca_cert.pem" +URL="https://$AUTHORITY:8443/api/signed/\$(uci get system.@system[0].hostname)/script/" + +case \$PLUTO_VERB in + up-client) + logger -t certidude -s "Downloading and executing \$URL" + \$CURL \$URL -o /tmp/script.sh && sh /tmp/script.sh + ;; + *) ;; +esac EOF +chmod +x $OVERLAY/etc/certidude/authority/$AUTHORITY/updown + cat << EOF > $OVERLAY/etc/ipsec.conf config setup + strictcrlpolicy=yes ca $AUTHORITY - cacert=/etc/certidude/authority/$AUTHORITY/ca_cert.pem - auto=add + auto=add + cacert=/etc/certidude/authority/$AUTHORITY/ca_cert.pem + ocspuri = http://$AUTHORITY/api/ocsp/ -conn router.k-space.ee - right=router.k-space.ee - dpdaction=restart - auto=start - rightsubnet=0.0.0.0/0 - rightid=%any - leftsourceip=%config - keyexchange=ikev2 - closeaction=restart - leftcert=/etc/certidude/authority/$AUTHORITY/client_cert.pem - left=%defaultroute +conn %default + keyingtries=%forever + dpdaction=restart + closeaction=restart + ike=aes256-sha384-ecp384! + esp=aes128gcm16-aes128gmac! + left=%defaultroute + leftcert=/etc/certidude/authority/$AUTHORITY/host_cert.pem + leftca="$AUTHORITY_CERTIFICATE_DISTINGUISHED_NAME" + rightca="$AUTHORITY_CERTIFICATE_DISTINGUISHED_NAME" + +conn client-to-site + auto=start + right="$ROUTER" + rightsubnet=0.0.0.0/0 + leftsourceip=%config + leftupdown=/etc/certidude/authority/$AUTHORITY/updown EOF - +cat << EOF > $OVERLAY/etc/uci-defaults/99-uhttpd-disable-https +uci delete uhttpd.main.listen_https +uci delete uhttpd.main.redirect_https +EOF diff --git a/doc/builder/ipcam.sh b/doc/builder/ipcam.sh index ed05350..a3acc55 100644 --- a/doc/builder/ipcam.sh +++ b/doc/builder/ipcam.sh @@ -38,6 +38,7 @@ uci certidude.@authority[0].green_led='gl-connect:green:lan' EOF -make -C $BUILD/$BASENAME image FILES=$OVERLAY PROFILE=$PROFILE PACKAGES="openssl-util curl ca-certificates strongswan-full htop \ - iftop tcpdump nmap nano mtr patch diffutils ipset usbutils luci luci-app-mjpg-streamer kmod-video-uvc \ - pciutils -dnsmasq -odhcpd -odhcp6c -kmod-ath9k picocom strongswan-mod-kernel-libipsec kmod-tun ip-full" +make -C $BUILD/$BASENAME image FILES=$OVERLAY PROFILE=$PROFILE PACKAGES="openssl-util curl ca-certificates \ + strongswan-default strongswan-mod-openssl strongswan-mod-curl strongswan-mod-ccm strongswan-mod-gcm htop \ + iftop tcpdump nmap nano mtr patch diffutils ipset usbutils luci luci-app-mjpg-streamer kmod-video-uvc dropbear \ + pciutils -dnsmasq -odhcpd -odhcp6c -kmod-ath9k picocom strongswan-mod-kernel-libipsec kmod-tun bc" diff --git a/doc/builder/mfp.sh b/doc/builder/mfp.sh index 6095be7..c5b7fbe 100644 --- a/doc/builder/mfp.sh +++ b/doc/builder/mfp.sh @@ -96,15 +96,15 @@ uci set firewall.@redirect[-1].target=DNAT uci set firewall.@redirect[-1].proto=tcp uci set firewall.@redirect[-1].enabled=0 -uci set uhttpd.main.listen_http=0.0.0.0:8080 - /etc/init.d/dropbear disable +uci set uhttpd.main.listen_http=0.0.0.0:8080 + EOF make -C $BUILD/$BASENAME image FILES=$OVERLAY PROFILE=$PROFILE PACKAGES="openssl-util curl ca-certificates htop \ - iftop tcpdump nmap nano mtr patch diffutils ipset usbutils luci \ - strongswan-mod-kernel-libipsec kmod-tun ip-full strongswan-full \ - pciutils -odhcpd -odhcp6c -kmod-ath9k picocom libustream-openssl kmod-crypto-gcm" + iftop tcpdump nmap nano mtr patch diffutils ipset usbutils luci dropbear kmod-tun \ + strongswan-default strongswan-mod-kernel-libipsec strongswan-mod-openssl strongswan-mod-curl strongswan-mod-ccm strongswan-mod-gcm \ + pciutils -odhcpd -odhcp6c -kmod-ath9k picocom libustream-openssl kmod-crypto-gcm bc" diff --git a/doc/overlay/etc/hotplug.d/iface/50-certidude b/doc/overlay/etc/hotplug.d/iface/50-certidude new file mode 100644 index 0000000..22b1b76 --- /dev/null +++ b/doc/overlay/etc/hotplug.d/iface/50-certidude @@ -0,0 +1,10 @@ +#!/bin/sh + +# To test: ACTION=ifup INTERFACE=wan sh /etc/hotplug.d/iface/50-certidude + +AUTHORITY=certidude.@authority[0] + +[ $ACTION == "ifup" ] || exit 0 +[ $INTERFACE == "$(uci get $AUTHORITY.trigger)" ] || exit 0 + +/usr/bin/certidude-enroll diff --git a/doc/overlay/etc/uci-defaults/60-cron b/doc/overlay/etc/uci-defaults/60-cron new file mode 100644 index 0000000..f445801 --- /dev/null +++ b/doc/overlay/etc/uci-defaults/60-cron @@ -0,0 +1,9 @@ +cat << EOF > /etc/crontabs/root +15 1 * * * sleep 70 && touch /etc/banner && reboot +10 1 1 */2 * /usr/bin/certidude-enroll-renew +EOF + +chmod 0600 /etc/crontabs/root + +/etc/init.d/cron enable + diff --git a/doc/overlay/usr/bin/certidude-enroll b/doc/overlay/usr/bin/certidude-enroll new file mode 100755 index 0000000..4b9c59a --- /dev/null +++ b/doc/overlay/usr/bin/certidude-enroll @@ -0,0 +1,123 @@ +#!/bin/sh + +AUTHORITY=certidude.@authority[0] + +# TODO: iterate over all authorities + +GATEWAY=$(uci get $AUTHORITY.gateway) +COMMON_NAME=$(uci get system.@system[0].hostname) + +DIR=/etc/certidude/authority/$(uci get $AUTHORITY.hostname) +mkdir -p $DIR + +AUTHORITY_PATH=$DIR/ca_cert.pem +CERTIFICATE_PATH=$DIR/host_cert.pem +REQUEST_PATH=$DIR/host_req.pem +KEY_PATH=$DIR/host_key.pem +KEY_TYPE=$(uci get $AUTHORITY.key_type) +KEY_LENGTH=$(uci get $AUTHORITY.key_length) +KEY_CURVE=$(uci get $AUTHORITY.key_curve) + +NTP_SERVERS=$(uci get system.ntp.server) + +logger -t certidude -s "Fetching time from NTP servers: $NTP_SERVERS" +ntpd -q -n -d -p $NTP_SERVERS + +logger -t certidude -s "Time is now: $(date)" + +# If certificate file is there assume everything's set up +if [ -f $CERTIFICATE_PATH ]; then + SERIAL=$(openssl x509 -in $CERTIFICATE_PATH -noout -serial | cut -d "=" -f 2 | tr [A-F] [a-f]) + logger -t certidude -s "Certificate with serial $SERIAL already exists in $CERTIFICATE_PATH, attempting to bring up VPN tunnel..." + ipsec restart + exit 0 +fi + + +######################################### +### Generate private key if necessary ### +######################################### + +if [ ! -f $KEY_PATH ]; then + + logger -t certidude -s "Generating $KEY_TYPE key for VPN..." + + case $KEY_TYPE in + rsa) + openssl genrsa -out $KEY_PATH.part $KEY_LENGTH + ;; + ec) + openssl ecparam -name $KEY_CURVE -genkey -noout -out $KEY_PATH.part + ;; + esac + mv $KEY_PATH.part $KEY_PATH +fi + + +############################ +### Fetch CA certificate ### +############################ + +if [ ! -f $AUTHORITY_PATH ]; then + + logger -t certidude -s "Fetching CA certificate from $URL/api/certificate/" + curl -f -s http://$(uci get $AUTHORITY.hostname)/api/certificate/ -o $AUTHORITY_PATH.part + if [ $? -ne 0 ]; then + logger -t certidude -s "Failed to receive CA certificate, server responded: $(cat $AUTHORITY_PATH.part)" + exit 10 + fi + + openssl x509 -in $AUTHORITY_PATH.part -noout + if [ $? -ne 0 ]; then + logger -t certidude -s "Received invalid CA certificate" + exit 11 + fi + + mv $AUTHORITY_PATH.part $AUTHORITY_PATH +fi + +logger -t certidude -s "CA certificate md5sum: $(md5sum -b $AUTHORITY_PATH)" + + +##################################### +### Generate request if necessary ### +##################################### + +if [ ! -f $REQUEST_PATH ]; then + openssl req -new -sha256 -key $KEY_PATH -out $REQUEST_PATH.part -subj "/CN=$COMMON_NAME" + mv $REQUEST_PATH.part $REQUEST_PATH +fi + +logger -t certidude -s "Request md5sum is $(md5sum -b $REQUEST_PATH)" + +curl -f -L \ + -H "Content-Type: application/pkcs10" \ + --cacert $AUTHORITY_PATH \ + --data-binary @$REQUEST_PATH \ + https://$(uci get $AUTHORITY.hostname):8443/api/request/?autosign=true\&wait=yes -o $CERTIFICATE_PATH.part + +# TODO: Loop until we get exitcode 0 +# TODO: Use backoff time $((2\*X)) + +if [ $? -ne 0 ]; then + echo "Failed to fetch certificate" + exit 21 +fi + +# Verify certificate +openssl verify -CAfile $AUTHORITY_PATH $CERTIFICATE_PATH.part + +if [ $? -ne 0 ]; then + logger -t certidude -s "Received bogus certificate!" + exit 22 +fi + +logger -t certidude -s "Certificate md5sum: $(md5sum -b $CERTIFICATE_PATH.part)" + +uci commit + +mv $CERTIFICATE_PATH.part $CERTIFICATE_PATH + +# Start services +logger -t certidude -s "Starting IPSec IKEv2 daemon..." +ipsec restart diff --git a/doc/overlay/usr/bin/certidude-enroll-renew b/doc/overlay/usr/bin/certidude-enroll-renew new file mode 100755 index 0000000..cb4370d --- /dev/null +++ b/doc/overlay/usr/bin/certidude-enroll-renew @@ -0,0 +1,25 @@ +#!/bin/sh + +AUTHORITY=certidude.@authority[0] +URL=https://$(uci get $AUTHORITY.hostname):8443 +DIR=/etc/certidude/authority/$(uci get $AUTHORITY.hostname) +AUTHORITY_PATH=$DIR/ca_cert.pem +CERTIFICATE_PATH=$DIR/host_cert.pem +REQUEST_PATH=$DIR/host_req.pem +KEY_PATH=$DIR/host_key.pem + +curl -f -L \ + -H "Content-Type: application/pkcs10" \ + --data-binary @$REQUEST_PATH \ + --cacert $AUTHORITY_PATH \ + --key $KEY_PATH \ + --cert $CERTIFICATE_PATH \ + $URL/api/request/ -o $CERTIFICATE_PATH.part + +if [ $? -eq 0 ]; then + logger -t certidude -s "Certificate renewal successful" + mv $CERTIFICATE_PATH.part $CERTIFICATE_PATH + ipsec reload +else + logger -t certidude -s "Failed to renew certificate" +fi diff --git a/tests/test_cli.py b/tests/test_cli.py index c6598f0..b56b015 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -60,12 +60,13 @@ def clean_client(): files = [ "/etc/certidude/client.conf", "/etc/certidude/services.conf", - "/var/lib/certidude/ca.example.lan/client_key.pem", - "/var/lib/certidude/ca.example.lan/server_key.pem", - "/var/lib/certidude/ca.example.lan/client_req.pem", - "/var/lib/certidude/ca.example.lan/server_req.pem", - "/var/lib/certidude/ca.example.lan/client_cert.pem", - "/var/lib/certidude/ca.example.lan/server_cert.pem", + "/etc/certidude/authority/ca.example.lan/ca_cert.pem", + "/etc/certidude/authority/ca.example.lan/client_key.pem", + "/etc/certidude/authority/ca.example.lan/server_key.pem", + "/etc/certidude/authority/ca.example.lan/client_req.pem", + "/etc/certidude/authority/ca.example.lan/server_req.pem", + "/etc/certidude/authority/ca.example.lan/client_cert.pem", + "/etc/certidude/authority/ca.example.lan/server_cert.pem", ] for path in files: if os.path.exists(path): @@ -85,6 +86,8 @@ def clean_client(): def clean_server(): + os.umask(0o22) + if os.path.exists("/run/certidude/server.pid"): with open("/run/certidude/server.pid") as fh: try: @@ -95,30 +98,29 @@ def clean_server(): if os.path.exists("/var/lib/certidude/ca.example.lan"): shutil.rmtree("/var/lib/certidude/ca.example.lan") - if os.path.exists("/etc/certidude/server.conf"): - os.unlink("/etc/certidude/server.conf") if os.path.exists("/run/certidude"): shutil.rmtree("/run/certidude") - if os.path.exists("/var/log/certidude.log"): - os.unlink("/var/log/certidude.log") - if os.path.exists("/etc/cron.hourly/certidude"): - os.unlink("/etc/cron.hourly/certidude") - # systemd - if os.path.exists("/etc/systemd/system/certidude.service"): - os.unlink("/etc/systemd/system/certidude.service") + files = [ + "/etc/krb5.keytab", + "/etc/samba/smb.conf", + "/etc/certidude/server.conf", + "/etc/certidude/builder.conf", + "/etc/certidude/profile.conf", + "/var/log/certidude.log", + "/etc/cron.hourly/certidude", + "/etc/systemd/system/certidude.service", + "/etc/nginx/sites-available/ca.conf", + "/etc/nginx/sites-enabled/ca.conf", + "/etc/nginx/sites-available/certidude.conf", + "/etc/nginx/sites-enabled/certidude.conf", + "/etc/nginx/conf.d/tls.conf", + "/etc/certidude/server.keytab", + ] - # Remove nginx stuff - if os.path.exists("/etc/nginx/sites-available/ca.conf"): - os.unlink("/etc/nginx/sites-available/ca.conf") - if os.path.exists("/etc/nginx/sites-enabled/ca.conf"): - os.unlink("/etc/nginx/sites-enabled/ca.conf") - if os.path.exists("/etc/nginx/sites-available/certidude.conf"): - os.unlink("/etc/nginx/sites-available/certidude.conf") - if os.path.exists("/etc/nginx/sites-enabled/certidude.conf"): - os.unlink("/etc/nginx/sites-enabled/certidude.conf") - if os.path.exists("/etc/nginx/conf.d/tls.conf"): - os.unlink("/etc/nginx/conf.d/tls.conf") + for filename in files: + if os.path.exists(filename): + os.unlink(filename) # Remove OpenVPN stuff if os.path.exists("/etc/openvpn"): @@ -135,8 +137,6 @@ def clean_server(): os.kill(int(fh.read()), 15) except OSError: pass - if os.path.exists("/etc/certidude/server.keytab"): - os.unlink("/etc/certidude/server.keytab") os.system("rm -Rfv /var/lib/samba/*") # Restore initial resolv.conf @@ -146,7 +146,7 @@ def test_cli_setup_authority(): assert os.getuid() == 0, "Run tests as root in a clean VM or container" assert check_output(["/bin/hostname", "-f"]) == b"ca.example.lan\n", "As a safety precaution, unittests only run in a machine whose hostanme -f is ca.example.lan" - os.system("apt-get install -q -y git build-essential python-dev libkrb5-dev") + os.system("DEBIAN_FRONTEND=noninteractive apt-get install -qq -y git build-essential python-dev libkrb5-dev") assert not os.environ.get("KRB5CCNAME"), "Environment contaminated" assert not os.environ.get("KRB5_KTNAME"), "Environment contaminated" @@ -189,39 +189,6 @@ def test_cli_setup_authority(): if "userbot" not in buf: os.system("useradd userbot -G users -p '$1$PBkf5waA$n9EV6WJ7PS6lyGWkgeTPf1' -c 'User Bot,,,'") - # Bootstrap domain controller here, - # Samba startup takes some time - os.system("apt-get install -y samba krb5-user winbind bc") - if os.path.exists("/etc/samba/smb.conf"): - os.unlink("/etc/samba/smb.conf") - os.system("samba-tool domain provision --server-role=dc --domain=EXAMPLE --realm=EXAMPLE.LAN --host-name=ca") - os.system("samba-tool user add userbot S4l4k4l4 --given-name='User' --surname='Bot'") - os.system("samba-tool user add adminbot S4l4k4l4 --given-name='Admin' --surname='Bot'") - os.system("samba-tool group addmembers 'Domain Admins' adminbot") - os.system("samba-tool user setpassword administrator --newpassword=S4l4k4l4") - if os.path.exists("/etc/krb5.keytab"): - os.unlink("/etc/krb5.keytab") - os.symlink("/var/lib/samba/private/secrets.keytab", "/etc/krb5.keytab") - os.chmod("/var/lib/samba/private/secrets.keytab", 0o644) # To allow access to certidude server - if os.path.exists("/etc/krb5.conf"): # Remove the one from krb5-user package - os.unlink("/etc/krb5.conf") - os.symlink("/var/lib/samba/private/krb5.conf", "/etc/krb5.conf") - with open("/etc/resolv.conf", "w") as fh: - fh.write("nameserver 127.0.0.1\nsearch example.lan\n") - # TODO: dig -t srv perhaps? - os.system("samba") - - # Samba bind 636 late (probably generating keypair) - # so LDAPS connections below will fail - timeout = 0 - while timeout < 30: - if os.path.exists("/var/lib/samba/private/tls/cert.pem"): - break - sleep(1) - timeout += 1 - else: - assert False, "Samba startup timed out" - reload(const) from certidude.cli import entry_point as cli @@ -447,15 +414,6 @@ def test_cli_setup_authority(): assert "Stored request " in inbox.pop(), inbox assert not inbox - buf = generate_csr(cn="test2.example.lan") - r = client().simulate_post("/api/request/", - query_string="autosign=1", - body=buf, - headers={"content-type":"application/pkcs10"}) - assert r.status_code == 202 # server CN, request stored - assert "Stored request " in inbox.pop(), inbox - assert not inbox - # Test signed certificate API call r = client().simulate_get("/api/signed/nonexistant/") assert r.status_code == 404, r.text @@ -574,7 +532,7 @@ def test_cli_setup_authority(): # Test tagging integration in scripting framework r = client().simulate_get("/api/signed/test/script/") assert r.status_code == 200, r.text # script render ok - assert "curl http://ca.example.lan/api/signed/test/attr " in r.text, r.text + assert "curl https://ca.example.lan:8443/api/signed/test/attr " in r.text, r.text assert "Tartu" in r.text, r.text r = client().simulate_post("/api/signed/test/tag/", @@ -640,7 +598,7 @@ def test_cli_setup_authority(): headers={"Authorization":admintoken}) assert r.status_code == 200, r.text assert "Revoked " in inbox.pop(), inbox - + """ # Log can be read only by admin r = client().simulate_get("/api/log/") @@ -652,7 +610,7 @@ def test_cli_setup_authority(): headers={"Authorization":admintoken}) assert r.status_code == 200, r.text assert r.headers.get('content-type') == "application/json; charset=UTF-8" - + """ # Test session API call r = client().simulate_get("/api/") @@ -708,11 +666,14 @@ def test_cli_setup_authority(): with open("/etc/certidude/client.conf", "a") as fh: fh.write("insecure = true\n") + fh.write("autosign = false\n") + assert not os.path.exists("/etc/certidude/authority/ca.example.lan/server_cert.pem") result = runner.invoke(cli, ["enroll", "--skip-self", "--no-wait"]) assert not result.exception, result.output assert not os.path.exists("/run/certidude/ca.example.lan.pid"), result.output - assert "(Autosign failed, only client certificates allowed to be signed automatically)" in result.output, result.output + assert "(autosign not requested)" in result.output, result.output + assert not os.path.exists("/etc/certidude/authority/ca.example.lan/server_cert.pem") child_pid = os.fork() if not child_pid: @@ -727,12 +688,12 @@ def test_cli_setup_authority(): 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 + assert os.path.exists("/etc/certidude/authority/ca.example.lan/server_cert.pem") result = runner.invoke(cli, ["enroll", "--skip-self", "--renew", "--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 - assert "Attached renewal signature" in result.output, result.output + assert "Renewing using current keypair" in result.output, result.output # Test nginx setup assert os.system("nginx -t") == 0, "Generated nginx config was invalid" @@ -745,7 +706,7 @@ def test_cli_setup_authority(): # First OpenVPN server is set up clean_client() - assert not os.path.exists("/var/lib/certidude/ca.example.lan/server_cert.pem") + assert not os.path.exists("/etc/certidude/authority/ca.example.lan/server_cert.pem") if not os.path.exists("/etc/openvpn/keys"): os.makedirs("/etc/openvpn/keys") @@ -761,12 +722,13 @@ def test_cli_setup_authority(): with open("/etc/certidude/client.conf", "a") as fh: fh.write("insecure = true\n") + fh.write("autosign = false\n") - assert not os.path.exists("/var/lib/certidude/ca.example.lan/server_cert.pem") + assert not os.path.exists("/etc/certidude/authority/ca.example.lan/server_cert.pem") result = runner.invoke(cli, ["enroll", "--skip-self", "--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 "(autosign not requested)" 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") @@ -785,7 +747,7 @@ def test_cli_setup_authority(): 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 - assert os.path.exists("/var/lib/certidude/ca.example.lan/server_cert.pem") + assert os.path.exists("/etc/certidude/authority//ca.example.lan/server_cert.pem") assert os.path.exists("/etc/openvpn/site-to-client.conf") # Secondly OpenVPN client is set up @@ -977,7 +939,7 @@ def test_cli_setup_authority(): result = runner.invoke(cli, ['setup', 'strongswan', 'server', "-cn", "ipsec.example.lan", "ca.example.lan"]) assert not result.exception, result.output - assert open("/etc/ipsec.secrets").read() == ": RSA /var/lib/certidude/ca.example.lan/server_key.pem\n" + assert open("/etc/ipsec.secrets").read() == ": RSA /etc/certidude/authority/ca.example.lan/server_key.pem\n" assert not os.path.exists("/var/lib/certidude/ca.example.lan/signed/ipsec.example.lan.pem") result = runner.invoke(cli, ['setup', 'strongswan', 'server', "-cn", "ipsec.example.lan", "ca.example.lan"]) @@ -986,10 +948,11 @@ def test_cli_setup_authority(): with open("/etc/certidude/client.conf", "a") as fh: fh.write("insecure = true\n") + fh.write("autosign = false\n") result = runner.invoke(cli, ["enroll", "--skip-self", "--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 "(autosign not requested)" 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/ipsec.example.lan.pem") @@ -1009,7 +972,7 @@ 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 os.path.exists("/var/lib/certidude/ca.example.lan/server_cert.pem") + assert os.path.exists("/etc/certidude/authority/ca.example.lan/server_cert.pem") # IPSec client as service @@ -1073,12 +1036,39 @@ def test_cli_setup_authority(): r = requests.get("http://ca.example.lan/api/scep/") assert r.status_code == 404 - r = requests.get("http://ca.example.lan/api/ocsp/") - assert r.status_code == 404 r = requests.post("http://ca.example.lan/api/scep/") assert r.status_code == 404 + + + ################# + ### Test OCSP ### + ################# + + r = requests.get("http://ca.example.lan/api/ocsp/") + assert r.status_code == 400 r = requests.post("http://ca.example.lan/api/ocsp/") - assert r.status_code == 404 + assert r.status_code == 400 + + + assert os.system("openssl ocsp -issuer /var/lib/certidude/ca.example.lan/ca_cert.pem -CAfile /var/lib/certidude/ca.example.lan/ca_cert.pem -cert /var/lib/certidude/ca.example.lan/signed/roadwarrior2.pem -text -url http://ca.example.lan/api/ocsp/ -out /tmp/ocsp1.log") == 0 + assert os.system("openssl ocsp -issuer /var/lib/certidude/ca.example.lan/ca_cert.pem -CAfile /var/lib/certidude/ca.example.lan/ca_cert.pem -cert /var/lib/certidude/ca.example.lan/ca_cert.pem -text -url http://ca.example.lan/api/ocsp/ -out /tmp/ocsp2.log") == 0 + + for filename in os.listdir("/var/lib/certidude/ca.example.lan/revoked"): + if not filename.endswith(".pem"): + continue + assert os.system("openssl ocsp -issuer /var/lib/certidude/ca.example.lan/ca_cert.pem -CAfile /var/lib/certidude/ca.example.lan/ca_cert.pem -cert /var/lib/certidude/ca.example.lan/revoked/%s -text -url http://ca.example.lan/api/ocsp/ -out /tmp/ocsp3.log" % filename) == 0 + break + + with open("/tmp/ocsp1.log") as fh: + buf = fh.read() + assert ": good" in buf, buf + with open("/tmp/ocsp2.log") as fh: + buf = fh.read() + assert ": unknown" in buf, buf + with open("/tmp/ocsp3.log") as fh: + buf = fh.read() + assert ": revoked" in buf, buf + #################################### @@ -1088,8 +1078,53 @@ def test_cli_setup_authority(): # Shut down current instance os.kill(server_pid, 15) requests.get("http://ca.example.lan/api/") +# sleep(2) +# os.kill(server_pid, 9) # TODO: Figure out why doesn't shut down gracefully os.waitpid(server_pid, 0) + # Install packages + os.system("apt-get install -y samba krb5-user winbind bc") + clean_server() + + # Bootstrap domain controller here, + # Samba startup takes some timec + os.system("samba-tool domain provision --server-role=dc --domain=EXAMPLE --realm=EXAMPLE.LAN --host-name=ca") + os.system("samba-tool user add userbot S4l4k4l4 --given-name='User' --surname='Bot'") + os.system("samba-tool user add adminbot S4l4k4l4 --given-name='Admin' --surname='Bot'") + os.system("samba-tool group addmembers 'Domain Admins' adminbot") + os.system("samba-tool user setpassword administrator --newpassword=S4l4k4l4") + os.symlink("/var/lib/samba/private/secrets.keytab", "/etc/krb5.keytab") + os.chmod("/var/lib/samba/private/secrets.keytab", 0o644) # To allow access to certidude server + if os.path.exists("/etc/krb5.conf"): # Remove the one from krb5-user package + os.unlink("/etc/krb5.conf") + os.symlink("/var/lib/samba/private/krb5.conf", "/etc/krb5.conf") + with open("/etc/resolv.conf", "w") as fh: + fh.write("nameserver 127.0.0.1\nsearch example.lan\n") + # TODO: dig -t srv perhaps? + os.system("samba") + + # Samba bind 636 late (probably generating keypair) + # so LDAPS connections below will fail + timeout = 0 + while timeout < 30: + if os.path.exists("/var/lib/samba/private/tls/cert.pem"): + break + sleep(1) + timeout += 1 + else: + assert False, "Samba startup timed out" + + # Bootstrap authority + bootstrap_pid = os.fork() # TODO: this shouldn't be necessary + if not bootstrap_pid: + result = runner.invoke(cli, ["setup", "authority", "--skip-packages", "--elliptic-curve"]) + assert not result.exception, result.output + return + else: + os.waitpid(bootstrap_pid, 0) + + assert os.getuid() == 0 and os.getgid() == 0, "Environment contaminated" + # (re)auth against DC assert os.system("kdestroy") == 0 assert not os.path.exists("/tmp/krb5cc_0") @@ -1116,13 +1151,11 @@ def test_cli_setup_authority(): # Certidude would auth against domain controller os.system("sed -e 's/ldap uri = ldaps:.*/ldap uri = ldaps:\\/\\/ca.example.lan/g' -i /etc/certidude/server.conf") os.system("sed -e 's/ldap uri = ldap:.*/ldap uri = ldap:\\/\\/ca.example.lan/g' -i /etc/certidude/server.conf") - os.system("sed -e 's/backends = pam/backends = kerberos ldap/g' -i /etc/certidude/server.conf") - os.system("sed -e 's/backend = posix/backend = ldap/g' -i /etc/certidude/server.conf") os.system("sed -e 's/dc1/ca/g' -i /etc/cron.hourly/certidude") os.system("sed -e 's/autosign subnets =.*/autosign subnets =/g' -i /etc/certidude/server.conf") - os.system("sed -e 's/machine enrollment =.*/machine enrollment = allowed/g' -i /etc/certidude/server.conf") + os.system("sed -e 's/machine enrollment subnets =.*/machine enrollment subnets = 0.0.0.0\\/0/g' -i /etc/certidude/server.conf") os.system("sed -e 's/scep subnets =.*/scep subnets = 0.0.0.0\\/0/g' -i /etc/certidude/server.conf") - os.system("sed -e 's/ocsp subnets =.*/ocsp subnets = 0.0.0.0\\/0/g' -i /etc/certidude/server.conf") + os.system("sed -e 's/ocsp subnets =.*/ocsp subnets =/g' -i /etc/certidude/server.conf") os.system("sed -e 's/crl subnets =.*/crl subnets =/g' -i /etc/certidude/server.conf") os.system("sed -e 's/address = certificates@example.lan/address =/g' -i /etc/certidude/server.conf") from certidude.common import pip @@ -1157,41 +1190,25 @@ def test_cli_setup_authority(): if r.status_code != 502: break sleep(1) - assert r.status_code == 401 + assert r.status_code == 401, "Timed out starting up the API backend" # CRL-s disabled now r = requests.get("http://ca.example.lan/api/revoked/") assert r.status_code == 404, r.text - # OCSP and SCEP should be enabled now + # SCEP should be enabled now r = requests.get("http://ca.example.lan/api/scep/") assert r.status_code == 400 - r = requests.get("http://ca.example.lan/api/ocsp/") - assert r.status_code == 400 r = requests.post("http://ca.example.lan/api/scep/") assert r.status_code == 405 + + # OCSP should be disabled now + r = requests.get("http://ca.example.lan/api/ocsp/") + assert r.status_code == 404 r = requests.post("http://ca.example.lan/api/ocsp/") - assert r.status_code == 400 + assert r.status_code == 404 - assert os.system("openssl ocsp -issuer /var/lib/certidude/ca.example.lan/ca_cert.pem -cert /var/lib/certidude/ca.example.lan/signed/roadwarrior2.pem -text -url http://ca.example.lan/api/ocsp/ -out /tmp/ocsp1.log") == 0 - assert os.system("openssl ocsp -issuer /var/lib/certidude/ca.example.lan/ca_cert.pem -cert /var/lib/certidude/ca.example.lan/ca_cert.pem -text -url http://ca.example.lan/api/ocsp/ -out /tmp/ocsp2.log") == 0 - - for filename in os.listdir("/var/lib/certidude/ca.example.lan/revoked"): - if not filename.endswith(".pem"): - continue - assert os.system("openssl ocsp -issuer /var/lib/certidude/ca.example.lan/ca_cert.pem -cert /var/lib/certidude/ca.example.lan/revoked/%s -text -url http://ca.example.lan/api/ocsp/ -out /tmp/ocsp3.log" % filename) == 0 - break - - with open("/tmp/ocsp1.log") as fh: - buf = fh.read() - assert ": good" in buf, buf - with open("/tmp/ocsp2.log") as fh: - buf = fh.read() - assert ": unknown" in buf, buf - with open("/tmp/ocsp3.log") as fh: - buf = fh.read() - assert ": revoked" in buf, buf ##################### @@ -1274,7 +1291,6 @@ def test_cli_setup_authority(): ### SCEP tests ### ################## - os.umask(0o022) 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"): @@ -1283,7 +1299,7 @@ def test_cli_setup_authority(): 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("echo '.\n.\n.\n.\nGateway\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 @@ -1301,13 +1317,15 @@ def test_cli_setup_authority(): # Shut down server assert os.path.exists("/proc/%d" % server_pid) os.kill(server_pid, 15) +# sleep(2) +# os.kill(server_pid, 9) os.waitpid(server_pid, 0) # Note: STORAGE_PATH was mangled above, hence it's /tmp not /var/lib/certidude assert open("/etc/apparmor.d/local/usr.lib.ipsec.charon").read() == \ - "/var/lib/certidude/ca.example.lan/client_key.pem r,\n" + \ - "/var/lib/certidude/ca.example.lan/ca_cert.pem r,\n" + \ - "/var/lib/certidude/ca.example.lan/client_cert.pem r,\n" + "/etc/certidude/authority/ca.example.lan/client_key.pem r,\n" + \ + "/etc/certidude/authority/ca.example.lan/ca_cert.pem r,\n" + \ + "/etc/certidude/authority/ca.example.lan/client_cert.pem r,\n" assert len(inbox) == 0, inbox # Make sure all messages were checked os.system("service nginx stop")