1
0
mirror of https://github.com/laurivosandi/certidude synced 2024-12-23 00:25:18 +00:00

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,11 +271,13 @@ def certidude_spawn(kill, no_interaction):
child_pid = os.fork() child_pid = os.fork()
if child_pid == 0: 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: with open(config.SIGNER_PID_PATH, "w") as fh:
fh.write("%d\n" % os.getpid()) fh.write("%d\n" % os.getpid())
# setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name))
logging.basicConfig( logging.basicConfig(
filename="/var/log/signer.log", filename="/var/log/signer.log",
level=logging.INFO) level=logging.INFO)
@ -140,8 +291,7 @@ def certidude_spawn(kill, no_interaction):
config.CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS, config.CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS,
config.REVOCATION_LIST_LIFETIME) config.REVOCATION_LIST_LIFETIME)
asyncore.loop() asyncore.loop()
else:
click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH))
@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,32 +31,26 @@ 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:
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")
try: try:
certificate = Certificate(open(certificate_path)) r = requests.get(authority_url)
click.echo("Found certificate: %s" % certificate_path) cert = crypto.load_certificate(crypto.FILETYPE_PEM, r.text)
except FileNotFoundError: 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: try:
request = Request(open(request_path)) request = Request(open(request_path))
click.echo("Found signing request: %s" % request_path) click.echo("Found signing request: %s" % request_path)
@ -117,42 +111,33 @@ def certidude_request_certificate(url, key_path, request_path, certificate_path,
os.rename(request_path + ".part", 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) click.echo("Submitting to %s, waiting for response..." % request_url)
try: submission = requests.post(request_url,
response = urllib.request.urlopen(submission) data=open(request_path),
buf = response.read() headers={"User-Agent": "Certidude", "Content-Type": "application/pkcs10", "Accept": "application/x-x509-user-cert"})
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") if submission.status_code == requests.codes.ok:
return 1 pass
assert buf, "Server responded with no body, status code %d" % response.code if submission.status_code == requests.codes.accepted:
cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf) # Server stored the request for processing (202 Accepted), but waiting was not requested, hence quitting for now
except crypto.Error: return
if buf == b'-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n': 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()
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 raise ValueError("Server refused to sign the request") # TODO: Raise proper exception
else:
try:
cert = crypto.load_certificate(crypto.FILETYPE_PEM, submission.text)
except crypto.Error:
raise ValueError("Failed to parse PEM: %s" % buf) 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) os.umask(0o022)
with open(certificate_path + ".part", "wb") as gh: with open(certificate_path + ".part", "w") as fh:
gh.write(buf) fh.write(submission.text)
click.echo("Writing certificate to: %s" % certificate_path) click.echo("Writing certificate to: %s" % certificate_path)
os.rename(certificate_path + ".part", certificate_path) os.rename(certificate_path + ".part", certificate_path)

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