From f2df17bb889cdb70fe9690b34228345ef8a535a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lauri=20V=C3=B5sandi?= Date: Fri, 15 Jan 2016 00:47:30 +0200 Subject: [PATCH] Refactor signature request submission Certidude client now reads configuration from /etc/certidude/client.conf, submits CSR-s and once signed configures services based on /etc/certidude/services.conf --- certidude/authority.py | 11 +-- certidude/cli.py | 207 +++++++++++++++++++++++++++++++++++------ certidude/errors.py | 12 +++ certidude/helpers.py | 205 +++++++++++++++++++--------------------- requirements.txt | 2 + 5 files changed, 293 insertions(+), 144 deletions(-) create mode 100644 certidude/errors.py diff --git a/certidude/authority.py b/certidude/authority.py index 4612fea..da5c9fd 100644 --- a/certidude/authority.py +++ b/certidude/authority.py @@ -8,6 +8,7 @@ from OpenSSL import crypto from certidude import config, push from certidude.wrappers import Certificate, Request from certidude.signer import raw_sign +from certidude import errors RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" @@ -15,12 +16,6 @@ RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0 # https://jamielinux.com/docs/openssl-certificate-authority/ # http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py -class RequestExists(Exception): - pass - -class DuplicateCommonNameError(Exception): - pass - def publish_certificate(func): # TODO: Implement e-mail and nginx notifications using hooks def wrapped(csr, *args, **kwargs): @@ -68,9 +63,9 @@ def store_request(buf, overwrite=False): # If there is cert, check if it's the same if os.path.exists(request_path): if open(request_path).read() == buf: - raise RequestExists("Request already exists") + raise errors.RequestExists("Request already exists") else: - raise DuplicateCommonNameError("Another request with same common name already exists") + raise errors.DuplicateCommonNameError("Another request with same common name already exists") else: with open(request_path + ".part", "w") as fh: fh.write(buf) diff --git a/certidude/cli.py b/certidude/cli.py index eeec993..1279c71 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -9,6 +9,7 @@ import logging import os import pwd import re +import requests import signal import socket import subprocess @@ -23,6 +24,7 @@ from time import sleep from setproctitle import setproctitle from OpenSSL import crypto + env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=True) # Big fat warning: @@ -60,12 +62,162 @@ if os.getuid() >= 1000: FIRST_NAME = gecos -@click.command("spawn", help="Run privilege isolated signer process") +@click.command("request", help="Run processes for requesting certificates and configuring services") +@click.option("-f", "--fork", default=False, is_flag=True, help="Fork to background") +def certidude_spawn_request(fork): + from certidude.helpers import certidude_request_certificate + from configparser import ConfigParser + + clients = ConfigParser() + clients.readfp(open("/etc/certidude/client.conf")) + + services = ConfigParser() + services.readfp(open("/etc/certidude/services.conf")) + + # Process directories + run_dir = "/run/certidude" + + # Prepare signer PID-s directory + if not os.path.exists(run_dir): + click.echo("Creating: %s" % run_dir) + os.makedirs(run_dir) + + for certificate in clients.sections(): + if clients.get(certificate, "managed") != "true": + continue + + pid_path = os.path.join(run_dir, certificate + ".pid") + + try: + with open(pid_path) as fh: + pid = int(fh.readline()) + os.kill(pid, signal.SIGTERM) + click.echo("Terminated process %d" % pid) + os.unlink(pid_path) + except (ValueError, ProcessLookupError, FileNotFoundError): + pass + + if fork: + child_pid = os.fork() + else: + child_pid = None + + if child_pid: + click.echo("Spawned certificate request process with PID %d" % (child_pid)) + continue + + with open(pid_path, "w") as fh: + fh.write("%d\n" % os.getpid()) + setproctitle("certidude spawn request %s" % certificate) + retries = 30 + while retries > 0: + try: + certidude_request_certificate( + clients.get(certificate, "server"), + clients.get(certificate, "key_path"), + clients.get(certificate, "request_path"), + clients.get(certificate, "certificate_path"), + clients.get(certificate, "authority_path"), + socket.gethostname(), + None, + autosign=True, + wait=True) + break + except requests.exceptions.Timeout: + retries -= 1 + continue + + for endpoint in services.sections(): + if services.get(endpoint, "certificate") != certificate: + continue + + csummer = hashlib.sha1() + csummer.update(endpoint.encode("ascii")) + csum = csummer.hexdigest() + uuid = csum[:8] + "-" + csum[8:12] + "-" + csum[12:16] + "-" + csum[16:20] + "-" + csum[20:32] + + # Set up IPsec via NetworkManager + if services.get(endpoint, "service") == "network-manager/strongswan": + + config = configparser.ConfigParser() + config.add_section("connection") + config.add_section("vpn") + config.add_section("ipv4") + + config.set("connection", "id", endpoint) + config.set("connection", "uuid", uuid) + config.set("connection", "type", "vpn") + + config.set("vpn", "service-type", "org.freedesktop.NetworkManager.strongswan") + config.set("vpn", "userkey", clients.get(certificate, "key_path")) + config.set("vpn", "usercert", clients.get(certificate, "certificate_path")) + config.set("vpn", "encap", "no") + config.set("vpn", "address", services.get(endpoint, "remote")) + config.set("vpn", "virtual", "yes") + config.set("vpn", "method", "key") + config.set("vpn", "certificate", clients.get(certificate, "authority_path")) + config.set("vpn", "ipcomp", "no") + + config.set("ipv4", "method", "auto") + + # Add routes, may need some more tweaking + for index, subnet in enumerate(services.get(endpoint, "route").split(","), start=1): + config.set("ipv4", "route%d" % index, subnet) + + # Prevent creation of files with liberal permissions + os.umask(0o177) + + # Write keyfile + with open(os.path.join("/etc/NetworkManager/system-connections", endpoint), "w") as configfile: + config.write(configfile) + continue + + # Set up IPsec via /etc/ipsec.conf + if services.get(endpoint, "service") == "strongswan": + from ipsecparse import loads + config = loads(open('/etc/ipsec.conf').read()) + config["conn", endpoint] = dict( + leftsourceip="%config", + left="%defaultroute", + leftcert=clients.get(certificate, "certificate_path"), + rightid="%any", + right=services.get(endpoint, "remote"), + rightsubnet=services.get(endpoint, "route"), + keyexchange="ikev2", + keyingtries="300", + dpdaction="restart", + closeaction="restart", + auto="start") + with open("/etc/ipsec.conf.part", "w") as fh: + fh.write(config.dumps()) + os.rename("/etc/ipsec.conf.part", "/etc/ipsec.conf") + + # Regenerate /etc/ipsec.secrets + with open("/etc/ipsec.secrets.part", "w") as fh: + for filename in os.listdir("/etc/ipsec.d/private"): + if not filename.endswith(".pem"): + continue + fh.write(": RSA /etc/ipsec.d/private/%s\n" % filename) + os.rename("/etc/ipsec.secrets.part", "/etc/ipsec.secrets") + + # Attempt to reload config or start if it's not running + if os.system("ipsec update") == 130: + os.system("ipsec start") + continue + + + + # TODO: OpenVPN, Puppet, OpenLDAP, intranet HTTPS, + + os.unlink(pid_path) + + +@click.command("signer", help="Run privilege isolated signer process") @click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instance") @click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys") -def certidude_spawn(kill, no_interaction): +def certidude_spawn_signer(kill, no_interaction): """ - Spawn processes for signers + Spawn privilege isolated signer process """ from certidude import config @@ -80,7 +232,6 @@ def certidude_spawn(kill, no_interaction): # Process directories run_dir = "/run/certidude" - chroot_dir = os.path.join(run_dir, "jail") # Prepare signer PID-s directory if not os.path.exists(run_dir): @@ -92,15 +243,13 @@ def certidude_spawn(kill, no_interaction): "".encode("charmap") # Prepare chroot directories + chroot_dir = os.path.join(run_dir, "jail") if not os.path.exists(os.path.join(chroot_dir, "dev")): os.makedirs(os.path.join(chroot_dir, "dev")) if not os.path.exists(os.path.join(chroot_dir, "dev", "urandom")): # TODO: use os.mknod instead os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom")) - ca_loaded = False - - try: with open(config.SIGNER_PID_PATH) as fh: pid = int(fh.readline()) @@ -122,26 +271,27 @@ def certidude_spawn(kill, no_interaction): child_pid = os.fork() - if child_pid == 0: - with open(config.SIGNER_PID_PATH, "w") as fh: - fh.write("%d\n" % os.getpid()) - -# setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name)) - logging.basicConfig( - filename="/var/log/signer.log", - level=logging.INFO) - server = SignServer( - config.SIGNER_SOCKET_PATH, - config.AUTHORITY_PRIVATE_KEY_PATH, - config.AUTHORITY_CERTIFICATE_PATH, - config.CERTIFICATE_LIFETIME, - config.CERTIFICATE_BASIC_CONSTRAINTS, - config.CERTIFICATE_KEY_USAGE_FLAGS, - config.CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS, - config.REVOCATION_LIST_LIFETIME) - asyncore.loop() - else: + if child_pid: click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH)) + return + + setproctitle("certidude spawn signer" % section) + with open(config.SIGNER_PID_PATH, "w") as fh: + fh.write("%d\n" % os.getpid()) + logging.basicConfig( + filename="/var/log/signer.log", + level=logging.INFO) + server = SignServer( + config.SIGNER_SOCKET_PATH, + config.AUTHORITY_PRIVATE_KEY_PATH, + config.AUTHORITY_CERTIFICATE_PATH, + config.CERTIFICATE_LIFETIME, + config.CERTIFICATE_BASIC_CONSTRAINTS, + config.CERTIFICATE_KEY_USAGE_FLAGS, + config.CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS, + config.REVOCATION_LIST_LIFETIME) + asyncore.loop() + @click.command("client", help="Setup X.509 certificates for application") @@ -894,6 +1044,9 @@ def certidude_setup_openvpn(): pass @click.group("setup", help="Getting started section") def certidude_setup(): pass +@click.group("spawn", help="Spawn helper processes") +def certidude_spawn(): pass + @click.group() def entry_point(): pass @@ -907,6 +1060,8 @@ certidude_setup.add_command(certidude_setup_openvpn) certidude_setup.add_command(certidude_setup_strongswan) certidude_setup.add_command(certidude_setup_client) certidude_setup.add_command(certidude_setup_production) +certidude_spawn.add_command(certidude_spawn_request) +certidude_spawn.add_command(certidude_spawn_signer) entry_point.add_command(certidude_setup) entry_point.add_command(certidude_serve) entry_point.add_command(certidude_spawn) diff --git a/certidude/errors.py b/certidude/errors.py new file mode 100644 index 0000000..8dcc649 --- /dev/null +++ b/certidude/errors.py @@ -0,0 +1,12 @@ + +class RequestExists(Exception): + pass + +class FatalError(Exception): + """ + Exception to be raised when user intervention is required + """ + pass + +class DuplicateCommonNameError(FatalError): + pass diff --git a/certidude/helpers.py b/certidude/helpers.py index fce6ec5..5a3ad01 100644 --- a/certidude/helpers.py +++ b/certidude/helpers.py @@ -1,11 +1,13 @@ import click import os +import requests import urllib.request +from certidude import errors from certidude.wrappers import Certificate, Request from OpenSSL import crypto -def certidude_request_certificate(url, key_path, request_path, certificate_path, authority_path, common_name, org_unit, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None, ip_address=None, dns=None): +def certidude_request_certificate(url, key_path, request_path, certificate_path, authority_path, common_name, org_unit=None, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None, ip_address=None, dns=None): """ Exchange CSR for certificate using Certidude HTTP API server """ @@ -18,12 +20,10 @@ def certidude_request_certificate(url, key_path, request_path, certificate_path, request_params.add("wait=forever") # Expand ca.example.com to http://ca.example.com/api/ - if not "/" in url: + if not url.endswith("/"): url += "/api/" if "//" not in url: url = "http://" + url - if not url.endswith("/"): - url = url + "/" authority_url = url + "certificate" request_url = url + "request" @@ -31,131 +31,116 @@ def certidude_request_certificate(url, key_path, request_path, certificate_path, if request_params: request_url = request_url + "?" + "&".join(request_params) + if os.path.exists(certificate_path): + click.echo("Found certificate: %s" % certificate_path) + # TODO: Check certificate validity, download CRL? + return + if os.path.exists(authority_path): click.echo("Found CA certificate in: %s" % authority_path) else: - if authority_url: - click.echo("Attempting to fetch CA certificate from %s" % authority_url) - try: - with urllib.request.urlopen(authority_url) as fh: - buf = fh.read() - try: - cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf) - except crypto.Error: - raise ValueError("Failed to parse PEM: %s" % buf) - with open(authority_path + ".part", "wb") as oh: - oh.write(buf) - click.echo("Writing CA certificate to: %s" % authority_path) - os.rename(authority_path + ".part", authority_path) - except urllib.error.HTTPError as e: - click.echo("Failed to fetch CA certificate, server responded with: %d %s" % (e.code, e.reason), err=True) - return 1 - else: - raise FileNotFoundError("CA certificate not found and no URL specified") + click.echo("Attempting to fetch CA certificate from %s" % authority_url) + + try: + r = requests.get(authority_url) + cert = crypto.load_certificate(crypto.FILETYPE_PEM, r.text) + except crypto.Error: + raise ValueError("Failed to parse PEM: %s" % r.text) + with open(authority_path + ".part", "w") as oh: + oh.write(r.text) + click.echo("Writing CA certificate to: %s" % authority_path) + os.rename(authority_path + ".part", authority_path) try: - certificate = Certificate(open(certificate_path)) - click.echo("Found certificate: %s" % certificate_path) + request = Request(open(request_path)) + click.echo("Found signing request: %s" % request_path) except FileNotFoundError: - try: - request = Request(open(request_path)) - click.echo("Found signing request: %s" % request_path) - except FileNotFoundError: - # Construct private key - click.echo("Generating 4096-bit RSA key...") - key = crypto.PKey() - key.generate_key(crypto.TYPE_RSA, 4096) + # Construct private key + click.echo("Generating 4096-bit RSA key...") + key = crypto.PKey() + key.generate_key(crypto.TYPE_RSA, 4096) - # Dump private key - os.umask(0o077) - with open(key_path + ".part", "wb") as fh: - fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) + # Dump private key + os.umask(0o077) + with open(key_path + ".part", "wb") as fh: + fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) - # Construct CSR - csr = crypto.X509Req() - csr.set_version(2) # Corresponds to X.509v3 - csr.set_pubkey(key) - request = Request(csr) + # Construct CSR + csr = crypto.X509Req() + csr.set_version(2) # Corresponds to X.509v3 + csr.set_pubkey(key) + request = Request(csr) - # Set subject attributes - request.common_name = common_name - if given_name: - request.given_name = given_name - if surname: - request.surname = surname - if org_unit: - request.organizational_unit = org_unit + # Set subject attributes + request.common_name = common_name + if given_name: + request.given_name = given_name + if surname: + request.surname = surname + if org_unit: + request.organizational_unit = org_unit - # Collect subject alternative names - subject_alt_name = set() - if email_address: - subject_alt_name.add("email:" + email_address) - if ip_address: - subject_alt_name.add("IP:" + ip_address) - if dns: - subject_alt_name.add("DNS:" + dns) + # Collect subject alternative names + subject_alt_name = set() + if email_address: + subject_alt_name.add("email:" + email_address) + if ip_address: + subject_alt_name.add("IP:" + ip_address) + if dns: + subject_alt_name.add("DNS:" + dns) - # Set extensions - extensions = [] - if key_usage: - extensions.append(("keyUsage", key_usage, True)) - if extended_key_usage: - extensions.append(("extendedKeyUsage", extended_key_usage, True)) - if subject_alt_name: - extensions.append(("subjectAltName", ", ".join(subject_alt_name), True)) - request.set_extensions(extensions) + # Set extensions + extensions = [] + if key_usage: + extensions.append(("keyUsage", key_usage, True)) + if extended_key_usage: + extensions.append(("extendedKeyUsage", extended_key_usage, True)) + if subject_alt_name: + extensions.append(("subjectAltName", ", ".join(subject_alt_name), True)) + request.set_extensions(extensions) - # Dump CSR - os.umask(0o022) - with open(request_path + ".part", "w") as fh: - fh.write(request.dump()) + # Dump CSR + os.umask(0o022) + with open(request_path + ".part", "w") as fh: + fh.write(request.dump()) - click.echo("Writing private key to: %s" % key_path) - os.rename(key_path + ".part", key_path) - click.echo("Writing certificate signing request to: %s" % request_path) - os.rename(request_path + ".part", request_path) + click.echo("Writing private key to: %s" % key_path) + os.rename(key_path + ".part", key_path) + click.echo("Writing certificate signing request to: %s" % request_path) + os.rename(request_path + ".part", request_path) - with open(request_path, "rb") as fh: - buf = fh.read() - submission = urllib.request.Request(request_url, buf) - submission.add_header("User-Agent", "Certidude") - submission.add_header("Content-Type", "application/pkcs10") - submission.add_header("Accept", "application/x-x509-user-cert") + click.echo("Submitting to %s, waiting for response..." % request_url) + submission = requests.post(request_url, + data=open(request_path), + headers={"User-Agent": "Certidude", "Content-Type": "application/pkcs10", "Accept": "application/x-x509-user-cert"}) - click.echo("Submitting to %s, waiting for response..." % request_url) - try: - response = urllib.request.urlopen(submission) - buf = response.read() - if response.code == 202: - click.echo("No waiting was requested and server responded with 202 Accepted, run this command again once the certificate is signed") - return 1 - assert buf, "Server responded with no body, status code %d" % response.code - cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf) - except crypto.Error: - if buf == b'-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n': - raise ValueError("Server refused to sign the request") # TODO: Raise proper exception - else: - raise ValueError("Failed to parse PEM: %s" % buf) - except urllib.error.HTTPError as e: - if e.code == 409: - click.echo("Different signing request with same CN is already present on server, server refuses to overwrite", err=True) - return 2 - else: - click.echo("Failed to fetch certificate, server responded with: %d %s" % (e.code, e.reason), err=True) - return 3 - else: - if response.code == 202: - click.echo("Server stored the request for processing (202 Accepted), but waiting was not requested, hence quitting for now", err=True) - return 254 + if submission.status_code == requests.codes.ok: + pass + if submission.status_code == requests.codes.accepted: + # Server stored the request for processing (202 Accepted), but waiting was not requested, hence quitting for now + return + if submission.status_code == requests.codes.conflict: + raise errors.DuplicateCommonNameError("Different signing request with same CN is already present on server, server refuses to overwrite") + else: + submission.raise_for_status() - os.umask(0o022) - with open(certificate_path + ".part", "wb") as gh: - gh.write(buf) + if submission.text == '-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n': + # Should the client retry or disable request submission? + raise ValueError("Server refused to sign the request") # TODO: Raise proper exception - click.echo("Writing certificate to: %s" % certificate_path) - os.rename(certificate_path + ".part", certificate_path) + try: + cert = crypto.load_certificate(crypto.FILETYPE_PEM, submission.text) + except crypto.Error: + raise ValueError("Failed to parse PEM: %s" % buf) + + os.umask(0o022) + with open(certificate_path + ".part", "w") as fh: + fh.write(submission.text) + + click.echo("Writing certificate to: %s" % certificate_path) + os.rename(certificate_path + ".part", certificate_path) # TODO: Validate fetched certificate against CA # TODO: Check that recevied certificate CN and pubkey match diff --git a/requirements.txt b/requirements.txt index ac754bd..819f79b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ cryptography==1.0 falcon==0.3.0 humanize==0.5.1 idna==2.0 +ipsecparse==0.1.0 Jinja2==2.8 ldap3==0.9.8.8 MarkupSafe==0.23 @@ -14,5 +15,6 @@ pycrypto==2.6.1 pykerberos==1.1.8 pyOpenSSL==0.15.1 python-mimeparse==0.1.4 +requests==2.2.1 setproctitle==1.1.9 six==1.9.0