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.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)

View File

@ -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, <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("-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)

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

View File

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