diff --git a/warden3/contrib/warden_ra/warden_ra.py b/warden3/contrib/warden_ra/warden_ra.py index f524153d720d515ad3de0c63620bc4e4f61a0703..bb340842aa667f2fc0facf80592d205fcf4ffa0c 100755 --- a/warden3/contrib/warden_ra/warden_ra.py +++ b/warden3/contrib/warden_ra/warden_ra.py @@ -6,13 +6,22 @@ import sys import os +import time +import fcntl +import errno import string import random import struct +import operator import argparse -import subprocess import json import logging +import os.path as pth +import subprocess +import shlex +import tempfile +import M2Crypto +import ConfigParser # *ph* server vulnerable to logjam, local openssl too new, use hammer to disable Diffie-Helmann import ssl ssl._DEFAULT_CIPHERS += ":!DH" @@ -20,105 +29,55 @@ ssl._DEFAULT_CIPHERS += ":!DH" import ejbcaws # usual path to warden server -sys.path.append(os.path.join(os.path.dirname(__file__), "..", "warden-server")) +sys.path.append(pth.join(pth.dirname(__file__), "..", "warden-server")) import warden_server from warden_server import Request, ObjectBase, FileLogger, SysLogger, Server, expose, read_cfg -class ClientDisabledError(Exception): - pass +class ClientDisabledError(Exception): pass +class ClientNotIssuableError(Exception): pass +class AuthenticationError(Exception): pass +class PopenError(Exception): pass -class EjbcaClient(object): +class Client(object): - def __init__(self, registry, ejbca_data=None): - self.registry = registry - self.ejbca_data = ejbca_data or {} - - @property - def admins(self): - return [u if not u.startswith("RFC822NAME") else u[11:] for u in self.ejbca_data["subjectAltName"].split(",")] - - @admins.setter - def admins(self, emails): - self.ejbca_data["subjectAltName"] = ",".join(("RFC822NAME=%s" % e for e in emails)) - - @property - def name(self): - username = self.ejbca_data["username"] - if not username.endswith(self.registry.username_suffix): - raise ValueError(("Ejbca user username does not conform to config", self.ejbca_data)) - return username[:-len(self.registry.username_suffix)] - - @name.setter - def name(self, new): - self.ejbca_data["username"] = new + self.registry.username_suffix - self.ejbca_data["subjectDN"] = self.registry.subject_dn_template % new - - @property - def enabled(self): - return self.ejbca_data["status"] != ejbcaws.STATUS_HISTORICAL - - @enabled.setter - def enabled(self, new): - if self.enabled: - if not new: - self.ejbca_data["status"] = ejbcaws.STATUS_HISTORICAL - else: - if new: - self.ejbca_data["status"] = ejbcaws.STATUS_GENERATED - - @property - def status(self): - s = self.ejbca_data["status"] - if s == ejbcaws.STATUS_NEW: - return "Issuable" - elif s == ejbcaws.STATUS_GENERATED: - return "Passive" - elif s == ejbcaws.STATUS_INITIALIZED: - return "New" - elif s == ejbcaws.STATUS_HISTORICAL: - return "Disabled" - else: - return "EJBCA status %d" % s - - def get_certs(self): - return self.registry.ejbca.find_certs(self.ejbca_data["username"], validOnly=False) - - def allow_new_cert(self, pwd=None): - if not self.enabled: - raise ClientDisabledError("This client is disabled") - self.ejbca_data["status"] = ejbcaws.STATUS_NEW - if pwd is not None: - self.ejbca_data["password"] = pwd - self.ejbca_data["clearPwd"] = True - - def new_cert(self, csr, pwd): - cert = self.registry.ejbca.pkcs10_request( - self.ejbca_data["username"], - pwd, csr, 0, ejbcaws.RESPONSETYPE_CERTIFICATE) - return cert + def __init__(self, name, admins=None, status=None, pwd=None, opaque=None): + self.name = name + self.admins = admins or [] + self.status = status or "New" + self.pwd = pwd + self.opaque = opaque or {} + + def update(self, admins=None, status=None, pwd=None): + if admins is not None: + self.admins = admins + if status: + if self.status == "Disabled" and status not in ("Passive", "Disabled"): + raise ClientDisabledError("This client is disabled") + self.status = status + self.pwd = pwd if status=="Issuable" and pwd else None def __str__(self): return ( - "Client: %s (%s)\n" + "Client: %s\n" "Admins: %s\n" "Status: %s\n" - ) % ( - self.name, - self.ejbca_data["subjectDN"], - ", ".join(self.admins), - self.status - ) + ) % (self.name, ", ".join(self.admins), self.status) - def verbose_str(self): - return "%s\n" % self.ejbca_data + def str(self, verbose=False): + return str(self) + (str(self.opaque) if self.opaque and verbose else "") - def save(self): - self.registry.ejbca.edit_user(self.ejbca_data) +class EjbcaRegistry(OpenSSLRegistry): -class EjbcaRegistry(object): + status_ejbca_to_str = { + ejbcaws.STATUS_NEW: "Issuable", + ejbcaws.STATUS_GENERATED: "Passive", + ejbcaws.STATUS_INITIALIZED: "New", + ejbcaws.STATUS_HISTORICAL: "Disabled" + } + status_str_to_ejbca = dict((v, k) for k, v in status_ejbca_to_str.items()) def __init__(self, log, url, cert=None, key=None, ca_name="", certificate_profile_name="", end_entity_profile_name="", @@ -131,8 +90,15 @@ class EjbcaRegistry(object): self.subject_dn_template = subject_dn_template self.username_suffix = username_suffix + def client_data(self, ejbca_data): + ejbca_username = ejbca_data["username"] + username = ejbca_username[:-len(self.username_suffix)] if ejbca_username.endswith(self.username_suffix) else ejbca_username + admins = [u if not u.startswith("RFC822NAME") else u[11:] for u in ejbca_data["subjectAltName"].split(",")] + status = self.status_ejbca_to_str.get(ejbca_data["status"], "Other") + return username, admins, status, None, ejbca_data + def get_clients(self): - return (EjbcaClient(registry=self, ejbca_data=data) for data in self.ejbca.get_users()) + return [Client(*self.client_data(u)) for u in self.ejbca.get_users()] def get_client(self, name): users = self.ejbca.find_user(ejbcaws.MATCH_WITH_USERNAME, ejbcaws.MATCH_TYPE_EQUALS, name + self.username_suffix) @@ -140,35 +106,151 @@ class EjbcaRegistry(object): raise LookupError("%d users %s found (more than one?!)" % (len(users), name)) if not users: return None - return EjbcaClient(registry=self, ejbca_data=users[0]) + return Client(*self.client_data(users[0])) - def new_client(self, name, admins): - user = self.get_client(name) - if user: - raise LookupError("Client %s already exists" % name) - new_ejbca_data = dict( + def save_client(self, client): + edata = client.opaque or dict( caName=self.ca_name, certificateProfileName=self.certificate_profile_name, endEntityProfileName=self.end_entity_profile_name, keyRecoverable=False, sendNotification=False, - status=ejbcaws.STATUS_INITIALIZED, - subjectAltName="", - subjectDN="", tokenType=ejbcaws.TOKEN_TYPE_USERGENERATED, - username="", password = "".join((random.choice(string.ascii_letters + string.digits) for dummy in range(16))), - clearPwd = True + clearPwd = True, + username = client.name + self.username_suffix, + subjectDN = self.subject_dn_template % client.name ) - client = EjbcaClient(registry=self, ejbca_data=new_ejbca_data) - client.name = name - client.admins = admins - return client + edata["subjectAltName"] = ",".join(("RFC822NAME=%s" % a for a in client.admins)) + edata["status"] = self.status_str_to_ejbca.get(client.status, edata["status"]) + if client.pwd: + edata["password"] = client.pwd + edata["clearPwd"] = True + self.ejbca.edit_user(edata) + + def get_certs(self, client): + return self.ejbca.find_certs(client.opaque["username"], validOnly=False) + + def new_cert(self, client, csr, pwd): + cert = self.ejbca.pkcs10_request( + client.opaque["username"], + pwd, csr, 0, ejbcaws.RESPONSETYPE_CERTIFICATE) + return cert - def verbose_str(self): + def __str__(self): return self.ejbca.get_version() +class OpenSSLRegistry(object): + + def __init__(self, log, base_dir, + subject_dn_template, openssl_sign, lock_timeout): + self.base_dir = base_dir + self.cnf_file = pth.join(base_dir, "openssl.cnf") + self.client_dir = pth.join(base_dir, "clients") + self.serial_file = pth.join(base_dir, "serial") + self.newcerts_dir = pth.join(base_dir, "newcerts") + self.csr_dir = pth.join(base_dir, "csr") + self.lock_file = pth.join(base_dir, "lock") + self.lock_timeout = lock_timeout + self.log = log + self.subject_dn_template = subject_dn_template + self.openssl_sign = openssl_sign + + def get_clients(self): + return [self.get_client(c) for c in os.listdir(self.client_dir) if pth.isdir(pth.join(self.client_dir, c))] + + def get_client(self, name): + config = ConfigParser.RawConfigParser() + if not config.read(pth.join(self.client_dir, name, "state")): + return None + datum = dict(config.items("Client")) + return Client(name, admins=datum["admins"].split(","), status=datum["status"], pwd=datum.get("password")) + + def new_client(self, name, admins=None): + user = self.get_client(name) + if user: + raise LookupError("Client %s already exists" % name) + return Client(name, admins) + + def save_client(self, client): + config = ConfigParser.RawConfigParser() + config.add_section("Client") + config.set("Client", "admins", ",".join(client.admins)) + config.set("Client", "status", client.status) + if client.pwd: + config.set("Client", "password", client.pwd) + client_path = pth.join(self.client_dir, client.name) + try: + os.makedirs(client_path) + except OSError as e: + if e.errno != errno.EEXIST: + raise + with tempfile.NamedTemporaryFile(dir=client_path, delete=False) as cf: + config.write(cf) + os.rename(cf.name, pth.join(client_path, "state")) # atomic + rewrite, so no need for locking + + def get_certs(self, client): + files = [fname for fname in os.listdir(pth.join(self.client_dir, client.name)) if not fname.startswith(".") and fname.endswith(".pem")] + certs = [M2Crypto.X509.load_cert(pth.join(self.client_dir, client.name, fname)) for fname in files] + return certs + + def __enter__(self): + self._lockfd = os.open(self.lock_file, os.O_CREAT) + start = time.time() + while True: + try: + fcntl.flock(self._lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return + except (OSError, IOError) as e: + if e.errno != errno.EAGAIN or time.time() > start + self.lock_timeout: + raise + time.sleep(0.5) + + def __exit__(self, type_, value, traceback): + fcntl.flock(self._lockfd, fcntl.LOCK_UN) + os.close(self._lockfd) + try: + os.unlink(self.lock_file) + except: + pass + + def run_openssl(self, command, **kwargs): + cmdline = shlex.split(command % kwargs) + process = subprocess.Popen(cmdline, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + res = process.communicate() + if process.returncode: + raise PopenError("Popen returned nonzero code", process.returncode, ' '.join(cmdline), res[0], res[1]) + return res + + def new_cert(self, client, csr, pwd): + if client.status != "Issuable" or not client.pwd: + raise ClientNotIssuableError("Client not allowed to issue request or password not set") + if client.pwd != pwd: + raise AuthenticationError("Wrong credentials") + dn = self.subject_dn_template.replace("/", "//").replace(",", "/") % client.name + if not dn.startswith("/"): + dn = "/" + dn + with tempfile.NamedTemporaryFile(dir=self.csr_dir, delete=False) as csr_file: + csr_file.write(csr) + with self: # lock dance + with open(self.serial_file) as f: + serial = f.read().strip() + output = self.run_openssl(self.openssl_sign, cnf = self.cnf_file, csr = csr_file.name, dn = dn) + self.log.debug(output) + os.rename(csr_file.name, pth.join(self.csr_dir, serial + ".csr.pem")) + client_pem_name = pth.join(self.client_dir, client.name, serial + ".cert.pem") + os.symlink(pth.join(self.newcerts_dir, serial + ".pem"), client_pem_name) + with open(client_pem_name) as pem: + cert = M2Crypto.X509.load_cert_string(pem.read(), M2Crypto.X509.FORMAT_PEM) + client.update(status="Passive", pwd=None) + self.save_client(client) + return cert + + def __str__(self): + return "%s<%s>" % (type(self).__name__, self.base_dir) + + def format_cert(cert): return ( "Subject: %s\n" @@ -186,6 +268,7 @@ def format_cert(cert): cert.get_issuer().as_text() ) + # Server side class OptionalAuthenticator(ObjectBase): @@ -242,21 +325,21 @@ class CertHandler(ObjectBase): client = self.registry.get_client(name[0]) if not client: raise self.req.error(message="Unknown client", error=403, name=name, password=password) - self.log.info("Client %s" % client.name) + self.log.info("Client %s" % client) if self.req.client == "cert": # Correctly authenticated by cert, most probably not preactivated with password, # so generate oneshot password and allow now password = "".join((random.choice(string.ascii_letters + string.digits) for dummy in range(16))) self.log.debug("Authorized by X509, enabling cert generation with password %s" % password) try: - client.allow_new_cert(pwd=password) + client.update(status="Issuable", pwd=password) + self.registry.save_client(client) except ClientDisabledError as e: raise self.req.error(message="Error enabling cert generation", error=403, exc=sys.exc_info()) - client.save() if not password: raise self.req.error(message="Missing password and certificate validation failed", error=403, name=name, password=password) try: - newcert = client.new_cert(csr_data, password) + newcert = self.registry.new_cert(client, csr_data, password) except Exception as e: raise self.req.error(message="Processing error", error=403, exc=sys.exc_info()) self.log.info("Generated.") @@ -272,7 +355,7 @@ section_order = ("log", "auth", "registry", "handler", "server") section_def = { "log": [FileLogger, SysLogger], "auth": [OptionalAuthenticator], - "registry": [EjbcaRegistry], + "registry": [OpenSSLRegistry, EjbcaRegistry], "handler": [CertHandler], "server": [Server] } @@ -286,11 +369,18 @@ param_def = { "req": {"type": "obj", "default": "req"}, "log": {"type": "obj", "default": "log"} }, + OpenSSLRegistry: { + "log": {"type": "obj", "default": "log"}, + "base_dir": {"type": "str", "default": pth.join(pth.dirname(__file__), "ca")}, + "subject_dn_template": {"type": "str", "default": "DC=cz,DC=example-ca,DC=warden,CN=%s"}, + "openssl_sign": {"type": "str", "default": "openssl ca -config %(cnf)s -batch -extensions server_cert -days 375 -notext -md sha256 -in %(csr)s -subj '%(dn)s'"}, + "lock_timeout": {"type": "natural", "default": "3"} + }, EjbcaRegistry: { "log": {"type": "obj", "default": "log"}, "url": {"type": "str", "default": "https://ejbca.example.org/ejbca/ejbcaws/ejbcaws?wsdl"}, - "cert": {"type": "filepath", "default": os.path.join(os.path.dirname(__file__), "warden_ra.cert.pem")}, - "key": {"type": "filepath", "default": os.path.join(os.path.dirname(__file__), "warden_ra.key.pem")}, + "cert": {"type": "filepath", "default": pth.join(pth.dirname(__file__), "warden_ra.cert.pem")}, + "key": {"type": "filepath", "default": pth.join(pth.dirname(__file__), "warden_ra.key.pem")}, "ca_name": {"type": "str", "default": "Example CA"}, "certificate_profile_name": {"type": "str", "default": "Example"}, "end_entity_profile_name": {"type": "str", "default": "Example EE"}, @@ -304,7 +394,7 @@ param_def = { } } -param_def[FileLogger]["filename"] = {"type": "filepath", "default": os.path.join(os.path.dirname(__file__), os.path.splitext(os.path.split(__file__)[1])[0] + ".log")} +param_def[FileLogger]["filename"] = {"type": "filepath", "default": pth.join(pth.dirname(__file__), pth.splitext(pth.split(__file__)[1])[0] + ".log")} def build_server(conf): @@ -313,36 +403,33 @@ def build_server(conf): # Command line -def list_clients(registry, name=None, verbose=False): +def list_clients(registry, name=None, verbose=False, show_cert=True): if name is not None: client = registry.get_client(name) if client is None: print "No such client." return else: - print(client) - if verbose: - print(client.verbose_str()) - for cert in sorted(client.get_certs(), key=lambda c: c.get_not_after().get_datetime()): - print(format_cert(cert)) - if verbose: - print(cert.as_text()) + print(client.str(verbose)) + if show_cert: + for cert in sorted(registry.get_certs(client), key=lambda c: c.get_not_after().get_datetime()): + print(format_cert(cert)) + if verbose: + print(cert.as_text()) else: clients = registry.get_clients() - for client in sorted (clients, key=lambda c: c.name): - print(client) - if verbose: - print(client.verbose_str()) + for client in sorted(clients, key=operator.attrgetter("name")): + print(client.str(verbose)) def register_client(registry, name, admins=None, verbose=False): try: - client = registry.new_client(name, admins or []) + client = registry.new_client(name, admins) except LookupError as e: print(e) return - client.save() - list_clients(registry, name, verbose) + registry.save_client(client) + list_clients(registry, name, verbose, show_cert=False) def applicant(registry, name, password=None, verbose=False): @@ -353,12 +440,12 @@ def applicant(registry, name, password=None, verbose=False): if password is None: password = "".join((random.choice(string.ascii_letters + string.digits) for dummy in range(16))) try: - client.allow_new_cert(pwd=password) + client.update(status="Issuable", pwd=password) except ClientDisabledError: print "This client is disabled. Use 'enable' first." return - client.save() - list_clients(registry, name, verbose) + registry.save_client(client) + list_clients(registry, name, verbose, show_cert=False) print("Application password is: %s\n" % password) @@ -367,9 +454,9 @@ def enable(registry, name, verbose=False): if not client: print "No such client." return - client.enabled = True - client.save() - list_clients(registry, name, verbose) + client.update(status="Passive") + registry.save_client(client) + list_clients(registry, name, verbose, show_cert=False) def disable(registry, name, verbose=False): @@ -377,9 +464,9 @@ def disable(registry, name, verbose=False): if not client: print "No such client." return - client.enabled = False - client.save() - list_clients(registry, name, verbose) + client.update(status="Disabled") + registry.save_client(client) + list_clients(registry, name, verbose, show_cert=False) def request(registry, key, csr, verbose=False): @@ -409,7 +496,7 @@ def gen_cert(registry, name, csr, cert, password, verbose=False): with open(csr, "r") as f: csr_data = f.read() client = registry.get_client(name) - newcert = client.new_cert(csr_data, password) + newcert = registry.new_cert(client, csr_data, password) print(format_cert(newcert)) if verbose: print(newcert.as_text()) @@ -510,7 +597,7 @@ def get_args(): if __name__ == "__main__": args = get_args() - config = os.path.join(os.path.dirname(__file__), args.config or "warden_ra.cfg") + config = pth.join(pth.dirname(__file__), args.config or "warden_ra.cfg") server = build_server(read_cfg(config)) registry = server.handler.registry if args.verbose: