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
This commit is contained in:
Lauri Võsandi 2016-01-15 00:47:30 +02:00
parent d8abde3d53
commit f2df17bb88
5 changed files with 293 additions and 144 deletions

View File

@ -8,6 +8,7 @@ from OpenSSL import crypto
from certidude import config, push from certidude import config, push
from certidude.wrappers import Certificate, Request from certidude.wrappers import Certificate, Request
from certidude.signer import raw_sign 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])$" 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/ # https://jamielinux.com/docs/openssl-certificate-authority/
# http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py # http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py
class RequestExists(Exception):
pass
class DuplicateCommonNameError(Exception):
pass
def publish_certificate(func): def publish_certificate(func):
# TODO: Implement e-mail and nginx notifications using hooks # TODO: Implement e-mail and nginx notifications using hooks
def wrapped(csr, *args, **kwargs): 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 there is cert, check if it's the same
if os.path.exists(request_path): if os.path.exists(request_path):
if open(request_path).read() == buf: if open(request_path).read() == buf:
raise RequestExists("Request already exists") raise errors.RequestExists("Request already exists")
else: else:
raise DuplicateCommonNameError("Another request with same common name already exists") raise errors.DuplicateCommonNameError("Another request with same common name already exists")
else: else:
with open(request_path + ".part", "w") as fh: with open(request_path + ".part", "w") as fh:
fh.write(buf) fh.write(buf)

View File

@ -9,6 +9,7 @@ import logging
import os import os
import pwd import pwd
import re import re
import requests
import signal import signal
import socket import socket
import subprocess import subprocess
@ -23,6 +24,7 @@ from time import sleep
from setproctitle import setproctitle from setproctitle import setproctitle
from OpenSSL import crypto from OpenSSL import crypto
env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=True) env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=True)
# Big fat warning: # Big fat warning:
@ -60,12 +62,162 @@ if os.getuid() >= 1000:
FIRST_NAME = gecos 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, <insert awesomeness here>
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("-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") @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 from certidude import config
@ -80,7 +232,6 @@ def certidude_spawn(kill, no_interaction):
# Process directories # Process directories
run_dir = "/run/certidude" run_dir = "/run/certidude"
chroot_dir = os.path.join(run_dir, "jail")
# Prepare signer PID-s directory # Prepare signer PID-s directory
if not os.path.exists(run_dir): if not os.path.exists(run_dir):
@ -92,15 +243,13 @@ def certidude_spawn(kill, no_interaction):
"".encode("charmap") "".encode("charmap")
# Prepare chroot directories # Prepare chroot directories
chroot_dir = os.path.join(run_dir, "jail")
if not os.path.exists(os.path.join(chroot_dir, "dev")): if not os.path.exists(os.path.join(chroot_dir, "dev")):
os.makedirs(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")): if not os.path.exists(os.path.join(chroot_dir, "dev", "urandom")):
# TODO: use os.mknod instead # TODO: use os.mknod instead
os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom")) os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom"))
ca_loaded = False
try: try:
with open(config.SIGNER_PID_PATH) as fh: with open(config.SIGNER_PID_PATH) as fh:
pid = int(fh.readline()) pid = int(fh.readline())
@ -122,26 +271,27 @@ def certidude_spawn(kill, no_interaction):
child_pid = os.fork() child_pid = os.fork()
if child_pid == 0: if child_pid:
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:
click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH)) 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") @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") @click.group("setup", help="Getting started section")
def certidude_setup(): pass def certidude_setup(): pass
@click.group("spawn", help="Spawn helper processes")
def certidude_spawn(): pass
@click.group() @click.group()
def entry_point(): pass 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_strongswan)
certidude_setup.add_command(certidude_setup_client) certidude_setup.add_command(certidude_setup_client)
certidude_setup.add_command(certidude_setup_production) 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_setup)
entry_point.add_command(certidude_serve) entry_point.add_command(certidude_serve)
entry_point.add_command(certidude_spawn) entry_point.add_command(certidude_spawn)

12
certidude/errors.py Normal file
View File

@ -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

View File

@ -1,11 +1,13 @@
import click import click
import os import os
import requests
import urllib.request import urllib.request
from certidude import errors
from certidude.wrappers import Certificate, Request from certidude.wrappers import Certificate, Request
from OpenSSL import crypto 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 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") request_params.add("wait=forever")
# Expand ca.example.com to http://ca.example.com/api/ # Expand ca.example.com to http://ca.example.com/api/
if not "/" in url: if not url.endswith("/"):
url += "/api/" url += "/api/"
if "//" not in url: if "//" not in url:
url = "http://" + url url = "http://" + url
if not url.endswith("/"):
url = url + "/"
authority_url = url + "certificate" authority_url = url + "certificate"
request_url = url + "request" request_url = url + "request"
@ -31,131 +31,116 @@ def certidude_request_certificate(url, key_path, request_path, certificate_path,
if request_params: if request_params:
request_url = request_url + "?" + "&".join(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): if os.path.exists(authority_path):
click.echo("Found CA certificate in: %s" % authority_path) click.echo("Found CA certificate in: %s" % authority_path)
else: else:
if authority_url: click.echo("Attempting to fetch CA certificate from %s" % authority_url)
click.echo("Attempting to fetch CA certificate from %s" % authority_url)
try: try:
with urllib.request.urlopen(authority_url) as fh: r = requests.get(authority_url)
buf = fh.read() cert = crypto.load_certificate(crypto.FILETYPE_PEM, r.text)
try: except crypto.Error:
cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf) raise ValueError("Failed to parse PEM: %s" % r.text)
except crypto.Error: with open(authority_path + ".part", "w") as oh:
raise ValueError("Failed to parse PEM: %s" % buf) oh.write(r.text)
with open(authority_path + ".part", "wb") as oh: click.echo("Writing CA certificate to: %s" % authority_path)
oh.write(buf) os.rename(authority_path + ".part", authority_path)
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")
try: try:
certificate = Certificate(open(certificate_path)) request = Request(open(request_path))
click.echo("Found certificate: %s" % certificate_path) click.echo("Found signing request: %s" % request_path)
except FileNotFoundError: except FileNotFoundError:
try:
request = Request(open(request_path))
click.echo("Found signing request: %s" % request_path)
except FileNotFoundError:
# Construct private key # Construct private key
click.echo("Generating 4096-bit RSA key...") click.echo("Generating 4096-bit RSA key...")
key = crypto.PKey() key = crypto.PKey()
key.generate_key(crypto.TYPE_RSA, 4096) key.generate_key(crypto.TYPE_RSA, 4096)
# Dump private key # Dump private key
os.umask(0o077) os.umask(0o077)
with open(key_path + ".part", "wb") as fh: with open(key_path + ".part", "wb") as fh:
fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key))
# Construct CSR # Construct CSR
csr = crypto.X509Req() csr = crypto.X509Req()
csr.set_version(2) # Corresponds to X.509v3 csr.set_version(2) # Corresponds to X.509v3
csr.set_pubkey(key) csr.set_pubkey(key)
request = Request(csr) request = Request(csr)
# Set subject attributes # Set subject attributes
request.common_name = common_name request.common_name = common_name
if given_name: if given_name:
request.given_name = given_name request.given_name = given_name
if surname: if surname:
request.surname = surname request.surname = surname
if org_unit: if org_unit:
request.organizational_unit = org_unit request.organizational_unit = org_unit
# Collect subject alternative names # Collect subject alternative names
subject_alt_name = set() subject_alt_name = set()
if email_address: if email_address:
subject_alt_name.add("email:" + email_address) subject_alt_name.add("email:" + email_address)
if ip_address: if ip_address:
subject_alt_name.add("IP:" + ip_address) subject_alt_name.add("IP:" + ip_address)
if dns: if dns:
subject_alt_name.add("DNS:" + dns) subject_alt_name.add("DNS:" + dns)
# Set extensions # Set extensions
extensions = [] extensions = []
if key_usage: if key_usage:
extensions.append(("keyUsage", key_usage, True)) extensions.append(("keyUsage", key_usage, True))
if extended_key_usage: if extended_key_usage:
extensions.append(("extendedKeyUsage", extended_key_usage, True)) extensions.append(("extendedKeyUsage", extended_key_usage, True))
if subject_alt_name: if subject_alt_name:
extensions.append(("subjectAltName", ", ".join(subject_alt_name), True)) extensions.append(("subjectAltName", ", ".join(subject_alt_name), True))
request.set_extensions(extensions) request.set_extensions(extensions)
# Dump CSR # Dump CSR
os.umask(0o022) os.umask(0o022)
with open(request_path + ".part", "w") as fh: with open(request_path + ".part", "w") as fh:
fh.write(request.dump()) fh.write(request.dump())
click.echo("Writing private key to: %s" % key_path) click.echo("Writing private key to: %s" % key_path)
os.rename(key_path + ".part", key_path) os.rename(key_path + ".part", key_path)
click.echo("Writing certificate signing request to: %s" % request_path) click.echo("Writing certificate signing request to: %s" % request_path)
os.rename(request_path + ".part", request_path) os.rename(request_path + ".part", request_path)
with open(request_path, "rb") as fh: click.echo("Submitting to %s, waiting for response..." % request_url)
buf = fh.read() submission = requests.post(request_url,
submission = urllib.request.Request(request_url, buf) data=open(request_path),
submission.add_header("User-Agent", "Certidude") headers={"User-Agent": "Certidude", "Content-Type": "application/pkcs10", "Accept": "application/x-x509-user-cert"})
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) if submission.status_code == requests.codes.ok:
try: pass
response = urllib.request.urlopen(submission) if submission.status_code == requests.codes.accepted:
buf = response.read() # Server stored the request for processing (202 Accepted), but waiting was not requested, hence quitting for now
if response.code == 202: return
click.echo("No waiting was requested and server responded with 202 Accepted, run this command again once the certificate is signed") if submission.status_code == requests.codes.conflict:
return 1 raise errors.DuplicateCommonNameError("Different signing request with same CN is already present on server, server refuses to overwrite")
assert buf, "Server responded with no body, status code %d" % response.code else:
cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf) submission.raise_for_status()
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
os.umask(0o022) if submission.text == '-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n':
with open(certificate_path + ".part", "wb") as gh: # Should the client retry or disable request submission?
gh.write(buf) raise ValueError("Server refused to sign the request") # TODO: Raise proper exception
click.echo("Writing certificate to: %s" % certificate_path) try:
os.rename(certificate_path + ".part", certificate_path) 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: Validate fetched certificate against CA
# TODO: Check that recevied certificate CN and pubkey match # TODO: Check that recevied certificate CN and pubkey match

View File

@ -4,6 +4,7 @@ cryptography==1.0
falcon==0.3.0 falcon==0.3.0
humanize==0.5.1 humanize==0.5.1
idna==2.0 idna==2.0
ipsecparse==0.1.0
Jinja2==2.8 Jinja2==2.8
ldap3==0.9.8.8 ldap3==0.9.8.8
MarkupSafe==0.23 MarkupSafe==0.23
@ -14,5 +15,6 @@ pycrypto==2.6.1
pykerberos==1.1.8 pykerberos==1.1.8
pyOpenSSL==0.15.1 pyOpenSSL==0.15.1
python-mimeparse==0.1.4 python-mimeparse==0.1.4
requests==2.2.1
setproctitle==1.1.9 setproctitle==1.1.9
six==1.9.0 six==1.9.0