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

Released 0.1.17

This commit is contained in:
Lauri Võsandi 2015-08-13 11:11:08 +03:00
parent f24ef4024c
commit c5d27e8a76
19 changed files with 809 additions and 404 deletions

View File

@ -3,3 +3,8 @@ include certidude/templates/*.html
include certidude/templates/*.svg include certidude/templates/*.svg
include certidude/templates/*.ovpn include certidude/templates/*.ovpn
include certidude/templates/*.cnf include certidude/templates/*.cnf
include certidude/templates/*.conf
include certidude/templates/*.ini
include certidude/static/js/*.js
include certidude/static/css/*.css
include certidude/static/*.html

View File

@ -13,13 +13,14 @@ Features
-------- --------
* Standard request, sign, revoke workflow via web interface. * Standard request, sign, revoke workflow via web interface.
* Colored command-line interface, check out ``certidude list`` * Colored command-line interface, check out ``certidude list``.
* OpenVPN integration, check out ``certidude setup openvpn server`` and ``certidude setup openvpn client`` * OpenVPN integration, check out ``certidude setup openvpn server`` and ``certidude setup openvpn client``.
* strongSwan integration, check out ``certidude setup strongswan server`` and ``certidude setup strongswan client``.
* Privilege isolation, separate signer process is spawned per private key isolating * Privilege isolation, separate signer process is spawned per private key isolating
private key use from the the web interface. private key use from the the web interface.
* Certificate numbering obfuscation, certificate serial numbers are intentionally * Certificate numbering obfuscation, certificate serial numbers are intentionally
randomized to avoid leaking information about business practices. randomized to avoid leaking information about business practices.
* Server-side events support via for example nginx-push-stream-module * Server-side events support via for example nginx-push-stream-module.
TODO TODO
@ -27,7 +28,6 @@ TODO
* Refactor mailing subsystem and server-side events to use hooks. * Refactor mailing subsystem and server-side events to use hooks.
* Notifications via e-mail. * Notifications via e-mail.
* strongSwan setup integration.
* OCSP support. * OCSP support.
* Deep mailbox integration, eg fetch CSR-s from mailbox via IMAP. * Deep mailbox integration, eg fetch CSR-s from mailbox via IMAP.
* WebCrypto support, meanwhile check out `hwcrypto.js <https://github.com/open-eid/hwcrypto.js>`_. * WebCrypto support, meanwhile check out `hwcrypto.js <https://github.com/open-eid/hwcrypto.js>`_.
@ -42,14 +42,14 @@ To install Certidude:
.. code:: bash .. code:: bash
apt-get install python3 python3-pip python3-dev cython3 build-essential libffi-dev libssl-dev apt-get install -y python3 python3-netifaces python3-pip python3-dev cython3 build-essential libffi-dev libssl-dev
pip3 install certidude pip3 install certidude
Create a user for ``certidude``: Create a system user for ``certidude``:
.. code:: bash .. code:: bash
useradd certidude adduser --system --no-create-home --group certidude
Setting up CA Setting up CA
@ -64,6 +64,12 @@ Certidude can set up CA relatively easily:
Tweak command-line options until you meet your requirements and Tweak command-line options until you meet your requirements and
then insert generated section to your /etc/ssl/openssl.cnf then insert generated section to your /etc/ssl/openssl.cnf
Spawn the signer process:
.. code:: bash
certidude spawn
Finally serve the certificate authority via web: Finally serve the certificate authority via web:
.. code:: bash .. code:: bash
@ -102,7 +108,13 @@ Install uWSGI:
apt-get install uwsgi uwsgi-plugin-python3 apt-get install uwsgi uwsgi-plugin-python3
Configure uUWSGI application in ``/etc/uwsgi/apps-available/certidude.ini``: To set up ``nginx`` and ``uwsgi`` is suggested:
.. code:: bash
certidude setup production
Otherwise manually configure uUWSGI application in ``/etc/uwsgi/apps-available/certidude.ini``:
.. code:: ini .. code:: ini

View File

@ -191,6 +191,16 @@ class RequestListResource(CertificateAuthorityBase):
""" """
Submit certificate signing request (CSR) in PEM format Submit certificate signing request (CSR) in PEM format
""" """
# Parse remote IPv4/IPv6 address
remote_addr = ipaddress.ip_address(req.env["REMOTE_ADDR"])
# Check for CSR submission whitelist
if ca.request_whitelist:
for subnet in ca.request_whitelist:
if subnet.overlaps(remote_addr):
break
else:
raise falcon.HTTPForbidden("IP address %s not whitelisted" % remote_addr)
if req.get_header("Content-Type") != "application/pkcs10": if req.get_header("Content-Type") != "application/pkcs10":
raise falcon.HTTPUnsupportedMediaType( raise falcon.HTTPUnsupportedMediaType(
@ -207,20 +217,23 @@ class RequestListResource(CertificateAuthorityBase):
else: else:
cert = Certificate(cert_buf) cert = Certificate(cert_buf)
if cert.pubkey == csr.pubkey: if cert.pubkey == csr.pubkey:
resp.status = falcon.HTTP_FOUND resp.status = falcon.HTTP_SEE_OTHER
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name) resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name)
return return
# TODO: check for revoked certificates and return HTTP 410 Gone # TODO: check for revoked certificates and return HTTP 410 Gone
# Process automatic signing if the IP address is whitelisted and autosigning was requested # Process automatic signing if the IP address is whitelisted and autosigning was requested
if ca.autosign_allowed(req.env["REMOTE_ADDR"]) and req.get_param("autosign"): if req.get_param("autosign").lower() in ("yes", "1", "true"):
for subnet in ca.autosign_whitelist:
if subnet.overlaps(remote_addr):
try: try:
resp.append_header("Content-Type", "application/x-x509-user-cert") resp.append_header("Content-Type", "application/x-x509-user-cert")
resp.body = ca.sign(req).dump() resp.body = ca.sign(req).dump()
return return
except FileExistsError: # Certificate already exists, try to save the request except FileExistsError: # Certificate already exists, try to save the request
pass pass
break
# Attempt to save the request otherwise # Attempt to save the request otherwise
try: try:
@ -237,7 +250,7 @@ class RequestListResource(CertificateAuthorityBase):
# Redirect to nginx pub/sub # Redirect to nginx pub/sub
url = url_template % dict(channel=request.fingerprint()) url = url_template % dict(channel=request.fingerprint())
click.echo("Redirecting to: %s" % url) click.echo("Redirecting to: %s" % url)
resp.status = falcon.HTTP_FOUND resp.status = falcon.HTTP_SEE_OTHER
resp.append_header("Location", url) resp.append_header("Location", url)
else: else:
click.echo("Using dummy streaming mode, please switch to nginx in production!", err=True) click.echo("Using dummy streaming mode, please switch to nginx in production!", err=True)

View File

@ -1,33 +1,35 @@
#!/usr/bin/python3 #!/usr/bin/env python3
# coding: utf-8 # coding: utf-8
import sys import asyncore
import click
import falcon
import logging
import mimetypes
import netifaces
import os
import pwd import pwd
import random import random
import socket
import click
import os
import asyncore
import time
import os
import re import re
import logging
import signal import signal
import netifaces import socket
import urllib.request
import subprocess import subprocess
from humanize import naturaltime import sys
from ipaddress import ip_network import time
from time import sleep from certidude.helpers import expand_paths, \
from datetime import datetime certidude_request_certificate
from OpenSSL import crypto
from setproctitle import setproctitle
from certidude.signer import SignServer from certidude.signer import SignServer
from jinja2 import Environment, PackageLoader
from certidude.wrappers import CertificateAuthorityConfig, \ from certidude.wrappers import CertificateAuthorityConfig, \
CertificateAuthority, Certificate, subject2dn, Request CertificateAuthority, Certificate, subject2dn, Request
from datetime import datetime
from humanize import naturaltime
from ipaddress import ip_network
from jinja2 import Environment, PackageLoader
from time import sleep
from setproctitle import setproctitle
from OpenSSL import crypto
env = Environment(loader=PackageLoader("certidude", "templates")) env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=True)
# Big fat warning: # Big fat warning:
# m2crypto overflows around 2030 because on 32-bit systems # m2crypto overflows around 2030 because on 32-bit systems
@ -42,17 +44,20 @@ assert hasattr(crypto.X509Req(), "get_extensions"), "You're running too old vers
# http://www.mad-hacking.net/documentation/linux/security/ssl-tls/creating-ca.xml # http://www.mad-hacking.net/documentation/linux/security/ssl-tls/creating-ca.xml
# https://kjur.github.io/jsrsasign/ # https://kjur.github.io/jsrsasign/
# keyUsage, extendedKeyUsage - https://www.openssl.org/docs/apps/x509v3_config.html # keyUsage, extendedKeyUsage - https://www.openssl.org/docs/apps/x509v3_config.html
# strongSwan key paths - https://wiki.strongswan.org/projects/1/wiki/SimpleCA
config = CertificateAuthorityConfig("/etc/ssl/openssl.cnf") config = CertificateAuthorityConfig("/etc/ssl/openssl.cnf")
# Parse command-line argument defaults from environment # Parse command-line argument defaults from environment
HOSTNAME = socket.gethostname() HOSTNAME = socket.gethostname()
USERNAME = os.environ.get("USER") USERNAME = os.environ.get("USER")
EMAIL = USERNAME + "@" + HOSTNAME
NOW = datetime.utcnow().replace(tzinfo=None) NOW = datetime.utcnow().replace(tzinfo=None)
FIRST_NAME = None FIRST_NAME = None
SURNAME = None SURNAME = None
EMAIL = None
if USERNAME:
EMAIL = USERNAME + "@" + HOSTNAME
if os.getuid() >= 1000: if os.getuid() >= 1000:
_, _, _, _, gecos, _, _ = pwd.getpwnam(USERNAME) _, _, _, _, gecos, _, _ = pwd.getpwnam(USERNAME)
@ -61,33 +66,18 @@ if os.getuid() >= 1000:
else: else:
FIRST_NAME = gecos FIRST_NAME = gecos
def first_nic_address(): DEFAULT_ROUTE, PRIMARY_INTERFACE = netifaces.gateways().get("default").get(2)
""" PRIMARY_ALIASES = netifaces.ifaddresses(PRIMARY_INTERFACE).get(2)
Return IP address of the first network interface PRIMARY_ADDRESS = PRIMARY_ALIASES[0].get("addr")
"""
for interface in netifaces.interfaces():
if interface == "lo":
continue
for iftype, addresses in netifaces.ifaddresses(interface).items():
if iftype != 2:
continue
for address in addresses:
return address.pop("addr")
raise ValueError("Unable to determine IP address of first NIC")
def spawn_signers(kill, no_interaction): @click.command("spawn", help="Run privilege isolated signer processes")
@click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instances")
@click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys")
def certidude_spawn(kill, no_interaction):
""" """
Spawn processes for signers Spawn processes for signers
""" """
os.umask(0o027)
uid = os.getuid()
assert uid == 0, "Not running as root"
# Preload charmap encoding for byte_string() function of pyOpenSSL
# in order to enable chrooting
"".encode("charmap")
# Process directories # Process directories
run_dir = "/run/certidude" run_dir = "/run/certidude"
signer_dir = os.path.join(run_dir, "signer") signer_dir = os.path.join(run_dir, "signer")
@ -98,6 +88,14 @@ def spawn_signers(kill, no_interaction):
click.echo("Creating: %s" % signer_dir) click.echo("Creating: %s" % signer_dir)
os.makedirs(signer_dir) os.makedirs(signer_dir)
os.umask(0o027)
uid = os.getuid()
assert uid == 0, "Not running as root"
# Preload charmap encoding for byte_string() function of pyOpenSSL
# in order to enable chrooting
"".encode("charmap")
# Prepare chroot directories # Prepare chroot directories
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"))
@ -106,11 +104,11 @@ def spawn_signers(kill, no_interaction):
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"))
for ca in config.all_authorities(): for ca in config.all_authorities():
socket_path = os.path.join(signer_dir, ca.slug + ".sock")
pidfile = "/run/certidude/signer/%s.pid" % ca.slug pidfile_path = os.path.join(signer_dir, ca.slug + ".pid")
try: try:
with open(pidfile) as fh: with open(pidfile_path) as fh:
pid = int(fh.readline()) pid = int(fh.readline())
os.kill(pid, 0) os.kill(pid, 0)
click.echo("Found process with PID %d for %s" % (pid, ca.slug)) click.echo("Found process with PID %d for %s" % (pid, ca.slug))
@ -133,158 +131,19 @@ def spawn_signers(kill, no_interaction):
child_pid = os.fork() child_pid = os.fork()
if child_pid == 0: if child_pid == 0:
with open(pidfile, "w") as fh: with open(pidfile_path, "w") as fh:
fh.write("%d\n" % os.getpid()) fh.write("%d\n" % os.getpid())
setproctitle("%s spawn %s" % (sys.argv[0], ca.slug)) setproctitle("%s spawn %s" % (sys.argv[0], ca.slug))
logging.basicConfig( logging.basicConfig(
filename="/var/log/certidude-%s.log" % ca.slug, filename="/var/log/certidude-%s.log" % ca.slug,
level=logging.INFO) level=logging.INFO)
socket_path = os.path.join(signer_dir, ca.slug + ".sock")
click.echo("Spawned certidude signer process with PID %d at %s" % (os.getpid(), socket_path))
server = SignServer(socket_path, ca.private_key, ca.certificate.path, server = SignServer(socket_path, ca.private_key, ca.certificate.path,
ca.lifetime, ca.basic_constraints, ca.key_usage, ca.extended_key_usage) ca.certificate_lifetime, ca.basic_constraints, ca.key_usage,
ca.extended_key_usage, ca.revocation_list_lifetime)
asyncore.loop() asyncore.loop()
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):
"""
Exchange CSR for certificate using Certidude HTTP API server
"""
# Set up URL-s
request_params = set()
if autosign:
request_params.add("autosign=yes")
if wait:
request_params.add("wait=forever")
if not url.endswith("/"):
url = url + "/"
authority_url = url + "certificate"
request_url = url + "request"
if request_params:
request_url = request_url + "?" + "&".join(request_params)
if os.path.exists(authority_path):
click.echo("Found CA certificate in: %s" % authority_path)
else: else:
if authority_url: click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, socket_path))
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:
certificate = Certificate(open(certificate_path))
click.echo("Found certificate: %s" % certificate_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)
# 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_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 extensions
extensions = []
if key_usage:
extensions.append(("keyUsage", key_usage, True))
if extended_key_usage:
extensions.append(("extendedKeyUsage", extended_key_usage, True))
if email_address:
extensions.append(("subjectAltName", "email:" + email_address, False))
request.set_extensions(extensions)
# 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)
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")
click.echo("Submitting to %s, waiting for response..." % request_url)
try:
response = urllib.request.urlopen(submission)
buf = response.read()
cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf)
except crypto.Error:
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)
with open(certificate_path + ".part", "wb") as gh:
gh.write(buf)
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
# TODO: Check file permissions
@click.command("spawn", help="Run privilege isolated signer processes")
@click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instances")
@click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys")
def certidude_spawn(**args):
spawn_signers(**args)
@click.command("client", help="Setup X.509 certificates for application") @click.command("client", help="Setup X.509 certificates for application")
@ -313,9 +172,10 @@ def certidude_setup_client(quiet, **kwargs):
@click.option("--org-unit", "-ou", help="Organizational unit") @click.option("--org-unit", "-ou", help="Organizational unit")
@click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, '%s' by default" % EMAIL) @click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, '%s' by default" % EMAIL)
@click.option("--subnet", "-s", default="192.168.33.0/24", type=ip_network, help="OpenVPN subnet, 192.168.33.0/24 by default") @click.option("--subnet", "-s", default="192.168.33.0/24", type=ip_network, help="OpenVPN subnet, 192.168.33.0/24 by default")
@click.option("--local", "-l", default=first_nic_address(), help="OpenVPN listening address, %s" % first_nic_address()) @click.option("--local", "-l", default=PRIMARY_ADDRESS, help="OpenVPN listening address, %s" % PRIMARY_ADDRESS)
@click.option("--port", "-p", default=1194, type=click.IntRange(1,60000), help="OpenVPN listening port, 1194 by default") @click.option("--port", "-p", default=1194, type=click.IntRange(1,60000), help="OpenVPN listening port, 1194 by default")
@click.option('--proto', "-t", default="udp", type=click.Choice(['udp', 'tcp']), help="OpenVPN transport protocol, UDP by default") @click.option('--proto', "-t", default="udp", type=click.Choice(['udp', 'tcp']), help="OpenVPN transport protocol, UDP by default")
@click.option("--route", "-r", type=ip_network, multiple=True, help="Subnets to advertise via this connection, multiple allowed")
@click.option("--config", "-o", @click.option("--config", "-o",
default="/etc/openvpn/site-to-client.conf", default="/etc/openvpn/site-to-client.conf",
type=click.File(mode="w", atomic=True, lazy=True), type=click.File(mode="w", atomic=True, lazy=True),
@ -326,7 +186,8 @@ def certidude_setup_client(quiet, **kwargs):
@click.option("--certificate-path", "-crt", default=HOSTNAME + ".crt", help="Certificate path, %s.crt relative to --directory by default" % HOSTNAME) @click.option("--certificate-path", "-crt", default=HOSTNAME + ".crt", help="Certificate path, %s.crt relative to --directory by default" % HOSTNAME)
@click.option("--dhparam-path", "-dh", default="dhparam2048.pem", help="Diffie/Hellman parameters path, dhparam2048.pem relative to --directory by default") @click.option("--dhparam-path", "-dh", default="dhparam2048.pem", help="Diffie/Hellman parameters path, dhparam2048.pem relative to --directory by default")
@click.option("--authority-path", "-ca", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to --dir by default") @click.option("--authority-path", "-ca", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to --dir by default")
def certidude_setup_openvpn_server(url, config, subnet, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, dhparam_path, local, proto, port): @expand_paths()
def certidude_setup_openvpn_server(url, config, subnet, route, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, dhparam_path, local, proto, port):
# TODO: Intelligent way of getting last IP address in the subnet # TODO: Intelligent way of getting last IP address in the subnet
subnet_first = None subnet_first = None
subnet_last = None subnet_last = None
@ -339,16 +200,6 @@ def certidude_setup_openvpn_server(url, config, subnet, email_address, common_na
subnet_second = addr subnet_second = addr
subnet_last = addr subnet_last = addr
if directory:
if not os.path.exists(directory):
click.echo("Making directory: %s" % directory)
os.makedirs(directory)
key_path = os.path.join(directory, key_path)
certificate_path = os.path.join(directory, certificate_path)
request_path = os.path.join(directory, request_path)
authority_path = os.path.join(directory, authority_path)
dhparam_path = os.path.join(directory, dhparam_path)
if not os.path.exists(certificate_path): if not os.path.exists(certificate_path):
click.echo("As OpenVPN server certificate needs specific key usage extensions please") click.echo("As OpenVPN server certificate needs specific key usage extensions please")
click.echo("use following command to sign on Certidude server instead of web interface:") click.echo("use following command to sign on Certidude server instead of web interface:")
@ -365,7 +216,7 @@ def certidude_setup_openvpn_server(url, config, subnet, email_address, common_na
org_unit, org_unit,
email_address, email_address,
key_usage="nonRepudiation,digitalSignature,keyEncipherment", key_usage="nonRepudiation,digitalSignature,keyEncipherment",
extended_key_usage="serverAuth", extended_key_usage="serverAuth,ikeIntermediate",
wait=True) wait=True)
if not os.path.exists(dhparam_path): if not os.path.exists(dhparam_path):
@ -376,7 +227,7 @@ def certidude_setup_openvpn_server(url, config, subnet, email_address, common_na
return retval return retval
# TODO: Add dhparam # TODO: Add dhparam
config.write(env.get_template("site-to-client.ovpn").render(locals())) config.write(env.get_template("openvpn-site-to-client.ovpn").render(locals()))
click.echo("Generated %s" % config.name) click.echo("Generated %s" % config.name)
click.echo() click.echo()
@ -402,17 +253,9 @@ def certidude_setup_openvpn_server(url, config, subnet, email_address, common_na
@click.option("--request-path", "-r", default=HOSTNAME + ".csr", help="Request path, %s.csr relative to --directory by default" % HOSTNAME) @click.option("--request-path", "-r", default=HOSTNAME + ".csr", help="Request path, %s.csr relative to --directory by default" % HOSTNAME)
@click.option("--certificate-path", "-c", default=HOSTNAME + ".crt", help="Certificate path, %s.crt relative to --directory by default" % HOSTNAME) @click.option("--certificate-path", "-c", default=HOSTNAME + ".crt", help="Certificate path, %s.crt relative to --directory by default" % HOSTNAME)
@click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to --dir by default") @click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to --dir by default")
@expand_paths()
def certidude_setup_openvpn_client(url, config, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, proto, remote): def certidude_setup_openvpn_client(url, config, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, proto, remote):
if directory:
if not os.path.exists(directory):
click.echo("Making directory: %s" % directory)
os.makedirs(directory)
key_path = os.path.join(directory, key_path)
certificate_path = os.path.join(directory, certificate_path)
request_path = os.path.join(directory, request_path)
authority_path = os.path.join(directory, authority_path)
retval = certidude_request_certificate( retval = certidude_request_certificate(
url, url,
key_path, key_path,
@ -428,7 +271,7 @@ def certidude_setup_openvpn_client(url, config, email_address, common_name, org_
return retval return retval
# TODO: Add dhparam # TODO: Add dhparam
config.write(env.get_template("client-to-site.ovpn").render(locals())) config.write(env.get_template("openvpn-client-to-site.ovpn").render(locals()))
click.echo("Generated %s" % config.name) click.echo("Generated %s" % config.name)
click.echo() click.echo()
@ -438,6 +281,164 @@ def certidude_setup_openvpn_client(url, config, email_address, common_name, org_
click.echo() click.echo()
@click.command("server", help="Set up strongSwan server")
@click.argument("url")
@click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, %s by default" % HOSTNAME)
@click.option("--org-unit", "-ou", help="Organizational unit")
@click.option("--fqdn", "-f", default=HOSTNAME, help="Fully qualified hostname, %s by default" % PRIMARY_ADDRESS)
@click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, %s by default" % EMAIL)
@click.option("--subnet", "-s", default="192.168.33.0/24", type=ip_network, help="IPsec virtual subnet, 192.168.33.0/24 by default")
@click.option("--local", "-l", default=PRIMARY_ADDRESS, help="IPsec gateway address, %s" % PRIMARY_ADDRESS)
@click.option("--route", "-r", type=ip_network, multiple=True, help="Subnets to advertise via this connection, multiple allowed")
@click.option("--config", "-o",
default="/etc/ipsec.conf",
type=click.File(mode="w", atomic=True, lazy=True),
help="strongSwan configuration file, /etc/ipsec.conf by default")
@click.option("--secrets", "-s",
default="/etc/ipsec.secrets",
type=click.File(mode="w", atomic=True, lazy=True),
help="strongSwan secrets file, /etc/ipsec.secrets by default")
@click.option("--directory", "-d", default="/etc/ipsec.d", help="Directory for keys, /etc/ipsec.d by default")
@click.option("--key-path", "-key", default="private/%s.pem" % HOSTNAME, help="Key path, private/%s.pem by default" % HOSTNAME)
@click.option("--request-path", "-csr", default="reqs/%s.pem" % HOSTNAME, help="Request path, reqs/%s.pem by default" % HOSTNAME)
@click.option("--certificate-path", "-crt", default="certs/%s.pem" % HOSTNAME, help="Certificate path, certs/%s.pem by default" % HOSTNAME)
@click.option("--authority-path", "-ca", default="cacerts/ca.pem", help="Certificate authority certificate path, cacerts/ca.pem by default")
@expand_paths()
def certidude_setup_strongswan_server(url, config, secrets, subnet, route, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, local, ip_address, fqdn):
config.write(env.get_template("strongswan-site-to-client.conf").render(locals()))
if not os.path.exists(certificate_path):
click.echo("As strongSwan server certificate needs specific key usage extensions please")
click.echo("use following command to sign on Certidude server instead of web interface:")
click.echo()
click.echo(" certidude sign %s" % common_name)
retval = certidude_request_certificate(
url,
key_path,
request_path,
certificate_path,
authority_path,
common_name,
org_unit,
email_address,
key_usage="nonRepudiation,digitalSignature,keyEncipherment",
extended_key_usage="serverAuth,ikeIntermediate",
ipv4_address=None if local.is_private else local,
dns=None if local.is_private or "." not in fdqn else fdqn,
wait=True)
if retval:
return retval
click.echo("Generated %s" % config.name)
click.echo()
click.echo("Inspect newly created %s and start strongSwan service:" % config.name)
click.echo()
click.echo(" apt-get install strongswan strongswan-starter strongswan-ikev2")
click.secho(" service strongswan restart", bold=True)
click.echo()
@click.command("client", help="Set up strongSwan client")
@click.argument("url")
@click.argument("remote")
@click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, %s by default" % HOSTNAME)
@click.option("--org-unit", "-ou", help="Organizational unit")
@click.option("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, '%s' by default" % EMAIL)
@click.option("--config", "-o",
default="/etc/ipsec.conf",
type=click.File(mode="w", atomic=True, lazy=True),
help="strongSwan configuration file, /etc/ipsec.conf by default")
@click.option("--secrets", "-s",
default="/etc/ipsec.secrets",
type=click.File(mode="w", atomic=True, lazy=True),
help="strongSwan secrets file, /etc/ipsec.secrets by default")
@click.option("--dpdaction", "-d",
default="restart",
type=click.Choice(["none", "clear", "hold", "restart"]),
help="Action upon dead peer detection; either none, clear, hold or restart")
@click.option("--auto", "-a",
default="start",
type=click.Choice(["ignore", "add", "route", "start"]),
help="Operation at startup; either ignore, add, route or start")
@click.option("--directory", "-d", default="/etc/ipsec.d", help="Directory for keys, /etc/ipsec.d by default")
@click.option("--key-path", "-key", default="private/%s.pem" % HOSTNAME, help="Key path, private/%s.pem by default" % HOSTNAME)
@click.option("--request-path", "-csr", default="reqs/%s.pem" % HOSTNAME, help="Request path, reqs/%s.pem by default" % HOSTNAME)
@click.option("--certificate-path", "-crt", default="certs/%s.pem" % HOSTNAME, help="Certificate path, certs/%s.pem by default" % HOSTNAME)
@click.option("--authority-path", "-ca", default="cacerts/ca.pem", help="Certificate authority certificate path, cacerts/ca.pem by default")
@expand_paths()
def certidude_setup_strongswan_client(url, config, secrets, email_address, common_name, org_unit, directory, key_path, request_path, certificate_path, authority_path, remote, auto, dpdaction):
retval = certidude_request_certificate(
url,
key_path,
request_path,
certificate_path,
authority_path,
common_name,
org_unit,
email_address,
wait=True)
if retval:
return retval
# TODO: Add dhparam
config.write(env.get_template("strongswan-client-to-site.conf").render(locals()))
click.echo("Generated %s" % config.name)
click.echo()
click.echo("Inspect newly created %s and start strongSwan service:" % config.name)
click.echo()
click.echo(" apt-get install strongswan strongswan-starter")
click.echo(" service strongswan restart")
click.echo()
@click.command("production", help="Set up nginx and uwsgi")
@click.option("--username", default="certidude", help="Service user account, created if necessary, 'certidude' by default")
@click.option("--hostname", default=HOSTNAME, help="nginx hostname, '%s' by default" % HOSTNAME)
@click.option("--static-path", default=os.path.join(os.path.dirname(__file__), "static"), help="Static files")
@click.option("--nginx-config", "-n",
default="/etc/nginx/nginx.conf",
type=click.File(mode="w", atomic=True, lazy=True),
help="nginx configuration, /etc/nginx/nginx.conf by default")
@click.option("--uwsgi-config", "-u",
default="/etc/uwsgi/apps-available/certidude.ini",
type=click.File(mode="w", atomic=True, lazy=True),
help="uwsgi configuration, /etc/uwsgi/ by default")
@click.option("--push-server", help="Push server URL, in case of different nginx instance")
def certidude_setup_production(username, hostname, push_server, nginx_config, uwsgi_config, static_path):
try:
pwd.getpwnam(username)
click.echo("Username '%s' already exists, excellent!" % username)
except KeyError:
cmd = "adduser", "--system", "--no-create-home", "--group", username
subprocess.check_call(cmd)
# cmd = "gpasswd", "-a", username, "www-data"
# subprocess.check_call(cmd)
if not static_path.endswith("/"):
static_path += "/"
nginx_config.write(env.get_template("nginx.conf").render(locals()))
click.echo("Generated: %s" % nginx_config.name)
uwsgi_config.write(env.get_template("uwsgi.ini").render(locals()))
click.echo("Generated: %s" % uwsgi_config.name)
if os.path.exists("/etc/uwsgi/apps-enabled/certidude.ini"):
os.unlink("/etc/uwsgi/apps-enabled/certidude.ini")
os.symlink(uwsgi_config.name, "/etc/uwsgi/apps-enabled/certidude.ini")
click.echo("Symlinked %s -> /etc/uwsgi/apps-enabled/certidude.ini" % uwsgi_config.name)
if not push_server:
click.echo("Remember to install nginx with wandenberg/nginx-push-stream-module!")
@click.command("authority", help="Set up Certificate Authority in a directory") @click.command("authority", help="Set up Certificate Authority in a directory")
@click.option("--group", "-g", default="certidude", help="Group for file permissions, certidude by default") @click.option("--group", "-g", default="certidude", help="Group for file permissions, certidude by default")
@click.option("--parent", "-p", help="Parent CA, none by default") @click.option("--parent", "-p", help="Parent CA, none by default")
@ -445,10 +446,11 @@ def certidude_setup_openvpn_client(url, config, email_address, common_name, org_
@click.option("--country", "-c", default="ee", help="Country, Estonia by default") @click.option("--country", "-c", default="ee", help="Country, Estonia by default")
@click.option("--state", "-s", default="Harjumaa", help="State or country, Harjumaa by default") @click.option("--state", "-s", default="Harjumaa", help="State or country, Harjumaa by default")
@click.option("--locality", "-l", default="Tallinn", help="City or locality, Tallinn by default") @click.option("--locality", "-l", default="Tallinn", help="City or locality, Tallinn by default")
@click.option("--lifetime", default=20*365, help="Lifetime in days, 7300 days (20 years) by default") @click.option("--authority-lifetime", default=20*365, help="Authority certificate lifetime in days, 7300 days (20 years) by default")
@click.option("--certificate-lifetime", default=5*365, help="Certificate lifetime in days, 1825 days (5 years) by default")
@click.option("--revocation-list-lifetime", default=1, help="Revocation list lifetime in days, 1 day by default")
@click.option("--organization", "-o", default="Example LLC", help="Company or organization name") @click.option("--organization", "-o", default="Example LLC", help="Company or organization name")
@click.option("--organizational-unit", "-ou", default="Certification Department") @click.option("--organizational-unit", "-ou", default="Certification Department")
@click.option("--crl-age", default=1, help="CRL expiration age, 1 day by default")
@click.option("--pkcs11", default=False, is_flag=True, help="Use PKCS#11 token instead of files") @click.option("--pkcs11", default=False, is_flag=True, help="Use PKCS#11 token instead of files")
@click.option("--crl-distribution-url", default=None, help="CRL distribution URL") @click.option("--crl-distribution-url", default=None, help="CRL distribution URL")
@click.option("--ocsp-responder-url", default=None, help="OCSP responder URL") @click.option("--ocsp-responder-url", default=None, help="OCSP responder URL")
@ -456,7 +458,7 @@ def certidude_setup_openvpn_client(url, config, email_address, common_name, org_
@click.option("--inbox", default="imap://user:pass@host:port/INBOX", help="Inbound e-mail server") @click.option("--inbox", default="imap://user:pass@host:port/INBOX", help="Inbound e-mail server")
@click.option("--outbox", default="smtp://localhost", help="Outbound e-mail server") @click.option("--outbox", default="smtp://localhost", help="Outbound e-mail server")
@click.argument("directory") @click.argument("directory")
def certidude_setup_authority(parent, country, state, locality, organization, organizational_unit, common_name, directory, crl_age, lifetime, pkcs11, group, crl_distribution_url, ocsp_responder_url, email_address, inbox, outbox): def certidude_setup_authority(parent, country, state, locality, organization, organizational_unit, common_name, directory, certificate_lifetime, authority_lifetime, revocation_list_lifetime, pkcs11, group, crl_distribution_url, ocsp_responder_url, email_address, inbox, outbox):
logging.info("Creating certificate authority in %s", directory) logging.info("Creating certificate authority in %s", directory)
_, _, uid, gid, gecos, root, shell = pwd.getpwnam(group) _, _, uid, gid, gecos, root, shell = pwd.getpwnam(group)
os.setgid(gid) os.setgid(gid)
@ -481,7 +483,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
crl_distribution_points = "URI:%s" % crl_distribution_url crl_distribution_points = "URI:%s" % crl_distribution_url
ca = crypto.X509() ca = crypto.X509()
#ca.set_version(3) # breaks gcr-viewer?! ca.set_version(2) # This corresponds to X.509v3
ca.set_serial_number(1) ca.set_serial_number(1)
ca.get_subject().CN = common_name ca.get_subject().CN = common_name
ca.get_subject().C = country ca.get_subject().C = country
@ -490,7 +492,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
ca.get_subject().O = organization ca.get_subject().O = organization
ca.get_subject().OU = organizational_unit ca.get_subject().OU = organizational_unit
ca.gmtime_adj_notBefore(0) ca.gmtime_adj_notBefore(0)
ca.gmtime_adj_notAfter(lifetime * 24 * 60 * 60) ca.gmtime_adj_notAfter(authority_lifetime * 24 * 60 * 60)
ca.set_issuer(ca.get_subject()) ca.set_issuer(ca.get_subject())
ca.set_pubkey(key) ca.set_pubkey(key)
ca.add_extensions([ ca.add_extensions([
@ -522,7 +524,10 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
subject_alt_name.encode("ascii")) subject_alt_name.encode("ascii"))
]) ])
if not ocsp_responder_url: if ocsp_responder_url:
raise NotImplementedError()
"""
ocsp_responder_url = "http://%s/api/%s/ocsp/" % (common_name, slug) ocsp_responder_url = "http://%s/api/%s/ocsp/" % (common_name, slug)
authority_info_access = "OCSP;URI:%s" % ocsp_responder_url authority_info_access = "OCSP;URI:%s" % ocsp_responder_url
ca.add_extensions([ ca.add_extensions([
@ -531,6 +536,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
False, False,
authority_info_access.encode("ascii")) authority_info_access.encode("ascii"))
]) ])
"""
click.echo("Signing %s..." % subject2dn(ca.get_subject())) click.echo("Signing %s..." % subject2dn(ca.get_subject()))
@ -550,7 +556,7 @@ def certidude_setup_authority(parent, country, state, locality, organization, or
os.mkdir(os.path.join(directory, subdir)) os.mkdir(os.path.join(directory, subdir))
with open(ca_crl, "wb") as fh: with open(ca_crl, "wb") as fh:
crl = crypto.CRL() crl = crypto.CRL()
fh.write(crl.export(ca, key, days=crl_age)) fh.write(crl.export(ca, key, days=revocation_list_lifetime))
with open(os.path.join(directory, "serial"), "w") as fh: with open(os.path.join(directory, "serial"), "w") as fh:
fh.write("1") fh.write("1")
@ -730,12 +736,35 @@ def certidude_sign(common_name, overwrite, lifetime):
else: else:
# Sign directly using private key # Sign directly using private key
cert = ca.sign2(request, overwrite, True, lifetime) cert = ca.sign2(request, overwrite, True, lifetime)
os.unlink(request.path)
click.echo("Signed %s" % cert.distinguished_name) click.echo("Signed %s" % cert.distinguished_name)
for key, value, data in cert.extensions: for key, value, data in cert.extensions:
click.echo("Added extension %s: %s" % (key, value)) click.echo("Added extension %s: %s" % (key, value))
click.echo() click.echo()
class StaticResource(object):
def __init__(self, root):
self.root = os.path.realpath(root)
click.echo("Serving static from: %s" % self.root)
def __call__(self, req, resp):
path = os.path.realpath(os.path.join(self.root, req.path[1:]))
if not path.startswith(self.root):
raise falcon.HTTPForbidden
print("Serving:", path)
if os.path.exists(path):
content_type, content_encoding = mimetypes.guess_type(path)
if content_type:
resp.append_header("Content-Type", content_type)
if content_encoding:
resp.append_header("Content-Encoding", content_encoding)
resp.append_header("Content-Disposition", "attachment")
resp.stream = open(path, "rb")
else:
resp.status = falcon.HTTP_404
resp.body = "File '%s' not found" % req.path
@click.command("serve", help="Run built-in HTTP server") @click.command("serve", help="Run built-in HTTP server")
@click.option("-u", "--user", default="certidude", help="Run as user") @click.option("-u", "--user", default="certidude", help="Run as user")
@ -743,7 +772,6 @@ def certidude_sign(common_name, overwrite, lifetime):
@click.option("-l", "--listen", default="0.0.0.0", help="Listen address") @click.option("-l", "--listen", default="0.0.0.0", help="Listen address")
@click.option("-s", "--enable-signature", default=False, is_flag=True, help="Allow signing operations with private key of CA") @click.option("-s", "--enable-signature", default=False, is_flag=True, help="Allow signing operations with private key of CA")
def certidude_serve(user, port, listen, enable_signature): def certidude_serve(user, port, listen, enable_signature):
spawn_signers(kill=False, no_interaction=False)
logging.basicConfig( logging.basicConfig(
filename='/var/log/certidude.log', filename='/var/log/certidude.log',
@ -775,6 +803,8 @@ def certidude_serve(user, port, listen, enable_signature):
app.add_route("/api/{ca}/request/{cn}/", RequestDetailResource(config)) app.add_route("/api/{ca}/request/{cn}/", RequestDetailResource(config))
app.add_route("/api/{ca}/request/", RequestListResource(config)) app.add_route("/api/{ca}/request/", RequestListResource(config))
app.add_route("/api/{ca}/", IndexResource(config)) app.add_route("/api/{ca}/", IndexResource(config))
app.add_sink(StaticResource(os.path.join(os.path.dirname(__file__), "static")))
httpd = make_server(listen, port, app, ThreadingWSGIServer) httpd = make_server(listen, port, app, ThreadingWSGIServer)
if user: if user:
_, _, uid, gid, gecos, root, shell = pwd.getpwnam(user) _, _, uid, gid, gecos, root, shell = pwd.getpwnam(user)
@ -789,6 +819,9 @@ def certidude_serve(user, port, listen, enable_signature):
click.echo("Warning: running as root, this is not reccommended!") click.echo("Warning: running as root, this is not reccommended!")
httpd.serve_forever() httpd.serve_forever()
@click.group("strongswan", help="strongSwan helpers")
def certidude_setup_strongswan(): pass
@click.group("openvpn", help="OpenVPN helpers") @click.group("openvpn", help="OpenVPN helpers")
def certidude_setup_openvpn(): pass def certidude_setup_openvpn(): pass
@ -798,11 +831,15 @@ def certidude_setup(): pass
@click.group() @click.group()
def entry_point(): pass def entry_point(): pass
certidude_setup_strongswan.add_command(certidude_setup_strongswan_server)
certidude_setup_strongswan.add_command(certidude_setup_strongswan_client)
certidude_setup_openvpn.add_command(certidude_setup_openvpn_server) certidude_setup_openvpn.add_command(certidude_setup_openvpn_server)
certidude_setup_openvpn.add_command(certidude_setup_openvpn_client) certidude_setup_openvpn.add_command(certidude_setup_openvpn_client)
certidude_setup.add_command(certidude_setup_authority) certidude_setup.add_command(certidude_setup_authority)
certidude_setup.add_command(certidude_setup_openvpn) 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_client)
certidude_setup.add_command(certidude_setup_production)
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)

183
certidude/helpers.py Normal file
View File

@ -0,0 +1,183 @@
import click
import logging
import netifaces
import os
import urllib.request
from certidude.wrappers import Certificate, Request
from certidude.signer import SignServer
from OpenSSL import crypto
def expand_paths():
"""
Prefix '..._path' keyword arguments of target function with 'directory' keyword argument
and create the directory if necessary
TODO: Move to separate file
"""
def wrapper(func):
def wrapped(**arguments):
d = arguments.get("directory")
for key, value in arguments.items():
if key.endswith("_path"):
if d:
value = os.path.join(d, value)
value = os.path.realpath(value)
parent = os.path.dirname(value)
if not os.path.exists(parent):
click.echo("Making directory %s for %s" % (repr(parent), repr(key)))
os.makedirs(parent)
elif not os.path.isdir(parent):
raise Exception("Path %s is not directory!" % parent)
arguments[key] = value
return func(**arguments)
return wrapped
return wrapper
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):
"""
Exchange CSR for certificate using Certidude HTTP API server
"""
# Set up URL-s
request_params = set()
if autosign:
request_params.add("autosign=yes")
if wait:
request_params.add("wait=forever")
if not url.endswith("/"):
url = url + "/"
authority_url = url + "certificate"
request_url = url + "request"
if request_params:
request_url = request_url + "?" + "&".join(request_params)
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")
try:
certificate = Certificate(open(certificate_path))
click.echo("Found certificate: %s" % certificate_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)
# 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)
# 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)
# 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())
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")
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:
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)
with open(certificate_path + ".part", "wb") as gh:
gh.write(buf)
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
# TODO: Check file permissions

View File

@ -33,7 +33,9 @@ def raw_sign(private_key, ca_cert, request, basic_constraints, lifetime, key_usa
Sign certificate signing request directly with private key assuming it's readable by the process Sign certificate signing request directly with private key assuming it's readable by the process
""" """
# Initialize X.509 certificate object
cert = crypto.X509() cert = crypto.X509()
ca.set_version(2) # This corresponds to X.509v3
# Set public key # Set public key
cert.set_pubkey(request.get_pubkey()) cert.set_pubkey(request.get_pubkey())
@ -130,7 +132,8 @@ class SignHandler(asynchat.async_chat):
self.send(crl.export( self.send(crl.export(
self.server.certificate, self.server.certificate,
self.server.private_key, self.server.private_key,
crypto.FILETYPE_PEM)) crypto.FILETYPE_PEM,
self.server.revocation_list_lifetime))
elif cmd == "ocsp-request": elif cmd == "ocsp-request":
NotImplemented # TODO: Implement OCSP NotImplemented # TODO: Implement OCSP
@ -168,7 +171,7 @@ class SignHandler(asynchat.async_chat):
class SignServer(asyncore.dispatcher): class SignServer(asyncore.dispatcher):
def __init__(self, socket_path, private_key, certificate, lifetime, basic_constraints, key_usage, extended_key_usage): def __init__(self, socket_path, private_key, certificate, lifetime, basic_constraints, key_usage, extended_key_usage, revocation_list_lifetime):
asyncore.dispatcher.__init__(self) asyncore.dispatcher.__init__(self)
# Bind to sockets # Bind to sockets
@ -183,6 +186,7 @@ class SignServer(asyncore.dispatcher):
self.private_key = crypto.load_privatekey(crypto.FILETYPE_PEM, open(private_key).read()) self.private_key = crypto.load_privatekey(crypto.FILETYPE_PEM, open(private_key).read())
self.certificate = crypto.load_certificate(crypto.FILETYPE_PEM, open(certificate).read()) self.certificate = crypto.load_certificate(crypto.FILETYPE_PEM, open(certificate).read())
self.lifetime = lifetime self.lifetime = lifetime
self.revocation_list_lifetime = revocation_list_lifetime
self.basic_constraints = basic_constraints self.basic_constraints = basic_constraints
self.key_usage = key_usage self.key_usage = key_usage
self.extended_key_usage = extended_key_usage self.extended_key_usage = extended_key_usage

View File

@ -0,0 +1,121 @@
svg {
position: relative;
top: 0.5em;
}
img {
max-width: 100%;
max-height: 100%;
}
ul {
list-style: none;
margin: 1em 0;
padding: 0;
}
button, .button {
color: #000;
float: right;
border: 1pt solid #ccc;
background-color: #eee;
border-radius: 6px;
margin: 2px;
padding: 4px 8px;
box-sizing: border-box;
}
button:disabled, .button:disabled {
color: #888;
}
.monospace {
font-family: 'Ubuntu Mono', courier, monospace;
}
footer {
display: block;
color: #fff;
text-align: center;
}
a {
text-decoration: none;
color: #44c;
}
footer a {
color: #aaf;
}
html,body {
margin: 0;
padding: 0 0 1em 0;
}
body {
background: #222;
background-image: url('//fc00.deviantart.net/fs71/i/2013/078/9/6/free_hexa_pattern_cc0_by_black_light_studio-d4ig12f.png');
background-position: center;
}
.comment {
color: #aaf;
}
table th, table td {
border: 1px solid #ccc;
padding: 2px;
}
h1, h2, th {
font-family: 'Gentium';
}
h1 {
text-align: center;
font-size: 22pt;
}
h2 {
font-size: 18pt;
}
h2 svg {
position: relative;
top: 16px;
}
p, td, footer, li, button {
font-family: 'PT Sans Narrow';
font-size: 14pt;
}
pre {
overflow: auto;
border: 1px solid #000;
background: #444;
color: #fff;
font-size: 12pt;
padding: 4px;
border-radius: 6px;
margin: 0 0;
}
#container {
max-width: 60em;
margin: 1em auto;
background: #fff;
padding: 1em;
border-style: solid;
border-width: 2px;
border-color: #aaa;
border-radius: 10px;
}
li {
margin: 4px 0;
padding: 4px 0;
clear: both;
border-top: 1px dashed #ccc;
}

File diff suppressed because one or more lines are too long

View File

@ -1,162 +1,55 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<link href='http://fonts.googleapis.com/css?family=Ubuntu+Mono' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Gentium' rel='stylesheet' type='text/css'>
<link href="//fonts.googleapis.com/css?family=PT+Sans+Narrow" rel="stylesheet" type="text/css">
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title>Certidude server</title> <title>Certidude server</title>
<style type="text/css"> <link href="/css/style.css" rel="stylesheet" type="text/css"/>
svg { <link href="//fonts.googleapis.com/css?family=Ubuntu+Mono" rel="stylesheet" type="text/css"/>
position: relative; <link href="//fonts.googleapis.com/css?family=Gentium" rel="stylesheet" type="text/css"/>
top: 0.5em; <link href="//fonts.googleapis.com/css?family=PT+Sans+Narrow" rel="stylesheet" type="text/css"/>
} <script type="text/javascript" src="/js/jquery-2.1.4.min.js"></script>
img {
max-width: 100%;
max-height: 100%;
}
ul {
list-style: none;
margin: 1em 0;
padding: 0;
}
button, .button {
color: #000;
float: right;
border: 1pt solid #ccc;
background-color: #eee;
border-radius: 6px;
margin: 2px;
padding: 4px 8px;
box-sizing: border-box;
}
button:disabled, .button:disabled {
color: #888;
}
.monospace {
font-family: 'Ubuntu Mono', courier, monospace;
}
footer {
display: block;
color: #fff;
text-align: center;
}
a {
text-decoration: none;
color: #44c;
}
footer a {
color: #aaf;
}
html,body {
margin: 0;
padding: 0 0 1em 0;
}
body {
background: #222;
background-image: url('http://fc00.deviantart.net/fs71/i/2013/078/9/6/free_hexa_pattern_cc0_by_black_light_studio-d4ig12f.png');
background-position: center;
}
.comment {
color: #aaf;
}
table th, table td {
border: 1px solid #ccc;
padding: 2px;
}
h1, h2, th {
font-family: 'Gentium';
}
h1 {
text-align: center;
font-size: 22pt;
}
h2 {
font-size: 18pt;
}
h2 svg {
position: relative;
top: 16px;
}
p, td, footer, li, button {
font-family: 'PT Sans Narrow';
font-size: 14pt;
}
pre {
overflow: auto;
border: 1px solid #000;
background: #444;
color: #fff;
font-size: 12pt;
padding: 4px;
border-radius: 6px;
margin: 0 0;
}
#container {
max-width: 60em;
margin: 1em auto;
background: #fff;
padding: 1em;
border-style: solid;
border-width: 2px;
border-color: #aaa;
border-radius: 10px;
}
li {
margin: 4px 0;
padding: 4px 0;
clear: both;
border-top: 1px dashed #ccc;
}
</style>
</head> </head>
<body> <body>
<div id="container"> <div id="container">
<h1>Submit signing request</h1> <h1>Submit signing request</h1>
<p>Request submission is allowed from: {% for i in authority.request_whitelist %}{{ i }} {% endfor %}</p>
<p>Autosign is allowed from: {% for i in authority.autosign_whitelist %}{{ i }} {% endfor %}</p>
<h2>IPsec gateway on OpenWrt</h2>
{% set s = authority.certificate.subject %} {% set s = authority.certificate.subject %}
<!--
<p>To submit new certificate signing request first set common name, eg:</p>
<pre> <pre>
export CN=$(hostname) opkg update
opkg install strongswan-default curl openssl-util
modprobe authenc
</pre> </pre>
<p>Generate key and submit using standard shell tools:</p> <p>Generate key and submit using standard shell tools:</p>
<pre> <pre>
curl {{request.url}}/certificate/ > ca.crt CN=$(cat /proc/sys/kernel/hostname)
openssl genrsa -out $CN.key 4096 curl {{request.url}}/certificate/ > /etc/ipsec.d/cacerts/ca.pem
openssl req -new -sha256 -key $CN.key -out $CN.csr -subj "{% if s.C %}/C={{s.C}}{% endif %}{% if s.ST %}/ST={{s.ST}}{% endif %}{% if s.L %}/L={{s.L}}{% endif %}{% if s.O %}/O={{s.O}}{% endif %}{% if s.OU %}/OU={{s.OU}}{% endif %}/CN=$CN" openssl genrsa -out /etc/ipsec.d/private/$CN.pem 4096
wget --header "Content-Type: application/pkcs10" --post-data="$(cat $CN.csr)" http://localhost:9090/api/buujaa/request/?autosign=1\&wait=30 -O $CN.crt chmod 0600 /etc/ipsec.d/private/$CN.pem
openssl verify -CAfile ca.crt $CN.crt openssl req -new -sha256 -key /etc/ipsec.d/private/$CN.pem -out /etc/ipsec.d/reqs/$CN.pem -subj "{% if s.C %}/C={{s.C}}{% endif %}{% if s.ST %}/ST={{s.ST}}{% endif %}{% if s.L %}/L={{s.L}}{% endif %}{% if s.O %}/O={{s.O}}{% endif %}{% if s.OU %}/OU={{s.OU}}{% endif %}/CN=$CN"
curl -L -H "Content-Type: application/pkcs10" --data-binary @/etc/ipsec.d/reqs/$CN.pem {{request.uri}}/request/?autosign=1\&wait=30 > /etc/ipsec.d/certs/$CN.pem.part
if [ $? -eq 0 ]; then mv /etc/ipsec.d/certs/$CN.pem.part /etc/ipsec.d/certs/$CN.pem; fi
openssl verify -CAfile /etc/ipsec.d/cacerts/ca.pem /etc/ipsec.d/certs/$CN.pem
</pre>
<p>
Inspect newly created files:
</p>
<pre>
openssl x509 -text -noout -in /etc/ipsec.d/cacerts/ca.pem
openssl x509 -text -noout -in /etc/ipsec.d/certs/$CN.pem
openssl rsa -check -in /etc/ipsec.d/private/$CN.pem
</pre> </pre>
-->
<p>Assuming you have Certidude installed</p> <p>Assuming you have Certidude installed</p>

View File

@ -0,0 +1,51 @@
user www-data;
worker_processes 4;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
{% if not push_server %}
push_stream_shared_memory_size 32M;
{% endif %}
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
upstream certidude_api {
server unix:///run/uwsgi/app/certidude/socket;
}
server {
server_name {{hostname}};
listen 80 default_server;
listen [::]:80 default_server ipv6only=on;
error_page 500 502 503 504 /50x.html;
root {{static_path}};
location /api/ {
include uwsgi_params;
uwsgi_pass certidude_api;
}
{% if not push_server %}
location ~ /publish/(.*) {
allow 127.0.0.1;
push_stream_publisher admin;
push_stream_channels_path $1;
}
location ~ /subscribe/(.*) {
push_stream_channels_path $1;
push_stream_subscriber long-polling;
}
{% endif %}
}
}

View File

@ -1,5 +1,6 @@
[CA_{{slug}}] [CA_{{slug}}]
default_days = 1825 default_crl_days = {{revocation_list_lifetime}}
default_days = {{certificate_lifetime}}
dir = {{directory}} dir = {{directory}}
private_key = $dir/ca_key.pem private_key = $dir/ca_key.pem
certificate = $dir/ca_crt.pem certificate = $dir/ca_crt.pem
@ -9,12 +10,15 @@ certs = $dir/signed/
crl = $dir/ca_crl.pem crl = $dir/ca_crl.pem
serial = $dir/serial serial = $dir/serial
{% if crl_distribution_points %} {% if crl_distribution_points %}
crlDistributionPoints = {{crl_distribution_points}}{% endif %} crlDistributionPoints = {{crl_distribution_points}}
{% endif %}
{% if email_address %} {% if email_address %}
emailAddress = {{email_address}}{% endif %} emailAddress = {{email_address}}
{% endif %}
x509_extensions = {{slug}}_cert x509_extensions = {{slug}}_cert
policy = poliy_{{slug}} policy = poliy_{{slug}}
autosign_whitelist = 127. request_whitelist =
autosign_whitelist = 127.0.0.0/8
inbox = {{inbox}} inbox = {{inbox}}
outbox = {{outbox}} outbox = {{outbox}}

View File

@ -14,4 +14,6 @@ group nogroup
ifconfig-pool-persist /tmp/openvpn-leases.txt ifconfig-pool-persist /tmp/openvpn-leases.txt
ifconfig {{subnet_first}} {{subnet.netmask}} ifconfig {{subnet_first}} {{subnet.netmask}}
server-bridge {{subnet_first}} {{subnet.netmask}} {{subnet_second}} {{subnet_last}} server-bridge {{subnet_first}} {{subnet.netmask}} {{subnet_second}} {{subnet_last}}
{% for subnet in route %}
push "route {{subnet}}"
{% endfor %}

View File

@ -0,0 +1,27 @@
# /etc/ipsec.conf - strongSwan IPsec configuration file
# left/local = client
# right/remote = gateway
config setup
conn %default
ikelifetime=60m
keylife=20m
rekeymargin=3m
keyingtries=1
keyexchange=ikev2
dpdaction={{dpdaction}}
conn home
auto={{auto}}
type=tunnel
left=%defaultroute # Use IP of default route for listening
leftcert={{certificate_path}} # Client certificate
leftid={{common_name}} # Client certificate identifier
leftfirewall=yes
right={{remote}} # Gateway IP address
rightid=%any # Allow any common name
rightsubnet=0.0.0.0/0 # Accept all subnets suggested by server
#rightcert=server.pem

View File

@ -0,0 +1,28 @@
# /etc/ipsec.conf - strongSwan IPsec configuration file
# left/local = gateway
# right/remote = client
config setup
conn %default
ikelifetime=60m
keylife=20m
rekeymargin=3m
keyingtries=1
keyexchange=ikev2
conn rw
auto=add
right=%any # Allow connecting from any IP address
left={{local}} # Gateway IP address
leftcert={{certificate_path}} # Gateway certificate
leftfirewall=yes
{% if route %}
{% if route | length == 1 %}
leftsubnet={{route[0]}} # Advertise routes via this connection
{% else %}
leftsubnet={ {{ route | join(', ') }} }
{% endif %}
{% endif %}

View File

@ -0,0 +1,23 @@
[uwsgi]
exec-as-root = /usr/local/bin/certidude spawn
master = true
processes = 1
vacuum = true
uid = {{username}}
gid = {{username}}
plugins = python34
chdir = /tmp
module = certidude.wsgi
callable = app
chmod-socket = 660
chown-socket = {{username}}:www-data
{% if push_server %}
env = CERTIDUDE_EVENT_PUBLISH={{push_server}}/publish/%(channel)s
env = CERTIDUDE_EVENT_SUBSCRIBE={{push_server}}/subscribe/%(channel)s
{% else %}
env = CERTIDUDE_EVENT_PUBLISH=http://localhost/event/publish/%(channel)s
env = CERTIDUDE_EVENT_SUBSCRIBE=http://localhost/event/subscribe/%(channel)s
{% endif %}
env = LANG=C.UTF-8
env = LC_ALL=C.UTF-8

View File

@ -7,6 +7,7 @@ import click
import socket import socket
import io import io
import urllib.request import urllib.request
import ipaddress
from configparser import RawConfigParser from configparser import RawConfigParser
from Crypto.Util import asn1 from Crypto.Util import asn1
from OpenSSL import crypto from OpenSSL import crypto
@ -78,7 +79,7 @@ class CertificateAuthorityConfig(object):
section = "CA_" + slug section = "CA_" + slug
dirs = dict([(key, self.get(section, key)) dirs = dict([(key, self.get(section, key))
for key in ("dir", "certificate", "crl", "certs", "new_certs_dir", "private_key", "revoked_certs_dir", "autosign_whitelist")]) for key in ("dir", "certificate", "crl", "certs", "new_certs_dir", "private_key", "revoked_certs_dir", "request_whitelist", "autosign_whitelist")])
# Variable expansion, eg $dir # Variable expansion, eg $dir
for key, value in dirs.items(): for key, value in dirs.items():
@ -89,7 +90,8 @@ class CertificateAuthorityConfig(object):
dirs["email_address"] = self.get(section, "emailAddress") dirs["email_address"] = self.get(section, "emailAddress")
dirs["inbox"] = self.get(section, "inbox") dirs["inbox"] = self.get(section, "inbox")
dirs["outbox"] = self.get(section, "outbox") dirs["outbox"] = self.get(section, "outbox")
dirs["lifetime"] = int(self.get(section, "default_days", "1825")) dirs["certificate_lifetime"] = int(self.get(section, "default_days", "1825"))
dirs["revocation_list_lifetime"] = int(self.get(section, "default_crl_days", "1"))
extensions_section = self.get(section, "x509_extensions") extensions_section = self.get(section, "x509_extensions")
if extensions_section: if extensions_section:
@ -296,7 +298,7 @@ class Request(CertificateBase):
else: else:
raise ValueError("Can't parse %s as X.509 certificate signing request!" % mixed) raise ValueError("Can't parse %s as X.509 certificate signing request!" % mixed)
assert not self.buf or self.buf == self.dump(), "%s is not %s" % (self.buf, self.dump()) assert not self.buf or self.buf == self.dump(), "%s is not %s" % (repr(self.buf), repr(self.dump()))
@property @property
def signable(self): def signable(self):
@ -390,29 +392,25 @@ class Certificate(CertificateBase):
class CertificateAuthority(object): class CertificateAuthority(object):
def __init__(self, slug, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, private_key=None, autosign=False, autosign_whitelist=None, email_address=None, inbox=None, outbox=None, basic_constraints="CA:FALSE", key_usage="digitalSignature,keyEncipherment", extended_key_usage="clientAuth", lifetime=5*365): def __init__(self, slug, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, private_key=None, autosign=False, autosign_whitelist=None, request_whitelist=None, email_address=None, inbox=None, outbox=None, basic_constraints="CA:FALSE", key_usage="digitalSignature,keyEncipherment", extended_key_usage="clientAuth", certificate_lifetime=5*365, revocation_list_lifetime=1):
self.slug = slug self.slug = slug
self.revocation_list = crl self.revocation_list = crl
self.signed_dir = certs self.signed_dir = certs
self.request_dir = new_certs_dir self.request_dir = new_certs_dir
self.revoked_dir = revoked_certs_dir self.revoked_dir = revoked_certs_dir
self.private_key = private_key self.private_key = private_key
self.autosign_whitelist = set([j for j in autosign_whitelist.split(" ") if j])
self.autosign_whitelist = set([ipaddress.ip_network(j) for j in autosign_whitelist.split(" ") if j])
self.request_whitelist = set([ipaddress.ip_network(j) for j in request_whitelist.split(" ") if j]).union(self.autosign_whitelist)
self.certificate = Certificate(open(certificate)) self.certificate = Certificate(open(certificate))
self.mailer = Mailer(outbox) if outbox else None self.mailer = Mailer(outbox) if outbox else None
self.lifetime = lifetime self.certificate_lifetime = certificate_lifetime
self.revocation_list_lifetime = revocation_list_lifetime
self.basic_constraints = basic_constraints self.basic_constraints = basic_constraints
self.key_usage = key_usage self.key_usage = key_usage
self.extended_key_usage = extended_key_usage self.extended_key_usage = extended_key_usage
def autosign_allowed(self, addr):
for j in self.autosign_whitelist:
if j.endswith(".") and addr.startswith(j):
return True
elif j == addr:
return True
return False
def _signer_exec(self, cmd, *bits): def _signer_exec(self, cmd, *bits):
sock = self.connect_signer() sock = self.connect_signer()
sock.send(cmd.encode("ascii")) sock.send(cmd.encode("ascii"))
@ -540,7 +538,7 @@ class CertificateAuthority(object):
self.certificate._obj, self.certificate._obj,
request._obj, request._obj,
self.basic_constraints, self.basic_constraints,
lifetime=lifetime or self.lifetime) lifetime=lifetime or self.certificate_lifetime)
path = os.path.join(self.signed_dir, request.common_name + ".pem") path = os.path.join(self.signed_dir, request.common_name + ".pem")
if os.path.exists(path): if os.path.exists(path):

View File

@ -13,8 +13,8 @@ from certidude.api import CertificateAuthorityResource, \
config = CertificateAuthorityConfig("/etc/ssl/openssl.cnf") config = CertificateAuthorityConfig("/etc/ssl/openssl.cnf")
assert os.getenv("CERTIDUDE_EVENT_SUBSCRIBE"), "Please set CERTIDUDE_EVENT_SUBSCRIBE to your web server's subscribe URL" assert os.getenv("CERTIDUDE_EVENT_SUBSCRIBE"), "Please set CERTIDUDE_EVENT_SUBSCRIBE to your web server's subscription URL"
assert os.getenv("CERTIDUDE_EVENT_PUBLISH"), "Please set CERTIDUDE_EVENT_SUBSCRIBE to your web server's subscribe URL" assert os.getenv("CERTIDUDE_EVENT_PUBLISH"), "Please set CERTIDUDE_EVENT_PUBLISH to your web server's publishing URL"
app = falcon.API() app = falcon.API()
app.add_route("/api/{ca}/ocsp/", CertificateStatusResource(config)) app.add_route("/api/{ca}/ocsp/", CertificateStatusResource(config))

View File

@ -1,11 +1,11 @@
#!/usr/bin/python #!/usr/bin/env python3
# coding: utf-8 # coding: utf-8
import os import os
from setuptools import setup from setuptools import setup
setup( setup(
name = "certidude", name = "certidude",
version = "0.1.7", version = "0.1.17",
author = u"Lauri Võsandi", author = u"Lauri Võsandi",
author_email = "lauri.vosandi@gmail.com", author_email = "lauri.vosandi@gmail.com",
description = "Certidude is a novel X.509 Certificate Authority management tool aiming to support PKCS#11 and in far future WebCrypto.", description = "Certidude is a novel X.509 Certificate Authority management tool aiming to support PKCS#11 and in far future WebCrypto.",