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

Implemented essential functionality

This commit is contained in:
Lauri Võsandi 2015-07-26 23:34:46 +03:00
parent 0af381fc46
commit d024f778f8
17 changed files with 2000 additions and 386 deletions

View File

@ -1,3 +1,5 @@
include README.rst include README.rst
include certidude/templates/*.html include certidude/templates/*.html
include certidude/templates/*.svg include certidude/templates/*.svg
include certidude/templates/*.ovpn
include certidude/templates/*.cnf

View File

@ -1,8 +1,39 @@
Certidude Certidude
========= =========
Certidude is a novel X.509 Certificate Authority management tool aiming to Introduction
support PKCS#11 and in far future WebCrypto ------------
Certidude is a novel X.509 Certificate Authority management tool
with privilege isolation mechanism aiming to
eventually support PKCS#11 and in far future WebCrypto.
Features
--------
* Standard request, sign, revoke workflow via web interface.
* Colored command-line interface, check out ``butterknife list``
* OpenVPN integration, check out ``butterknife setup openvpn server`` and ``butterknife setup openvpn client``
* Privilege isolation, separate signer process is spawned per private key isolating
private key use from the the web interface.
* Certificate numbering obfuscation, certificate serial numbers are intentionally
randomized to avoid leaking information about business practices.
* Server-side events support via for example nginx-push-stream-module
TODO
----
* Refactor mailing subsystem and server-side events to use hooks.
* Notifications via e-mail.
* strongSwan setup integration.
* OCSP support.
* 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>`_.
* Certificate push/pull, making it possible to sign offline.
* PKCS#11 hardware token support for signatures at command-line.
Install Install
------- -------
@ -11,7 +42,7 @@ To install Certidude:
.. code:: bash .. code:: bash
apt-get install python3-openssl apt-get install python3 python3-dev build-essential
pip3 install certidude pip3 install certidude
@ -22,13 +53,100 @@ Certidude can set up CA relatively easily:
.. code:: bash .. code:: bash
certidude ca create /path/to/directory certidude setup authority /path/to/directory
Tweak command-line options until you meet your requirements and Tweak command-line options until you meet your requirements and
finally insert corresponding segment to your /etc/ssl/openssl.cnf then insert generated section to your /etc/ssl/openssl.cnf
Finally serve the certificate authority via web: Finally serve the certificate authority via web:
.. code:: bash .. code:: bash
certidude serve certidude serve
Certificate management
----------------------
Use following command to request a certificate on a machine:
.. code::
certidude setup client http://certidude-hostname-or-ip:perhaps-port/api/ca-name/
Use following to list signing requests, certificates and revoked certificates:
.. code::
certidude list
Use web interface or following to sign a certificate on Certidude server:
.. code::
certidude sign client-hostname-or-common-name
Streaming push support
----------------------
We support `nginx-push-stream-module <https://github.com/wandenberg/nginx-push-stream-module>`_,
configure it as follows to enable real-time responses to events:
.. code::
user www-data;
worker_processes 4;
pid /run/nginx.pid;
events {
worker_connections 768;
# multi_accept on;
}
http {
push_stream_shared_memory_size 32M;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
gzip on;
gzip_disable "msie6";
server {
listen 80 default_server;
listen [::]:80 default_server ipv6only=on;
server_name localhost;
location ~ /event/publish/(.*) {
allow 127.0.0.1; # Allow publishing only from this IP address
push_stream_publisher admin;
push_stream_channels_path $1;
}
location ~ /event/subscribe/(.*) {
push_stream_channels_path $1;
push_stream_subscriber long-polling;
}
location /api/ {
proxy_pass http://127.0.0.1:9090/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
For ``butterknife serve`` export environment variables:
.. code:: bash
export CERTIDUDE_EVENT_PUBLISH = "http://localhost/event/publish/%s"
export CERTIDUDE_EVENT_SUBSCRIBE = "http://localhost/event/subscribe/%s"
certidude server -p 9090

View File

@ -3,10 +3,17 @@ import falcon
import os import os
import json import json
import types import types
import urllib.request
import click
from time import sleep
from certidude.wrappers import Request, Certificate
from certidude.mailer import Mailer
from pyasn1.codec.der import decoder
from datetime import datetime, date from datetime import datetime, date
from OpenSSL import crypto from OpenSSL import crypto
from jinja2 import Environment, PackageLoader from jinja2 import Environment, PackageLoader, Template
env = Environment(loader=PackageLoader('certidude', 'templates'))
env = Environment(loader=PackageLoader("certidude", "templates"))
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])$"
@ -19,6 +26,7 @@ def pop_certificate_authority(func):
return func(self, req, resp, *args, **kwargs) return func(self, req, resp, *args, **kwargs)
return wrapped return wrapped
def validate_common_name(func): def validate_common_name(func):
def wrapped(*args, **kwargs): def wrapped(*args, **kwargs):
if not re.match(RE_HOSTNAME, kwargs["cn"]): if not re.match(RE_HOSTNAME, kwargs["cn"]):
@ -26,18 +34,20 @@ def validate_common_name(func):
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapped return wrapped
class MyEncoder(json.JSONEncoder): class MyEncoder(json.JSONEncoder):
def default(self, obj): def default(self, obj):
if isinstance(obj, datetime): if isinstance(obj, datetime):
return obj.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + "Z" return obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
if isinstance(obj, date): if isinstance(obj, date):
return obj.strftime('%Y-%m-%d') return obj.strftime("%Y-%m-%d")
if isinstance(obj, map): if isinstance(obj, map):
return tuple(obj) return tuple(obj)
if isinstance(obj, types.GeneratorType): if isinstance(obj, types.GeneratorType):
return tuple(obj) return tuple(obj)
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, obj)
def serialize(func): def serialize(func):
""" """
Falcon response serialization Falcon response serialization
@ -51,13 +61,14 @@ def serialize(func):
if not resp.body: if not resp.body:
if not req.client_accepts_json: if not req.client_accepts_json:
raise falcon.HTTPUnsupportedMediaType( raise falcon.HTTPUnsupportedMediaType(
'This API only supports the JSON media type.', "This API only supports the JSON media type.",
href='http://docs.examples.com/api/json') href="http://docs.examples.com/api/json")
resp.set_header('Content-Type', 'application/json') resp.set_header("Content-Type", "application/json")
resp.body = json.dumps(r, cls=MyEncoder) resp.body = json.dumps(r, cls=MyEncoder)
return r return r
return wrapped return wrapped
def templatize(path): def templatize(path):
template = env.get_template(path) template = env.get_template(path)
def wrapper(func): def wrapper(func):
@ -69,20 +80,30 @@ def templatize(path):
resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate"); resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate");
resp.set_header("Pragma", "no-cache"); resp.set_header("Pragma", "no-cache");
resp.set_header("Expires", "0"); resp.set_header("Expires", "0");
resp.set_header('Content-Type', 'application/json') resp.set_header("Content-Type", "application/json")
resp.body = json.dumps(r, cls=MyEncoder) resp.body = json.dumps(r, cls=MyEncoder)
return r return r
else: else:
resp.set_header('Content-Type', 'text/html') resp.set_header("Content-Type", "text/html")
resp.body = template.render(request=req, **r) resp.body = template.render(request=req, **r)
return r return r
return wrapped return wrapped
return wrapper return wrapper
class CertificateAuthorityBase(object): class CertificateAuthorityBase(object):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
class RevocationListResource(CertificateAuthorityBase):
@pop_certificate_authority
def on_get(self, req, resp, ca):
resp.set_header("Content-Type", "application/x-pkcs7-crl")
resp.append_header("Content-Disposition", "attachment; filename=%s.crl" % ca.slug)
resp.body = ca.export_crl()
class SignedCertificateDetailResource(CertificateAuthorityBase): class SignedCertificateDetailResource(CertificateAuthorityBase):
@pop_certificate_authority @pop_certificate_authority
@validate_common_name @validate_common_name
@ -105,17 +126,17 @@ class SignedCertificateListResource(CertificateAuthorityBase):
def on_get(self, req, resp, ca): def on_get(self, req, resp, ca):
for j in authority.get_signed(): for j in authority.get_signed():
yield omit( yield omit(
key_type=j.key_type(), key_type=j.key_type,
key_length=j.key_length(), key_length=j.key_length,
subject=j.get_dn(), subject=j.distinguished_name,
issuer=j.get_issuer_dn(), cn=j.common_name,
cn=j.subject.CN, c=j.country_code,
c=j.subject.C, st=j.state_or_county,
st=j.subject.ST, l=j.city,
l=j.subject.L, o=j.organization,
o=j.subject.O, ou=j.organizational_unit,
ou=j.subject.OU, fingerprint=j.fingerprint)
fingerprint=j.get_pubkey_fingerprint())
class RequestDetailResource(CertificateAuthorityBase): class RequestDetailResource(CertificateAuthorityBase):
@pop_certificate_authority @pop_certificate_authority
@ -128,6 +149,7 @@ class RequestDetailResource(CertificateAuthorityBase):
if not os.path.exists(path): if not os.path.exists(path):
raise falcon.HTTPNotFound() raise falcon.HTTPNotFound()
resp.stream = open(path, "rb") resp.stream = open(path, "rb")
resp.append_header("Content-Type", "application/x-x509-user-cert")
resp.append_header("Content-Disposition", "attachment; filename=%s.csr" % cn) resp.append_header("Content-Disposition", "attachment; filename=%s.csr" % cn)
@pop_certificate_authority @pop_certificate_authority
@ -136,14 +158,16 @@ class RequestDetailResource(CertificateAuthorityBase):
""" """
Sign a certificate signing request Sign a certificate signing request
""" """
path = os.path.join(ca.request_dir, cn + ".pem") csr = ca.get_request(cn)
if not os.path.exists(path): cert = ca.sign(csr, overwrite=True, delete=True)
raise falcon.HTTPNotFound() os.unlink(csr.path)
ca.sign(ca.get_request(cn))
resp.body = "Certificate successfully signed" resp.body = "Certificate successfully signed"
resp.status = falcon.HTTP_201 resp.status = falcon.HTTP_201
resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn) resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn)
@pop_certificate_authority
def on_delete(self, req, resp, ca, cn):
ca.delete_request(cn)
class RequestListResource(CertificateAuthorityBase): class RequestListResource(CertificateAuthorityBase):
@serialize @serialize
@ -151,46 +175,133 @@ class RequestListResource(CertificateAuthorityBase):
def on_get(self, req, resp, ca): def on_get(self, req, resp, ca):
for j in ca.get_requests(): for j in ca.get_requests():
yield omit( yield omit(
key_type=j.key_type(), key_type=j.key_type,
key_length=j.key_length(), key_length=j.key_length,
subject=j.get_dn(), subject=j.distinguished_name,
cn=j.subject.CN, cn=j.common_name,
c=j.subject.C, c=j.country_code,
st=j.subject.ST, st=j.state_or_county,
l=j.subject.L, l=j.city,
o=j.subject.O, o=j.organization,
ou=j.subject.OU, ou=j.organizational_unit,
fingerprint=j.get_pubkey_fingerprint()) fingerprint=j.fingerprint())
@pop_certificate_authority @pop_certificate_authority
def on_post(self, req, resp, ca): def on_post(self, req, resp, ca):
"""
Submit certificate signing request (CSR) in PEM format
"""
if req.get_header("Content-Type") != "application/pkcs10": if req.get_header("Content-Type") != "application/pkcs10":
raise falcon.HTTPUnsupportedMediaType( raise falcon.HTTPUnsupportedMediaType(
"This API call accepts only application/pkcs10 content type") "This API call accepts only application/pkcs10 content type")
# POTENTIAL SECURITY HOLE HERE! body = req.stream.read(req.content_length)
# Should we sanitize input before we handle it to SSL libs? csr = Request(body)
# Check if this request has been already signed and return corresponding certificte if it has been signed
try: try:
csr = crypto.load_certificate_request( cert_buf = ca.get_certificate(csr.common_name)
crypto.FILETYPE_PEM, req.stream.read(req.content_length)) except FileNotFoundError:
except crypto.Error: pass
raise falcon.HTTPBadRequest("Invalid CSR", "Failed to parse request body as PEM") else:
cert = Certificate(cert_buf)
if cert.pubkey == csr.pubkey:
resp.status = falcon.HTTP_FOUND
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name)
return
common_name = csr.get_subject().CN # TODO: check for revoked certificates and return HTTP 410 Gone
if not re.match(RE_HOSTNAME, common_name): # Process automatic signing if the IP address is whitelisted and autosigning was requested
raise falcon.HTTPBadRequest("Invalid CN", "Common name supplied with CSR did not match validation regex") if ca.autosign_allowed(req.env["REMOTE_ADDR"]) and req.get_param("autosign"):
try:
resp.append_header("Content-Type", "application/x-x509-user-cert")
resp.body = ca.sign(req).dump()
return
except FileExistsError: # Certificate already exists, try to save the request
pass
path = os.path.join(ca.request_dir, common_name + ".pem") # Attempt to save the request otherwise
with open(path, "wb") as fh: try:
fh.write(crypto.dump_certificate_request( request = ca.store_request(body)
crypto.FILETYPE_PEM, csr)) except FileExistsError:
raise falcon.HTTPConflict(
"CSR with such CN already exists",
"Will not overwrite existing certificate signing request, explicitly delete CSR and try again")
# Wait the certificate to be signed if waiting is requested
if req.get_param("wait"):
url_template = os.getenv("CERTIDUDE_EVENT_SUBSCRIBE")
if url_template:
# Redirect to nginx pub/sub
url = url_template % request.fingerprint()
click.echo("Redirecting to: %s" % url)
resp.status = falcon.HTTP_FOUND
resp.append_header("Location", url)
else:
click.echo("Using dummy streaming mode, please switch to nginx in production!", err=True)
# Dummy streaming mode
while True:
sleep(1)
if not ca.request_exists(csr.common_name):
resp.append_header("Content-Type", "application/x-x509-user-cert")
resp.status = falcon.HTTP_201 # Certificate was created
resp.body = ca.get_certificate(csr.common_name)
break
else:
# Request was accepted, but not processed
resp.status = falcon.HTTP_202
class CertificateStatusResource(CertificateAuthorityBase):
"""
openssl ocsp -issuer CAcert_class1.pem -serial 0x<serial no in hex> -url http://localhost -CAfile cacert_both.pem
"""
def on_post(self, req, resp, ca):
ocsp_request = req.stream.read(req.content_length)
for component in decoder.decode(ocsp_request):
click.echo(component)
resp.append_header("Content-Type", "application/ocsp-response")
resp.status = falcon.HTTP_200
raise NotImplementedError()
class CertificateAuthorityResource(CertificateAuthorityBase): class CertificateAuthorityResource(CertificateAuthorityBase):
@pop_certificate_authority
def on_get(self, req, resp, ca):
path = os.path.join(ca.certificate.path)
resp.stream = open(path, "rb")
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % ca.slug)
class IndexResource(CertificateAuthorityBase):
@templatize("index.html") @templatize("index.html")
@pop_certificate_authority
def on_get(self, req, resp, ca): def on_get(self, req, resp, ca):
return { return {
"authority": self.config.instantiate_authority(ca)} "authority": ca }
class ApplicationConfigurationResource(CertificateAuthorityBase):
@validate_common_name
@pop_certificate_authority
def on_get(self, req, resp, ca, cn):
ctx = dict(
cn = cn,
certificate = ca.get_certificate(cn),
ca_certificate = open(ca.certificate.path, "r").read())
resp.append_header("Content-Type", "application/ovpn")
resp.append_header("Content-Disposition", "attachment; filename=%s.ovpn" % cn)
resp.body = Template(open("/etc/openvpn/%s.template" % ca.slug).read()).render(ctx)
@validate_common_name
@pop_certificate_authority
def on_put(self, req, resp, ca, cn=None):
pkey_buf, req_buf, cert_buf = ca.create_bundle(cn)
ctx = dict(
private_key = pkey_buf,
certificate = cert_buf,
ca_certificate = ca.certificate.dump())
resp.append_header("Content-Type", "application/ovpn")
resp.append_header("Content-Disposition", "attachment; filename=%s.ovpn" % cn)
resp.body = Template(open("/etc/openvpn/%s.template" % ca.slug).read()).render(ctx)

View File

@ -1,43 +1,459 @@
#!/usr/bin/python3 #!/usr/bin/python3
# coding: utf-8 # coding: utf-8
import sys
import pwd
import random
import socket import socket
import click import click
import os import os
import asyncore
import time import time
import os import os
import re import re
import logging
import signal
import netifaces
import urllib.request
from humanize import naturaltime
from ipaddress import ip_network
from time import sleep
from datetime import datetime from datetime import datetime
from OpenSSL import crypto from OpenSSL import crypto
from setproctitle import setproctitle
from certidude.signer import SignServer
from jinja2 import Environment, PackageLoader
from certidude.wrappers import CertificateAuthorityConfig, \ from certidude.wrappers import CertificateAuthorityConfig, \
CertificateAuthority, SerialCounter, Certificate, subject2dn CertificateAuthority, Certificate, subject2dn, Request
env = Environment(loader=PackageLoader("certidude", "templates"))
# Big fat warning: # Big fat warning:
# m2crypto overflows around 2030 because on 32-bit systems # m2crypto overflows around 2030 because on 32-bit systems
# m2crypto does not support hardware engine support (?) # m2crypto does not support hardware engine support (?)
# m2crypto CRL object is pretty much useless # m2crypto CRL object is pretty much useless
# pyopenssl has no straight-forward methods for getting RSA key modulus # pyopenssl has no straight-forward methods for getting RSA key modulus
# pyopenssl 0.13 bundled with Ubuntu 14.04 has no get_extension_count() for X509Req objects
assert hasattr(crypto.X509Req(), "get_extensions"), "You're running too old version of pyopenssl, upgrade to 0.15+"
# 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/
# keyUsage, extendedKeyUsage - https://www.openssl.org/docs/apps/x509v3_config.html
config = CertificateAuthorityConfig("/etc/ssl/openssl.cnf") config = CertificateAuthorityConfig("/etc/ssl/openssl.cnf")
# Parse command-line argument defaults from environment
HOSTNAME = socket.gethostname()
USERNAME = os.environ.get("USER")
EMAIL = USERNAME + "@" + HOSTNAME
NOW = datetime.utcnow().replace(tzinfo=None) NOW = datetime.utcnow().replace(tzinfo=None)
FIRST_NAME = None
SURNAME = None
@click.command("create", help="Set up Certificate Authority in a directory") if os.getuid() >= 1000:
_, _, _, _, gecos, _, _ = pwd.getpwnam(USERNAME)
if " " in gecos:
FIRST_NAME, SURNAME = gecos.split(" ", 1)
else:
FIRST_NAME = gecos
def first_nic_address():
"""
Return IP address of the first network interface
"""
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):
"""
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
run_dir = "/run/certidude"
signer_dir = os.path.join(run_dir, "signer")
chroot_dir = os.path.join(signer_dir, "jail")
# Prepare signer PID-s directory
if not os.path.exists(signer_dir):
click.echo("Creating: %s" % signer_dir)
os.makedirs(signer_dir)
# Prepare chroot directories
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"))
for ca in config.all_authorities():
pidfile = "/run/certidude/signer/%s.pid" % ca.slug
try:
with open(pidfile) as fh:
pid = int(fh.readline())
os.kill(pid, 0)
click.echo("Found process with PID %d for %s" % (pid, ca.slug))
except (ValueError, ProcessLookupError, FileNotFoundError):
pid = 0
if pid > 0:
if kill:
try:
click.echo("Killing %d" % pid)
os.kill(pid, signal.SIGTERM)
sleep(1)
os.kill(pid, signal.SIGKILL)
sleep(1)
except ProcessLookupError:
pass
else:
continue
child_pid = os.fork()
if child_pid == 0:
with open(pidfile, "w") as fh:
fh.write("%d\n" % os.getpid())
setproctitle("%s spawn %s" % (sys.argv[0], ca.slug))
logging.basicConfig(
filename="/var/log/certidude-%s.log" % ca.slug,
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,
ca.lifetime, ca.basic_constraints, ca.key_usage, ca.extended_key_usage)
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:
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_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.argument("url") #, help="Certidude authority endpoint 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("--email-address", "-m", default=EMAIL, help="E-mail associated with the request, '%s' by default" % EMAIL)
@click.option("--given-name", "-gn", default=FIRST_NAME, help="Given name of the person associted with the certificate, '%s' by default" % FIRST_NAME)
@click.option("--surname", "-sn", default=SURNAME, help="Surname of the person associted with the certificate, '%s' by default" % SURNAME)
@click.option("--key-usage", "-ku", help="Key usage attributes, none requested by default")
@click.option("--extended-key-usage", "-eku", help="Extended key usage attributes, none requested by default")
@click.option("--quiet", "-q", default=False, is_flag=True, help="Disable verbose output")
@click.option("--autosign", "-s", default=False, is_flag=True, help="Request for automatic signing if available")
@click.option("--wait", "-w", default=False, is_flag=True, help="Wait for certificate, by default return immideately")
@click.option("--key-path", "-k", default=HOSTNAME + ".key", help="Key path, %s.key by default" % HOSTNAME)
@click.option("--request-path", "-r", default=HOSTNAME + ".csr", help="Request path, %s.csr by default" % HOSTNAME)
@click.option("--certificate-path", "-c", default=HOSTNAME + ".crt", help="Certificate path, %s.crt by default" % HOSTNAME)
@click.option("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt by default")
def certidude_setup_client(quiet, **kwargs):
return certidude_request_certificate(**kwargs)
@click.command("server", help="Set up OpenVPN 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("--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("--local", "-l", default=first_nic_address(), help="OpenVPN listening address, %s" % first_nic_address())
@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("--config", "-o",
default="/etc/openvpn/site-to-client.conf",
type=click.File(mode="w", atomic=True, lazy=True),
help="OpenVPN configuration file")
@click.option("--directory", "-d", default="/etc/openvpn/keys", help="Directory for keys, /etc/openvpn/keys by default")
@click.option("--key-path", "-k", default=HOSTNAME + ".key", help="Key path, %s.key 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("--authority-path", "-a", 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, local, proto, port):
# TODO: Intelligent way of getting last IP address in the subnet
subnet_first = None
subnet_last = None
subnet_second = None
for addr in subnet.hosts():
if not subnet_first:
subnet_first = addr
continue
if not subnet_second:
subnet_second = 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)
if not os.path.exists(certificate_path):
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()
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",
wait=True)
if retval:
return retval
# TODO: Add dhparam
config.write(env.get_template("site-to-client.ovpn").render(locals()))
click.echo("Generated %s" % config.name)
click.echo()
click.echo("Inspect newly created %s and start OpenVPN service:" % config.name)
click.echo()
click.secho(" service openvpn restart", bold=True)
click.echo()
@click.command("client", help="Set up OpenVPN client")
@click.argument("url")
@click.argument("remote")
@click.option('--proto', "-t", default="udp", type=click.Choice(['udp', 'tcp']), help="OpenVPN transport protocol, UDP by default")
@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/openvpn/client-to-site.conf",
type=click.File(mode="w", atomic=True, lazy=True),
help="OpenVPN configuration file")
@click.option("--directory", "-d", default="/etc/openvpn/keys", help="Directory for keys, /etc/openvpn/keys by default")
@click.option("--key-path", "-k", default=HOSTNAME + ".key", help="Key path, %s.key 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("--authority-path", "-a", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to --dir by default")
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(
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("client-to-site.ovpn").render(locals()))
click.echo("Generated %s" % config.name)
click.echo()
click.echo("Inspect newly created %s and start OpenVPN service:" % config.name)
click.echo()
click.echo(" service openvpn restart")
click.echo()
@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("--parent", "-p", help="Parent CA, none by default") @click.option("--parent", "-p", help="Parent CA, none by default")
@click.option("--common-name", "-cn", default=socket.gethostname(), help="Common name, hostname by default") @click.option("--common-name", "-cn", default=HOSTNAME, help="Common name, hostname by default")
@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, help="Lifetime in years") @click.option("--lifetime", default=20*365, help="Lifetime in days, 7300 days (20 years) 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("--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("--ocsp-responder-url", default=None, help="OCSP responder URL")
@click.option("--email-address", default=EMAIL, help="CA e-mail address")
@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.argument("directory") @click.argument("directory")
def ca_create(parent, country, state, locality, organization, organizational_unit, common_name, directory, crl_age, lifetime, pkcs11): 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):
logging.info("Creating certificate authority in %s", directory)
_, _, uid, gid, gecos, root, shell = pwd.getpwnam(group)
os.setgid(gid)
click.echo("Generating 4096-bit RSA key...") click.echo("Generating 4096-bit RSA key...")
if pkcs11: if pkcs11:
@ -45,10 +461,20 @@ def ca_create(parent, country, state, locality, organization, organizational_uni
else: else:
key = crypto.PKey() key = crypto.PKey()
key.generate_key(crypto.TYPE_RSA, 4096) key.generate_key(crypto.TYPE_RSA, 4096)
slug = os.path.basename(directory) slug = os.path.basename(directory)
crl_distribution_points = "URI:http://%s/api/%s/revoked/" % (common_name, slug)
if not crl_distribution_url:
crl_distribution_url = "http://%s/api/%s/revoked/" % (common_name, slug)
# File paths
ca_key = os.path.join(directory, "ca_key.pem")
ca_crt = os.path.join(directory, "ca_crt.pem")
ca_crl = os.path.join(directory, "ca_crl.pem")
crl_distribution_points = "URI:%s" % crl_distribution_url
ca = crypto.X509() ca = crypto.X509()
ca.set_version(3) #ca.set_version(3) # breaks gcr-viewer?!
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
@ -57,14 +483,14 @@ def ca_create(parent, country, state, locality, organization, organizational_uni
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 * 365 * 24 * 60 * 60) ca.gmtime_adj_notAfter(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([
crypto.X509Extension( crypto.X509Extension(
b"basicConstraints", b"basicConstraints",
True, True,
b"CA:TRUE, pathlen:0"), b"CA:TRUE"),
crypto.X509Extension( crypto.X509Extension(
b"keyUsage", b"keyUsage",
True, True,
@ -80,74 +506,170 @@ def ca_create(parent, country, state, locality, organization, organizational_uni
crl_distribution_points.encode("ascii")) crl_distribution_points.encode("ascii"))
]) ])
if email_address:
subject_alt_name = "email:%s" % email_address
ca.add_extensions([
crypto.X509Extension(
b"subjectAltName",
False,
subject_alt_name.encode("ascii"))
])
if not ocsp_responder_url:
ocsp_responder_url = "http://%s/api/%s/ocsp/" % (common_name, slug)
authority_info_access = "OCSP;URI:%s" % ocsp_responder_url
ca.add_extensions([
crypto.X509Extension(
b"authorityInfoAccess",
False,
authority_info_access.encode("ascii"))
])
click.echo("Signing %s..." % subject2dn(ca.get_subject())) click.echo("Signing %s..." % subject2dn(ca.get_subject()))
# openssl x509 -in ca_crt.pem -outform DER | sha1sum
# openssl x509 -fingerprint -in ca_crt.pem
ca.sign(key, "sha1") ca.sign(key, "sha1")
os.umask(0o027)
if not os.path.exists(directory): if not os.path.exists(directory):
os.makedirs(directory) os.makedirs(directory)
os.umask(0o007)
for subdir in ("signed", "requests", "revoked"): for subdir in ("signed", "requests", "revoked"):
if not os.path.exists(os.path.join(directory, subdir)): if not os.path.exists(os.path.join(directory, subdir)):
os.mkdir(os.path.join(directory, subdir)) os.mkdir(os.path.join(directory, subdir))
with open(os.path.join(directory, "ca_key.pem"), "wb") as fh: with open(ca_crl, "wb") as fh:
fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key))
with open(os.path.join(directory, "ca_crt.pem"), "wb") as fh:
fh.write(crypto.dump_certificate(crypto.FILETYPE_PEM, ca))
with open(os.path.join(directory, "ca_crl.pem"), "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=crl_age))
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")
os.umask(0o027)
with open(ca_crt, "wb") as fh:
fh.write(crypto.dump_certificate(crypto.FILETYPE_PEM, ca))
os.umask(0o077)
with open(ca_key, "wb") as fh:
fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key))
click.echo("Insert following to /etc/ssl/openssl.cnf:")
click.echo() click.echo()
click.echo("Add following to your /etc/ssl/openssl.cnf:") click.secho(env.get_template("openssl.cnf").render(locals()), fg="blue")
click.echo()
click.echo("[CA_%s]" % slug)
click.echo("dir = %s" % directory)
click.echo("private_key = $dir/ca_key.pem")
click.echo("certificate = $dir/ca_crt.pem")
click.echo("new_certs_dir = $dir/requests/")
click.echo("revoked_certs_dir = $dir/revoked/")
click.echo("certs = $dir/signed/")
click.echo("crl = $dir/ca_crl.pem")
click.echo("serial = $dir/serial")
click.echo("crlDistributionPoints = %s" % crl_distribution_points)
click.echo() click.echo()
click.echo("Use following commands to inspect the newly created files:") click.echo("Use following commands to inspect the newly created files:")
click.echo() click.echo()
click.echo(" openssl crl -inform PEM -text -noout -in %s" % os.path.join(directory, "ca_crl.pem")) click.echo(" openssl crl -inform PEM -text -noout -in %s" % ca_crl)
click.echo(" openssl x509 -in %s -text -noout" % os.path.join(directory, "ca_crt.pem")) click.echo(" openssl x509 -text -noout -in %s" % ca_crt)
click.echo(" openssl rsa -in %s -check" % os.path.join(directory, "ca_key.pem")) click.echo(" openssl rsa -check -in %s" % ca_key)
click.echo(" openssl verify -CAfile %s %s" % (ca_crt, ca_crt))
click.echo()
click.echo("Use following to launch privilege isolated signer processes:")
click.echo()
click.echo(" certidude spawn")
click.echo() click.echo()
click.echo("Use following command to serve CA read-only:") click.echo("Use following command to serve CA read-only:")
click.echo() click.echo()
click.echo(" certidude serve") click.echo(" certidude serve")
@click.command("list", help="List Certificate Authorities")
def ca_list():
for ca in config.all_authorities():
click.echo("Certificate authority '%s'" % ca.certificate.get_dn())
if ca.certificate.not_before < NOW and ca.certificate.not_after > NOW: @click.command("list", help="List certificates")
click.echo(" ✓ Certificate valid %s" % (ca.certificate.not_after - NOW)) @click.argument("ca", nargs=-1)
elif NOW > ca.certificate.not_after: @click.option("--show-key-type", "-k", default=False, is_flag=True, help="Show key type and length")
click.echo(" ✗ Certificate expired") @click.option("--show-path", "-p", default=False, is_flag=True, help="Show filesystem paths")
@click.option("--show-extensions", "-e", default=False, is_flag=True, help="Show X.509 Certificate Extensions")
def certidude_list(ca, show_key_type, show_extensions, show_path):
from pycountry import countries
def dump_common(j):
if show_path:
click.echo(" | | Path: %s" % j.path)
person = [j for j in (j.given_name, j.surname) if j]
if person:
click.echo(" | | Associated person: %s" % " ".join(person) + (" <%s>" % j.email_address if j.email_address else ""))
elif j.email_address:
click.echo(" | | Associated e-mail: " + j.email_address)
bits = [j for j in (
countries.get(alpha2=j.country_code.upper()).name if
j.country_code else "",
j.state_or_county,
j.city,
j.organization,
j.organizational_unit) if j]
if bits:
click.echo(" | | Organization: %s" % ", ".join(bits))
if show_key_type:
click.echo(" | | Key type: %s-bit %s" % (j.key_length, j.key_type))
if show_extensions:
for key, value, data in j.extensions:
click.echo((" | | Extension " + key + ":").ljust(50) + " " + value)
elif j.key_usage:
click.echo(" | | Key usage: " + j.key_usage)
click.echo(" | |")
for ca in config.all_authorities():
click.echo("Certificate authority " + click.style(ca.slug, fg="blue"))
# if ca.certificate.email_address:
# click.echo(" \u2709 %s" % ca.certificate.email_address)
if ca.certificate.signed < NOW and ca.certificate.expires > NOW:
print(ca.certificate.expires)
click.echo(" | \u2713 Certificate: " + click.style("valid", fg="green") + ", %s" % ca.certificate.expires)
elif NOW > ca.certificate.expires:
click.echo(" | \u2717 Certificate: " + click.style("expired", fg="red"))
else: else:
click.echo(" ✗ Certificate authority not valid yet") click.echo(" | \u2717 Certificate: " + click.style("not valid yet", fg="red"))
if os.path.exists(ca.private_key): if os.path.exists(ca.private_key):
click.echo(" ✓ Private key %s okay" % ca.private_key) click.echo(" | \u2713 Private key " + ca.private_key + ": " + click.style("okay", fg="green"))
# TODO: Check permissions
else: else:
click.echo(" ✗ Private key %s does not exist" % ca.private_key) click.echo(" | \u2717 Private key " + ca.private_key + ": " + click.style("does not exist", fg="red"))
if os.path.isdir(ca.signed_dir): if os.path.isdir(ca.signed_dir):
click.echo(" ✓ Signed certificates directory %s okay" % ca.signed_dir) click.echo(" | \u2713 Signed certificates directory " + ca.signed_dir + ": " + click.style("okay", fg="green"))
else: else:
click.echo(" ✗ Signed certificates directory %s okay" % ca.signed_dir) click.echo(" | \u2717 Signed certificates directory " + ca.signed_dir + ": " + click.style("does not exist", fg="red"))
click.echo(" Revoked certificates directory: %s" % ca.revoked_dir) if ca.revoked_dir:
click.echo(" Revocation list: %s" % ca.revocation_list) click.echo(" | Revoked certificates directory: %s" % ca.revoked_dir)
click.echo(" +-- Pending requests")
for j in ca.get_requests():
click.echo(" | +-- Request " + click.style(j.common_name, fg="blue"))
click.echo(" | | Submitted: %s, %s" % (naturaltime(j.created), j.created))
dump_common(j)
click.echo(" +-- Signed certificates")
for j in ca.get_signed():
click.echo(" | +-- Certificate " + click.style(j.common_name, fg="blue") + " " + click.style(":".join(re.findall("\d\d", j.serial_number)), fg="white"))
if j.signed < NOW and j.expires > NOW:
click.echo(" | | \u2713 Certificate " + click.style("valid", fg="green") + " " + naturaltime(j.expires))
elif NOW > j.expires:
click.echo(" | | \u2717 Certificate " + click.style("expired", fg="red") + " " + naturaltime(j.expires))
else:
click.echo(" | | \u2717 Certificate " + click.style("not valid yet", fg="red"))
dump_common(j)
click.echo(" +-- Revocations")
for j in ca.get_revoked():
click.echo(" | +-- Revocation " + click.style(j.common_name, fg="blue") + " " + click.style(":".join(re.findall("\d\d", j.serial_number)), fg="white"))
# click.echo(" | | Serial: %s" % ":".join(re.findall("\d\d", j.serial_number)))
if show_path:
click.echo(" | | Path: %s" % j.path)
click.echo(" | | Revoked: %s%s" % (naturaltime(NOW-j.changed), click.style(", %s" % j.changed, fg="white")))
dump_common(j)
click.echo() click.echo()
@ -155,6 +677,7 @@ def ca_list():
@click.argument("ca") @click.argument("ca")
@config.pop_certificate_authority() @config.pop_certificate_authority()
def cert_list(ca): def cert_list(ca):
mapping = {} mapping = {}
click.echo("Listing certificates for: %s" % ca.certificate.subject.CN) click.echo("Listing certificates for: %s" % ca.certificate.subject.CN)
@ -174,12 +697,51 @@ def cert_list(ca):
for request in ca.get_requests(): for request in ca.get_requests():
click.echo("%s" % request.subject.CN) click.echo("%s" % request.subject.CN)
@click.command("sign", help="Sign certificates")
@click.argument("common_name")
@click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN")
@click.option("--lifetime", "-l", help="Lifetime")
def certidude_sign(common_name, overwrite, lifetime):
def iterate():
for ca in config.all_authorities():
for request in ca.get_requests():
if request.common_name != common_name:
continue
print(request.fingerprint(), request.common_name, request.path, request.key_usage)
yield ca, request
results = tuple(iterate())
click.echo()
click.echo("Press Ctrl-C to cancel singing these requests...")
sys.stdin.readline()
for ca, request in results:
if request.signable:
# Sign via signer process
cert = ca.sign(request)
else:
# Sign directly using private key
cert = ca.sign2(request, overwrite, True, lifetime)
os.unlink(request.path)
click.echo("Signed %s" % cert.distinguished_name)
for key, value, data in cert.extensions:
click.echo("Added extension %s: %s" % (key, value))
click.echo()
@click.command("serve", help="Run built-in HTTP server") @click.command("serve", help="Run built-in HTTP server")
@click.option("-u", "--user", default=None, help="Run as user") @click.option("-u", "--user", default="certidude", help="Run as user")
@click.option("-p", "--port", default=80, help="Listen port") @click.option("-p", "--port", default=80, help="Listen port")
@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 serve(user, port, listen, enable_signature): def certidude_serve(user, port, listen, enable_signature):
spawn_signers(kill=False, no_interaction=False)
logging.basicConfig(
filename='/var/log/certidude.log',
level=logging.DEBUG)
click.echo("Serving API at %s:%d" % (listen, port)) click.echo("Serving API at %s:%d" % (listen, port))
import pwd import pwd
import falcon import falcon
@ -187,18 +749,24 @@ def serve(user, port, listen, enable_signature):
from socketserver import ThreadingMixIn from socketserver import ThreadingMixIn
from certidude.api import CertificateAuthorityResource, \ from certidude.api import CertificateAuthorityResource, \
RequestDetailResource, RequestListResource, \ RequestDetailResource, RequestListResource, \
SignedCertificateDetailResource, SignedCertificateListResource SignedCertificateDetailResource, SignedCertificateListResource, \
RevocationListResource, IndexResource, ApplicationConfigurationResource, \
CertificateStatusResource
class ThreadingWSGIServer(ThreadingMixIn, WSGIServer): class ThreadingWSGIServer(ThreadingMixIn, WSGIServer):
pass pass
click.echo("Listening on %s:%d" % (listen, port)) click.echo("Listening on %s:%d" % (listen, port))
app = falcon.API() app = falcon.API()
app.add_route("/api/{ca}/ocsp/", CertificateStatusResource(config))
app.add_route("/api/{ca}/signed/{cn}/openvpn", ApplicationConfigurationResource(config))
app.add_route("/api/{ca}/certificate/", CertificateAuthorityResource(config))
app.add_route("/api/{ca}/revoked/", RevocationListResource(config))
app.add_route("/api/{ca}/signed/{cn}/", SignedCertificateDetailResource(config)) app.add_route("/api/{ca}/signed/{cn}/", SignedCertificateDetailResource(config))
app.add_route("/api/{ca}/signed/", SignedCertificateListResource(config)) app.add_route("/api/{ca}/signed/", SignedCertificateListResource(config))
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}/", CertificateAuthorityResource(config)) app.add_route("/api/{ca}/", IndexResource(config))
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)
@ -208,23 +776,27 @@ def serve(user, port, listen, enable_signature):
click.echo("Switching to user %s (uid=%d, gid=%d)" % (user, uid, gid)) click.echo("Switching to user %s (uid=%d, gid=%d)" % (user, uid, gid))
os.setgid(gid) os.setgid(gid)
os.setuid(uid) os.setuid(uid)
os.umask(0o007)
elif os.getuid() == 0: elif os.getuid() == 0:
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(help="Certificate Authority management") @click.group("openvpn", help="OpenVPN helpers")
def ca(): pass def certidude_setup_openvpn(): pass
@click.group(help="Certificate management") @click.group("setup", help="Getting started section")
def cert(): pass def certidude_setup(): pass
cert.add_command(cert_list)
ca.add_command(ca_create)
ca.add_command(ca_list)
@click.group() @click.group()
def entry_point(): pass def entry_point(): pass
entry_point.add_command(ca) certidude_setup_openvpn.add_command(certidude_setup_openvpn_server)
entry_point.add_command(cert) certidude_setup_openvpn.add_command(certidude_setup_openvpn_client)
entry_point.add_command(serve) certidude_setup.add_command(certidude_setup_authority)
certidude_setup.add_command(certidude_setup_openvpn)
certidude_setup.add_command(certidude_setup_client)
entry_point.add_command(certidude_setup)
entry_point.add_command(certidude_serve)
entry_point.add_command(certidude_spawn)
entry_point.add_command(certidude_sign)
entry_point.add_command(certidude_list)

104
certidude/mailer.py Normal file
View File

@ -0,0 +1,104 @@
import os
import smtplib
from time import sleep
from jinja2 import Environment, PackageLoader
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from urllib.parse import urlparse
class Mailer(object):
def __init__(self, url):
scheme, netloc, path, params, query, fragment = urlparse(url)
scheme = scheme.lower()
if path:
raise ValueError("Path for URL not supported")
if params:
raise ValueError("Parameters for URL not supported")
if query:
raise ValueError("Query for URL not supported")
if fragment:
raise ValueError("Fragment for URL not supported")
self.username = None
self.password = ""
if scheme == "smtp":
self.secure = False
self.port = 25
elif scheme == "smtps":
self.secure = True
self.port = 465
else:
raise ValueError("Unknown scheme '%s', currently SMTP and SMTPS are only supported" % scheme)
if "@" in netloc:
credentials, netloc = netloc.split("@")
if ":" in credentials:
self.username, self.password = credentials.split(":")
else:
self.username = credentials
if ":" in netloc:
self.server, port_str = netloc.split(":")
self.port = int(port_str)
else:
self.server = netloc
self.env = Environment(loader=PackageLoader("certidude", "email_templates"))
self.conn = None
def reconnect(self):
# Gmail employs some sort of IPS
# https://accounts.google.com/DisplayUnlockCaptcha
print("Connecting to:", self.server, self.port)
self.conn = smtplib.SMTP(self.server, self.port)
if self.secure:
self.conn.starttls()
if self.username and self.password:
self.conn.login(self.username, self.password)
def enqueue(self, sender, recipients, subject, template, **context):
self.send(sender, recipients, subject, template, **context)
def send(self, sender, recipients, subject, template, **context):
recipients = [j for j in recipients if j]
if not recipients:
print("No recipients to send e-mail to!")
return
print("Sending e-mail to:", recipients, "body follows:")
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = sender
msg["To"] = ", ".join(recipients)
text = self.env.get_template(template + ".txt").render(context)
html = self.env.get_template(template + ".html").render(context)
print(text)
part1 = MIMEText(text, "plain")
part2 = MIMEText(html, "html")
msg.attach(part1)
msg.attach(part2)
backoff = 1
while True:
try:
if not self.conn:
self.reconnect()
self.conn.sendmail(sender, recipients, msg.as_string())
return
except smtplib.SMTPServerDisconnected:
print("Connection to %s unexpectedly closed, probably TCP timeout, backing off for %d second" % (self.server, backoff))
self.reconnect()
backoff = backoff * 2
sleep(backoff)

203
certidude/signer.py Normal file
View File

@ -0,0 +1,203 @@
import random
import pwd
import socket
import os
import asyncore
import asynchat
from datetime import datetime
from OpenSSL import crypto
"""
Signer processes are spawned per private key.
Private key should only be readable by root.
Signer process starts up as root, reads private key,
drops privileges and awaits for opcodes (sign-request, export-crl) at UNIX domain socket
under /run/certidude/signer/
The main motivation behind the concept is to mitigate private key leaks
by confining it to a separate process.
Note that signer process uses basicConstraints, keyUsage and extendedKeyUsage
attributes from openssl.cnf via CertificateAuthority wrapper class.
Hence it's possible only to sign such certificates via the signer process,
making it hard to take advantage of hacked Certidude server, eg. being able to sign
certificate authoirty (basicConstraints=CA:TRUE) or
TLS server certificates (extendedKeyUsage=serverAuth).
"""
EXTENSION_WHITELIST = set(["subjectAltName"])
def raw_sign(private_key, ca_cert, request, basic_constraints, lifetime, key_usage=None, extended_key_usage=None):
"""
Sign certificate signing request directly with private key assuming it's readable by the process
"""
cert = crypto.X509()
cert.set_pubkey(request.get_pubkey())
# TODO: Assert openssl.cnf policy for subject attributes
# if request.get_subject().O != ca_cert.get_subject().O:
# raise ValueError("Orgnization name mismatch!")
# if request.get_subject().C != ca_cert.get_subject().C:
# raise ValueError("Country mismatch!")
# Copy attributes from CA
cert.get_subject().C = ca_cert.get_subject().C
cert.get_subject().ST = ca_cert.get_subject().ST
cert.get_subject().L = ca_cert.get_subject().L
cert.get_subject().O = ca_cert.get_subject().O
# Copy attributes from request
cert.get_subject().CN = request.get_subject().CN
req_subject = request.get_subject()
if hasattr(req_subject, "OU") and req_subject.OU:
cert.get_subject().OU = req_subject.OU
# Copy e-mail, key usage, extended key from request
for extension in request.get_extensions():
cert.add_extensions([extension])
# TODO: Set keyUsage and extendedKeyUsage defaults if none has been provided in the request
# Override basic constraints if nececssary
if basic_constraints:
cert.add_extensions([
crypto.X509Extension(
b"basicConstraints",
True,
basic_constraints.encode("ascii"))])
if key_usage:
try:
cert.add_extensions([
crypto.X509Extension(
b"keyUsage",
True,
key_usage.encode("ascii"))])
except crypto.Error:
raise ValueError("Invalid value '%s' for keyUsage attribute" % key_usage)
if extended_key_usage:
cert.add_extensions([
crypto.X509Extension(
b"extendedKeyUsage",
True,
extended_key_usage.encode("ascii"))])
# Set certificate lifetime
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(lifetime * 24 * 60 * 60)
# Generate serial from 0x10000000000000000000 to 0xffffffffffffffffffff
cert.set_serial_number(random.randint(
0x1000000000000000000000000000000000000000,
0xffffffffffffffffffffffffffffffffffffffff))
cert.sign(private_key, 'sha1')
return cert
class SignHandler(asynchat.async_chat):
def __init__(self, sock, server):
asynchat.async_chat.__init__(self, sock=sock)
self.buffer = []
self.set_terminator(b"\n\n")
self.server = server
def parse_command(self, cmd, body=""):
if cmd == "export-crl":
"""
Generate CRL object based on certificate serial number and revocation timestamp
"""
crl = crypto.CRL()
if body:
for line in body.split("\n"):
serial_number, timestamp = line.split(":")
# TODO: Assert serial against regex
revocation = crypto.Revoked()
revocation.set_rev_date(datetime.fromtimestamp(int(timestamp)).strftime("%Y%m%d%H%M%SZ").encode("ascii"))
revocation.set_reason(b"keyCompromise")
revocation.set_serial(serial_number.encode("ascii"))
crl.add_revoked(revocation)
self.send(crl.export(
self.server.certificate,
self.server.private_key,
crypto.FILETYPE_PEM))
elif cmd == "ocsp-request":
NotImplemented # TODO: Implement OCSP
elif cmd == "sign-request":
request = crypto.load_certificate_request(crypto.FILETYPE_PEM, body)
for e in request.get_extensions():
key = e.get_short_name().decode("ascii")
if key not in EXTENSION_WHITELIST:
raise ValueError("Certificte Signing Request contains extension '%s' which is not whitelisted" % key)
# TODO: Potential exploits during PEM parsing?
cert = raw_sign(
self.server.private_key,
self.server.certificate,
request,
basic_constraints=self.server.basic_constraints,
key_usage=self.server.key_usage,
extended_key_usage=self.server.extended_key_usage,
lifetime=self.server.lifetime)
self.send(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
else:
raise NotImplementedError("Unknown command: %s" % cmd)
self.close_when_done()
def found_terminator(self):
args = (b"".join(self.buffer)).decode("ascii").split("\n", 1)
self.parse_command(*args)
self.buffer = []
def collect_incoming_data(self, data):
self.buffer.append(data)
class SignServer(asyncore.dispatcher):
def __init__(self, socket_path, private_key, certificate, lifetime, basic_constraints, key_usage, extended_key_usage):
asyncore.dispatcher.__init__(self)
# Bind to sockets
if os.path.exists(socket_path):
os.unlink(socket_path)
os.umask(0o007)
self.create_socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.bind(socket_path)
self.listen(5)
# Load CA private key and certificate
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.lifetime = lifetime
self.basic_constraints = basic_constraints
self.key_usage = key_usage
self.extended_key_usage = extended_key_usage
# Perhaps perform chroot as well, currently results in
# (<class 'OpenSSL.crypto.Error'>:[('random number generator', 'SSLEAY_RAND_BYTES', 'PRNG not seeded')
# probably needs partially populated /dev in chroot
# Dropping privileges
_, _, uid, gid, gecos, root, shell = pwd.getpwnam("nobody")
os.chroot("/run/certidude/signer/jail")
os.setgid(gid)
os.setuid(uid)
def handle_accept(self):
pair = self.accept()
if pair is not None:
sock, addr = pair
handler = SignHandler(sock, self)

View File

@ -0,0 +1,12 @@
client
remote {{remote}}
proto {{proto}}
dev tap0
nobind
key {{key_path}}
cert {{certificate_path}}
ca {{authority_path}}
comp-lzo
user nobody
group nogroup

View File

@ -4,7 +4,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="48px" height="48px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve"> width="32px" height="32px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<path id="certificate-15" d="M374.021,384.08c-4.527,29.103-16.648,55.725-36.043,77.92c-1.125-7.912-4.359-15.591-7.428-21.727 <path id="certificate-15" d="M374.021,384.08c-4.527,29.103-16.648,55.725-36.043,77.92c-1.125-7.912-4.359-15.591-7.428-21.727
c-7.023,3.705-15.439,5.666-22.799,5.666c-1.559,0-3.102-0.084-4.543-0.268c20.586-21.459,30.746-43.688,33.729-73.294 c-7.023,3.705-15.439,5.666-22.799,5.666c-1.559,0-3.102-0.084-4.543-0.268c20.586-21.459,30.746-43.688,33.729-73.294
c4.828,1.341,10.697,2.046,18.072,2.046C362.119,379.285,364.918,382.319,374.021,384.08z M457.709,445.672 c4.828,1.341,10.697,2.046,18.072,2.046C362.119,379.285,364.918,382.319,374.021,384.08z M457.709,445.672

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The icon can be used freely in both personal and commercial projects with no attribution required, but always appreciated.
You may NOT sub-license, resell, rent, redistribute or otherwise transfer the icon without express written permission from iconmonstr.com -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="32px" height="32px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve">
<path id="email-2-icon" d="M49.744,103.407v305.186H50.1h411.156h1V103.407H49.744z M415.533,138.407L255.947,260.465
L96.473,138.407H415.533z M84.744,173.506l85.504,65.441L84.744,324.45V173.506z M85.1,373.593l113.186-113.186l57.654,44.127
l57.375-43.882l112.941,112.94H85.1z M427.256,325.097l-85.896-85.896l85.896-65.695V325.097z"/>
</svg>

After

Width:  |  Height:  |  Size: 982 B

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- License Agreement at http://iconmonstr.com/license/ -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="32px" height="32px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve">
<path id="flag-3-icon" d="M120.204,462H74.085V50h46.119V462z M437.915,80.746c0,0-29.079,25.642-67.324,25.642
c-60.271,0-61.627-51.923-131.596-51.923c-37.832,0-73.106,17.577-88.045,30.381c0,12.64,0,216.762,0,216.762
c21.204-14.696,53.426-30.144,88.286-30.144c66.08,0,75.343,49.388,134.242,49.388c38.042,0,64.437-24.369,64.437-24.369V80.746z"/>
</svg>

After

Width:  |  Height:  |  Size: 786 B

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The icon can be used freely in both personal and commercial projects with no attribution required, but always appreciated.
You may NOT sub-license, resell, rent, redistribute or otherwise transfer the icon without express written permission from iconmonstr.com -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="32px" height="32px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve">
<path id="key-2-icon" stroke="#000000" stroke-miterlimit="10" d="M286.529,325.486l-45.314,45.314h-43.873l0.002,43.872
l-45.746-0.001v41.345l-100.004-0.001l150.078-150.076c-4.578-4.686-10.061-11.391-13.691-17.423L50,426.498v-40.939
l145.736-145.736C212.174,278.996,244.713,310.705,286.529,325.486z M425.646,92.339c48.473,48.473,48.471,127.064-0.002,175.535
c-48.477,48.476-127.061,48.476-175.537,0.001c-48.473-48.472-48.475-127.062,0-175.537
C298.58,43.865,377.172,43.865,425.646,92.339z M400.73,117.165c-12.023-12.021-31.516-12.021-43.537,0
c-12.021,12.022-12.021,31.517,0,43.538s31.514,12.021,43.537-0.001C412.754,148.68,412.75,129.188,400.73,117.165z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -5,7 +5,7 @@ You may NOT sub-license, resell, rent, redistribute or otherwise transfer the ic
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="48px" height="48px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve"> width="32px" height="32px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve">
<path id="time-13-icon" d="M361.629,172.206c15.555-19.627,24.121-44.229,24.121-69.273V50h-259.5v52.933 <path id="time-13-icon" d="M361.629,172.206c15.555-19.627,24.121-44.229,24.121-69.273V50h-259.5v52.933
c0,25.044,8.566,49.646,24.121,69.273l50.056,63.166c9.206,11.617,9.271,27.895,0.159,39.584l-50.768,65.13 c0,25.044,8.566,49.646,24.121,69.273l50.056,63.166c9.206,11.617,9.271,27.895,0.159,39.584l-50.768,65.13
c-15.198,19.497-23.568,43.85-23.568,68.571V462h259.5v-53.343c0-24.722-8.37-49.073-23.567-68.571l-50.769-65.13 c-15.198,19.497-23.568,43.85-23.568,68.571V462h259.5v-53.343c0-24.722-8.37-49.073-23.567-68.571l-50.769-65.13

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -9,6 +9,11 @@
<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"> <style type="text/css">
svg {
position: relative;
top: 0.5em;
}
img { img {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
@ -16,7 +21,7 @@
ul { ul {
list-style: none; list-style: none;
margin: 0; margin: 1em 0;
padding: 0; padding: 0;
} }
@ -31,9 +36,12 @@
box-sizing: border-box; box-sizing: border-box;
} }
button:disabled, .button:disabled {
color: #888;
}
.monospace { .monospace {
font-family: 'Ubuntu Mono', monospace; font-family: 'Ubuntu Mono', courier, monospace;
font-size: 80%;
} }
footer { footer {
@ -106,8 +114,8 @@
} }
#container { #container {
max-width: 60em;
margin: 1em; margin: 1em auto;
background: #fff; background: #fff;
padding: 1em; padding: 1em;
border-style: solid; border-style: solid;
@ -123,13 +131,6 @@
border-top: 1px dashed #ccc; border-top: 1px dashed #ccc;
} }
li .details {
opacity: 0.2;
}
li:hover .details {
opacity: 1.0;
}
</style> </style>
</head> </head>
@ -140,18 +141,37 @@
{% set s = authority.certificate.subject %} {% set s = authority.certificate.subject %}
<p>To submit new certificate signing request:</p> <!--
<p>To submit new certificate signing request first set common name, eg:</p>
<pre> <pre>
export CN=$(hostname) export CN=$(hostname)
openssl genrsa -out $CN.key 4096
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"
curl -H "Content-Type: application/pkcs10" -X POST -d "$(cat $CN.csr)" {{ request.url }}/request/
</pre> </pre>
<p>After signing the request</p> <p>Generate key and submit using standard shell tools:</p>
<pre> <pre>
curl -f {{ request.url }}/signed/$CN > $CN.crt curl {{request.url}}/certificate/ > ca.crt
openssl genrsa -out $CN.key 4096
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"
wget --header "Content-Type: application/pkcs10" --post-data="$(cat $CN.csr)" http://localhost:9090/api/buujaa/request/?autosign=1\&wait=30 -O $CN.crt
openssl verify -CAfile ca.crt $CN.crt
</pre>
-->
<p>Assuming you have Certidude installed</p>
<pre>
certidude setup client {{request.url}}
</pre>
<p>To set up OpenVPN server</p>
<pre>
certidude setup openvpn server {{request.url}}
</pre>
<p>Or to set up OpenVPN client</p>
<pre>
certidude setup openvpn client {{request.url}}
</pre> </pre>
<h1>Pending requests</h1> <h1>Pending requests</h1>
@ -159,46 +179,123 @@ curl -f {{ request.url }}/signed/$CN > $CN.crt
<ul> <ul>
{% for j in authority.get_requests() %} {% for j in authority.get_requests() %}
<li> <li>
{% include 'iconmonstr-time-13-icon.svg' %} <a class="button" href="/api/{{authority.slug}}/request/{{j.common_name}}/">Fetch</a>
<span class="monospace">{{ j.get_dn() }}</span> {% if j.signable %}
<span class="monospace details" title="SHA-1 of public key">{{ j.get_pubkey_fingerprint().upper() }}</span> <button onClick="javascript:$.ajax({url:'/api/{{authority.slug}}/request/{{j.common_name}}/',type:'patch'});">Sign</button>
{% else %}
<button title="Please use certidude command-line utility to sign unusual requests" disabled>Sign</button>
{% endif %}
<button onClick="javascript:$.ajax({url:'/api/{{authority.slug}}/request/{{j.common_name}}/',type:'delete'});">Delete</button>
<a class="button" href="/api/{{authority.slug}}/request/{{j.subject.CN}}/">Fetch</a>
<button onClick="javascript:$.ajax({url:'/api/{{authority.slug}}/request/{{j.subject.CN}}/',type:'patch'});">Sign</button> <div class="monospace">
<button>Delete</button> {% include 'iconmonstr-certificate-15-icon.svg' %}
<br/> {{j.distinguished_name}}
<span>{{ j.key_length() }}-bit {{ j.key_type() }}</span> </div>
{% if j.email_address %}
<div class="email">{% include 'iconmonstr-email-2-icon.svg' %} {{ j.email_address }}</div>
{% endif %}
<div class="monospace">
{% include 'iconmonstr-key-2-icon.svg' %}
<span title="SHA-1 of public key">
{{ j.fingerprint() }}
</span>
{{ j.key_length }}-bit
{{ j.key_type }}
</div>
{% set key_usage = j.key_usage %}
{% if key_usage %}
<div>
{% include 'iconmonstr-flag-3-icon.svg' %}
{{j.key_usage}}
</div>
{% endif %}
</li> </li>
{% else %}
<li>Great job! No certificate signing requests to sign.</li>
{% endfor %} {% endfor %}
</ul> </ul>
<h1>Signed certificates</h1> <h1>Signed certificates</h1>
<p>You can fetch a certificate by <i>common name</i> signing the request</p>
<pre>
curl -f {{request.url}}/signed/$CN > $CN.crt
</pre>
<ul> <ul>
{% for j in authority.get_signed() | sort | reverse %} {% for j in authority.get_signed() | sort | reverse %}
<li> <li>
{% include 'iconmonstr-certificate-15-icon.svg' %}
{{ j.serial}} <span class="monospace">{{ j.get_dn() }}</span>
<span class="monospace details" title="SHA-1 of public key">{{ j.get_pubkey_fingerprint() }}</span>
{{ j.key_length() }}-bit {{ j.key_type() }}
<a class="button" href="/api/{{authority.slug}}/signed/{{j.subject.CN}}/">Fetch</a> <a class="button" href="/api/{{authority.slug}}/signed/{{j.subject.CN}}/">Fetch</a>
<button onClick="javascript:$.ajax({url:'/api/{{authority.slug}}/signed/{{j.subject.CN}}/',type:'delete'});">Revoke</button> <button onClick="javascript:$.ajax({url:'/api/{{authority.slug}}/signed/{{j.subject.CN}}/',type:'delete'});">Revoke</button>
{% for key, value in j.get_extensions() %} <div class="monospace">
{{key}}={{value}}, {% include 'iconmonstr-certificate-15-icon.svg' %}
{% endfor %} {{j.distinguished_name}}
</div>
{% if j.email_address %}
<div class="email">{% include 'iconmonstr-email-2-icon.svg' %} {{ j.email_address }}</div>
{% endif %}
<div class="monospace">
{% include 'iconmonstr-key-2-icon.svg' %}
<span title="SHA-1 of public key">
{{ j.fingerprint() }}
</span>
{{ j.key_length }}-bit
{{ j.key_type }}
</div>
<div>
{% include 'iconmonstr-flag-3-icon.svg' %}
{{j.key_usage}}
</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<h1>Revoked certificates</h1> <h1>Revoked certificates</h1>
<p>To fetch certificate revocation list:</p>
<pre>
curl {{request.url}}/revoked/ | openssl crl -text -noout
</pre>
<!--
<p>To perform online certificate status request</p>
<pre>
curl {{request.url}}/certificate/ > ca.pem
openssl ocsp -issuer ca.pem -CAfile ca.pem -url {{request.url}}/ocsp/ -serial 0x
</pre>
-->
<ul> <ul>
{% for serial, reason, timestamp in authority.get_revoked() %} {% for j in authority.get_revoked() %}
<li>{{ serial}} {{ reason }} {{ timestamp}} </li> <li>
{{j.changed}}
{{j.serial_number}} <span class="monospace">{{j.distinguished_name}}</span>
</li>
{% else %}
<li>Great job! No certificate signing requests to sign.</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div>
</body>
<footer>
<a href="http://github.com/laurivosandi/certidude">Certidude</a> by
<a href="http://github.com/laurivosandi/">Lauri Võsandi</a>
</footer>
</html>

View File

@ -0,0 +1,32 @@
[CA_{{slug}}]
default_days = 1825
dir = {{directory}}
private_key = $dir/ca_key.pem
certificate = $dir/ca_crt.pem
new_certs_dir = $dir/requests/
revoked_certs_dir = $dir/revoked/
certs = $dir/signed/
crl = $dir/ca_crl.pem
serial = $dir/serial
{% if crl_distribution_points %}
crlDistributionPoints = {{crl_distribution_points}}{% endif %}
{% if email_address %}
emailAddress = {{email_address}}{% endif %}
x509_extensions = {{slug}}_cert
policy = poliy_{{slug}}
autosign_whitelist = 127.
inbox = {{inbox}}
outbox = {{outbox}}
[policy_{{slug}}]
countryName = match
stateOrProvinceName = match
organizationName = match
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[{{slug}}_cert]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation,digitalSignature,keyEncipherment
extendedKeyUsage = clientAuth

View File

@ -0,0 +1,16 @@
mode server
tls-server
proto {{proto}}
port {{port}}
dev tap0
local {{local}}
key {{key_path}}
cert {{certificate_path}}
ca {{authority_path}}
comp-lzo
user nobody
group nogroup
ifconfig-pool-persist /tmp/openvpn-leases.txt
ifconfig {{subnet_first}} {{subnet.netmask}}
server-bridge {{subnet_first}} {{subnet.netmask}} {{subnet_second}} {{subnet_last}}

View File

@ -1,33 +1,63 @@
import os import os
from OpenSSL import crypto
from datetime import datetime
import hashlib import hashlib
from Crypto.Util import asn1 import logging
import re import re
import itertools import itertools
import click
import socket
import io
import urllib.request
from configparser import RawConfigParser from configparser import RawConfigParser
from Crypto.Util import asn1
from OpenSSL import crypto
from datetime import datetime
from jinja2 import Environment, PackageLoader, Template
from certidude.mailer import Mailer
from certidude.signer import raw_sign, EXTENSION_WHITELIST
env = Environment(loader=PackageLoader("certidude", "email_templates"))
# https://securityblog.redhat.com/2014/06/18/openssl-privilege-separation-analysis/
# 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
def notify(func):
# TODO: Implement e-mail and nginx notifications using hooks
def wrapped(instance, csr, *args, **kwargs):
cert = func(instance, csr, *args, **kwargs)
assert isinstance(cert, Certificate), "notify wrapped function %s returned %s" % (func, type(cert))
url_template = os.getenv("CERTIDUDE_EVENT_PUBLISH")
if url_template:
url = url_template % csr.fingerprint()
notification = urllib.request.Request(url, cert.dump().encode("ascii"))
notification.add_header("User-Agent", "Certidude API")
notification.add_header("Content-Type", "application/x-x509-user-cert")
click.echo("Submitting notification to %s, waiting for response..." % url)
response = urllib.request.urlopen(notification)
response.read()
return cert
# TODO: Implement e-mailing
# self.mailer.send(
# self.certificate.email_address,
# (self.certificate.email_address, cert.email_address),
# "Certificate %s signed" % cert.distinguished_name,
# "certificate-signed",
# old_cert=old_cert,
# cert=cert,
# ca=self.certificate)
return wrapped
def subject2dn(subject): def subject2dn(subject):
bits = [] bits = []
for j in "C", "S", "L", "O", "OU", "CN": for j in "CN", "GN", "SN", "C", "S", "L", "O", "OU":
if getattr(subject, j, None): if getattr(subject, j, None):
bits.append("/%s=%s" % (j, getattr(subject, j))) bits.append("/%s=%s" % (j, getattr(subject, j)))
return "".join(bits) return "".join(bits)
class SerialCounter(object):
def __init__(self, filename):
self.path = filename
with open(filename, "r") as fh:
self.value = int(fh.read(), 16)
def increment(self):
self.value += 1
with open(self.path, "w") as fh:
fh.write("%04x" % self.value)
return self.value
class CertificateAuthorityConfig(object): class CertificateAuthorityConfig(object):
""" """
Attempt to parse CA-s from openssl.cnf Attempt to parse CA-s from openssl.cnf
@ -38,12 +68,17 @@ class CertificateAuthorityConfig(object):
for arg in args: for arg in args:
self._config.readfp(itertools.chain(["[global]"], open(os.path.expanduser(arg)))) self._config.readfp(itertools.chain(["[global]"], open(os.path.expanduser(arg))))
def get(self, section, key, default=""):
if self._config.has_option(section, key):
return self._config.get(section, key)
else:
return default
def instantiate_authority(self, slug): def instantiate_authority(self, slug):
section = "CA_" + slug section = "CA_" + slug
dirs = dict([(key, self._config.get(section, key) dirs = dict([(key, self.get(section, key))
if self._config.has_option(section, key) else "") 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", "serial", "private_key", "revoked_certs_dir")])
# Variable expansion, eg $dir # Variable expansion, eg $dir
for key, value in dirs.items(): for key, value in dirs.items():
@ -51,12 +86,26 @@ class CertificateAuthorityConfig(object):
dirs[key] = re.sub(r'\$([a-z]+)', lambda m:dirs[m.groups()[0]], value) dirs[key] = re.sub(r'\$([a-z]+)', lambda m:dirs[m.groups()[0]], value)
dirs.pop("dir") dirs.pop("dir")
return CertificateAuthority(slug, **dirs) dirs["email_address"] = self.get(section, "emailAddress")
dirs["inbox"] = self.get(section, "inbox")
dirs["outbox"] = self.get(section, "outbox")
dirs["lifetime"] = int(self.get(section, "default_days", "1825"))
extensions_section = self.get(section, "x509_extensions")
if extensions_section:
dirs["basic_constraints"] = self.get(extensions_section, "basicConstraints")
dirs["key_usage"] = self.get(extensions_section, "keyUsage")
dirs["extended_key_usage"] = self.get(extensions_section, "extendedKeyUsage")
authority = CertificateAuthority(slug, **dirs)
return authority
def all_authorities(self): def all_authorities(self):
for section in self._config: for section in self._config:
if section.startswith("CA_"): if section.startswith("CA_"):
yield self.instantiate_authority(section[3:]) try:
yield self.instantiate_authority(section[3:])
except FileNotFoundError:
pass
def pop_certificate_authority(self): def pop_certificate_authority(self):
def wrapper(func): def wrapper(func):
@ -68,215 +117,461 @@ class CertificateAuthorityConfig(object):
return wrapper return wrapper
class CertificateBase: class CertificateBase:
def get_issuer_dn(self): @property
def given_name(self):
return self.subject.GN
@given_name.setter
def given_name(self, value):
return setattr(self.subject, "GN", value)
@property
def surname(self):
return self.subject.SN
@surname.setter
def surname(self, value):
return setattr(self.subject, "SN", value)
@property
def common_name(self):
return self.subject.CN
@common_name.setter
def common_name(self, value):
return setattr(self._obj.get_subject(), "CN", value)
@property
def country_code(self):
return getattr(self._obj.get_subject(), "C", None)
@property
def state_or_county(self):
return getattr(self._obj.get_subject(), "S", None)
@property
def city(self):
return getattr(self._obj.get_subject(), "L", None)
@property
def organization(self):
return getattr(self._obj.get_subject(), "O", None)
@property
def organizational_unit(self):
return getattr(self._obj.get_subject(), "OU", None)
@country_code.setter
def country_code(self, value):
return setattr(self._obj.get_subject(), "C", value)
@state_or_county.setter
def state_or_county(self, value):
return setattr(self._obj.get_subject(), "S", value)
@city.setter
def city(self, value):
return setattr(self._obj.get_subject(), "L", value)
@organization.setter
def organization(self, value):
return setattr(self._obj.get_subject(), "O", value)
@organizational_unit.setter
def organizational_unit(self, value):
return setattr(self._obj.get_subject(), "OU", value)
@property
def key_usage(self):
def iterate():
for key, value, data in self.extensions:
if key == "keyUsage" or key == "extendedKeyUsage":
yield value
return ", ".join(iterate())
@property
def subject(self):
return self._obj.get_subject()
@property
def issuer(self):
return self._obj.get_issuer()
@property
def issuer_dn(self):
return subject2dn(self.issuer) return subject2dn(self.issuer)
def get_dn(self): @property
def distinguished_name(self):
return subject2dn(self.subject) return subject2dn(self.subject)
@property
def key_length(self): def key_length(self):
return self._obj.get_pubkey().bits() return self._obj.get_pubkey().bits()
@property
def key_type(self): def key_type(self):
if self._obj.get_pubkey().type() == 6: if self._obj.get_pubkey().type() == 6:
return "RSA" return "RSA"
else: else:
raise NotImplementedError() raise NotImplementedError()
def get_pubkey(self): @property
pub_asn1=crypto.dump_privatekey(crypto.FILETYPE_ASN1, self._obj.get_pubkey()) def extensions(self):
pub_der=asn1.DerSequence() for e in self._obj.get_extensions():
pub_der.decode(pub_asn1) yield e.get_short_name().decode("ascii"), str(e), e.get_data()
return pub_der[1]
def get_pubkey_hex(self): def set_extensions(self, extensions):
h = "%x" % self.get_pubkey() # X509Req().add_extensions() first invocation takes only effect?!
assert len(h) * 4 == self.key_length(), "%s is not %s" % (len(h)*4, self.key_length()) assert self._obj.get_extensions() == [], "Extensions already set!"
return ":".join(re.findall("..", "%x" % self.get_pubkey()))
def get_pubkey_fingerprint(self): self._obj.add_extensions([
crypto.X509Extension(
key.encode("ascii"),
critical,
value.encode("ascii")) for (key,value,critical) in extensions])
@property
def email_address(self):
for bit in self.subject_alt_name.split(", "):
if bit.startswith("email:"):
return bit[6:]
return ""
@property
def subject_alt_name(self):
for key, value, data in self.extensions:
if key == "subjectAltName":
return value
return ""
@subject_alt_name.setter
def subject_alt_name(self, value):
self.set_extension("subjectAltName", value, False)
@property
def pubkey(self):
pubkey_asn1=crypto.dump_privatekey(crypto.FILETYPE_ASN1, self._obj.get_pubkey())
pubkey_der=asn1.DerSequence()
pubkey_der.decode(pubkey_asn1)
zero, modulo, exponent = pubkey_der
return modulo, exponent
@property
def pubkey_hex(self):
modulo, exponent = self.pubkey
h = "%x" % modulo
assert len(h) * 4 == self.key_length, "%s is not %s" % (len(h)*4, self.key_length)
return re.findall("\d\d", h)
def fingerprint(self):
import binascii import binascii
return ":".join(re.findall("..", hashlib.sha1(binascii.unhexlify("%x" % self.get_pubkey())).hexdigest())) m, _ = self.pubkey
return ":".join(re.findall("..", hashlib.sha1(binascii.unhexlify("%x" % m)).hexdigest()))
class Request(CertificateBase):
def __init__(self, mixed=None):
self.buf = None
self.path = NotImplemented
self.created = NotImplemented
if isinstance(mixed, io.TextIOWrapper):
self.path = mixed.name
_, _, _, _, _, _, _, _, mtime, _ = os.stat(self.path)
self.created = datetime.fromtimestamp(mtime)
mixed = mixed.read()
if isinstance(mixed, bytes):
mixed = mixed.decode("ascii")
if isinstance(mixed, str):
try:
self.buf = mixed
mixed = crypto.load_certificate_request(crypto.FILETYPE_PEM, mixed)
except crypto.Error:
print("Failed to parse:", mixed)
raise
if isinstance(mixed, crypto.X509Req):
self._obj = mixed
else:
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())
@property
def signable(self):
for key, value, data in self.extensions:
if key not in EXTENSION_WHITELIST:
return False
return True
def dump(self):
return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self._obj).decode("ascii")
def __repr__(self):
return "Request(%s)" % repr(self.path)
def create(self):
# Generate 4096-bit RSA key
key = crypto.PKey()
key.generate_key(crypto.TYPE_RSA, 4096)
# Create request
req = crypto.X509Req()
req.set_pubkey(key)
return Request(req)
class Certificate(CertificateBase): class Certificate(CertificateBase):
def __init__(self, filename, authority=None): def __init__(self, mixed):
self.path = os.path.realpath(filename) self.buf = NotImplemented
try: self.path = NotImplemented
self._obj = crypto.load_certificate(crypto.FILETYPE_PEM, open(filename).read()) self.changed = NotImplemented
except crypto.Error:
click.echo("Failed to parse certificate: %s" % filename)
raise
self.not_before = datetime.strptime(self._obj.get_notBefore().decode("ascii"), "%Y%m%d%H%M%SZ")
self.not_after = datetime.strptime(self._obj.get_notAfter().decode("ascii"), "%Y%m%d%H%M%SZ")
self.subject = self._obj.get_subject()
self.issuer = self._obj.get_issuer()
self.serial = self._obj.get_serial_number()
self.authority = authority
self.subject_key_identifier = None
def get_extensions(self): if isinstance(mixed, io.TextIOWrapper):
for i in range(1, self._obj.get_extension_count()): self.path = mixed.name
ext = self._obj.get_extension(i) _, _, _, _, _, _, _, _, _, ctime = os.stat(self.path)
yield ext.get_short_name(), str(ext) self.changed = datetime.fromtimestamp(ctime)
mixed = mixed.read()
if isinstance(mixed, str):
try:
self.buf = mixed
mixed = crypto.load_certificate(crypto.FILETYPE_PEM, mixed)
except crypto.Error:
print("Failed to parse:", mixed)
raise
if isinstance(mixed, crypto.X509):
self._obj = mixed
else:
raise ValueError("Can't parse %s as X.509 certificate!" % mixed)
assert not self.buf or self.buf == self.dump(), "%s is not %s" % (self.buf, self.dump())
@property
def extensions(self):
# WTF?!
for j in range(1, self._obj.get_extension_count()):
e = self._obj.get_extension(j)
yield e.get_short_name().decode("ascii"), str(e), e.get_data()
@property
def serial_number(self):
return "%040x" % self._obj.get_serial_number()
@property
def signed(self):
return datetime.strptime(self._obj.get_notBefore().decode("ascii"), "%Y%m%d%H%M%SZ")
@property
def expires(self):
return datetime.strptime(self._obj.get_notAfter().decode("ascii"), "%Y%m%d%H%M%SZ")
def dump(self):
return crypto.dump_certificate(crypto.FILETYPE_PEM, self._obj).decode("ascii")
def digest(self): def digest(self):
return self._obj.digest("md5").decode("ascii") return self._obj.digest("md5").decode("ascii")
def __eq__(self, other): def __eq__(self, other):
return self.serial == other.serial return self.serial_number == other.serial_number
def __gt__(self, other): def __gt__(self, other):
return self.serial > other.serial return self.signed > other.signed
def __lt__(self, other): def __lt__(self, other):
return self.serial < other.serial return self.signed < other.signed
def __gte__(self, other): def __gte__(self, other):
return self.serial >= other.serial return self.signed >= other.signed
def __lte__(self, other): def __lte__(self, other):
return self.serial <= other.serial return self.signed <= other.signed
def lock_crl(func):
def wrapped(ca, *args, **kwargs):
# TODO: Implement actual locking!
try:
crl = crypto.load_crl(crypto.FILETYPE_PEM, open(ca.revocation_list).read())
except crypto.Error:
click.echo("Failed to parse CRL in %s" % ca.revocation_list)
raise
count = len(crl.get_revoked() or ())
retval = func(ca, crl, *args, **kwargs)
if count != len(crl.get_revoked() or ()):
click.echo("Updating CRL")
partial = ca.revocation_list + ".part"
with open(partial, "wb") as fh:
fh.write(crl.export(
ca.certificate._obj,
crypto.load_privatekey(crypto.FILETYPE_PEM, open(ca.private_key).read()),
crypto.FILETYPE_PEM))
os.rename(partial, ca.revocation_list)
return retval
return wrapped
class Request(CertificateBase):
def __init__(self, request_path):
self.path = request_path
self._obj = crypto.load_certificate_request(crypto.FILETYPE_PEM, open(self.path).read())
self.subject = self._obj.get_subject()
"""
pub_asn1=crypto.dump_privatekey(crypto.FILETYPE_ASN1, self._obj.get_pubkey())
pub_der=asn1.DerSequence()
pub_der.decode(pub_asn1)
n=pub_der[1]
# Get the modulus
print("public modulus: %x" % n)
import binascii
self.sha_hash = hashlib.sha1(binascii.unhexlify("%x" % n)).hexdigest()
"""
def __repr__(self):
return "Request(%s)" % repr(self.path)
class CertificateAuthority(object): class CertificateAuthority(object):
def __init__(self, slug, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, serial=None, private_key=None):
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):
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.certificate = Certificate(open(certificate))
self.mailer = Mailer(outbox) if outbox else None
self.lifetime = lifetime
self.basic_constraints = basic_constraints
self.key_usage = key_usage
self.extended_key_usage = extended_key_usage
if isinstance(certificate, str): def autosign_allowed(self, addr):
self.certificate = Certificate(certificate, self) for j in self.autosign_whitelist:
else: if j.endswith(".") and addr.startswith(j):
self.certificate = certificate return True
elif j == addr:
if isinstance(serial, str): return True
self.serial_counter=SerialCounter(serial) return False
else:
self.serial_counter=serial
def _signer_exec(self, cmd, *bits):
sock = self.connect_signer()
sock.send(cmd.encode("ascii"))
sock.send(b"\n")
for bit in bits:
sock.send(bit.encode("ascii"))
sock.sendall(b"\n\n")
buf = sock.recv(8192)
if not buf:
raise
return buf
def __repr__(self): def __repr__(self):
return "CertificateAuthority(slug=%s)" % repr(self.slug) return "CertificateAuthority(slug=%s)" % repr(self.slug)
def get_request(self, cn):
return Request(os.path.join(self.request_dir, cn + ".pem"))
def get_certificate(self, cn): def get_certificate(self, cn):
return Certificate(os.path.join(self.signed_dir, cn + ".pem")) return open(os.path.join(self.signed_dir, cn + ".pem")).read()
@lock_crl def connect_signer(self):
def revoke(self, crl, cn): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
certificate = self.get_certificate(cn) sock.connect("/run/certidude/signer/%s.sock" % self.slug)
revocation = crypto.Revoked() return sock
revocation.set_rev_date(datetime.now().strftime("%Y%m%d%H%M%SZ").encode("ascii"))
revocation.set_reason(b"keyCompromise")
revocation.set_serial(("%x" % certificate.serial).encode("ascii"))
if self.revoked_dir:
os.rename(certificate.path, self.revoked_dir)
else:
os.unlink(certificate.path)
crl.add_revoked(revocation)
@lock_crl def revoke(self, cn):
def get_revoked(self, crl): cert = Certificate(open(os.path.join(self.signed_dir, cn + ".pem")))
for revocation in crl.get_revoked() or (): revoked_filename = os.path.join(self.revoked_dir, "%s.pem" % cert.serial_number)
yield int(revocation.get_serial(), 16), \ os.rename(cert.path, revoked_filename)
revocation.get_reason().decode("ascii"), \
datetime.strptime(revocation.get_rev_date().decode("ascii"), "%Y%m%d%H%M%SZ") def get_revoked(self):
for root, dirs, files in os.walk(self.revoked_dir):
for filename in files:
if filename.endswith(".pem"):
yield Certificate(open(os.path.join(root, filename)))
break
def get_signed(self): def get_signed(self):
for root, dirs, files in os.walk(self.signed_dir): for root, dirs, files in os.walk(self.signed_dir):
for filename in files: for filename in files:
yield Certificate(os.path.join(root, filename)) if filename.endswith(".pem"):
yield Certificate(open(os.path.join(root, filename)))
break break
def get_requests(self): def get_requests(self):
for root, dirs, files in os.walk(self.request_dir): for root, dirs, files in os.walk(self.request_dir):
for filename in files: for filename in files:
yield Request(os.path.join(root, filename)) if filename.endswith(".pem"):
yield Request(open(os.path.join(root, filename)))
break
def sign(self, request, lifetime=5*365*24*60*60): def get_request(self, cn):
cert = crypto.X509() return Request(open(os.path.join(self.request_dir, cn + ".pem")))
cert.add_extensions([
crypto.X509Extension(
b"basicConstraints",
True,
b"CA:FALSE, pathlen:0"),
crypto.X509Extension(
b"keyUsage",
True,
b"digitalSignature, keyEncipherment"),
crypto.X509Extension(
b"subjectKeyIdentifier",
False,
b"hash",
subject = self.certificate._obj),
crypto.X509Extension(
b"authorityKeyIdentifier",
False,
b"keyid:always",
issuer = self.certificate._obj)])
cert.set_pubkey(request._obj.get_pubkey())
cert.set_subject(request._obj.get_subject())
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(lifetime)
cert.set_serial_number(self.serial_counter.increment())
pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, open(self.private_key).read()) def store_request(self, buf, overwrite=False):
cert.sign(pkey, 'sha1') request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf)
common_name = request.get_subject().CN
request_path = os.path.join(self.request_dir, common_name + ".pem")
path = os.path.join(self.signed_dir, request.subject.CN + ".pem") # If there is cert, check if it's the same
assert not os.path.exists(path), "File %s already exists!" % path if os.path.exists(request_path):
if open(request_path, "rb").read() != buf:
print("Request already exists, not creating new request")
raise FileExistsError("Request already exists")
else:
with open(request_path + ".part", "wb") as fh:
fh.write(buf)
os.rename(request_path + ".part", request_path)
return Request(open(request_path))
def request_exists(self, cn):
return os.path.exists(os.path.join(self.request_dir, cn + ".pem"))
def delete_request(self, cn):
os.unlink(os.path.join(self.request_dir, cn + ".pem"))
def create_bundle(self, common_name, organizational_unit=None, email_address=None, overwrite=True):
req = Request.create()
req.country = self.certificate.country
req.state_or_county = self.certificate.state_or_county
req.city = self.certificate.city
req.organization = self.certificate.organization
req.organizational_unit = organizational_unit or self.certificate.organizational_unit
req.common_name = common_name
req.email_address = email_address
cert_buf = self.sign(req, overwrite)
return crypto.dump_privatekey(crypto.FILETYPE_PEM, key).decode("ascii"), \
req_buf, cert_buf
@notify
def sign(self, req, overwrite=False, delete=True):
"""
Sign certificate signing request via signer process
"""
cert_path = os.path.join(self.signed_dir, req.common_name + ".pem")
# Move existing certificate if necessary
if os.path.exists(cert_path):
old_cert = Certificate(open(cert_path))
if overwrite:
self.revoke(req.common_name)
elif req.pubkey == old_cert.pubkey:
return old_cert
else:
raise FileExistsError("Will not overwrite existing certificate")
# Sign via signer process
cert_buf = self._signer_exec("sign-request", req.dump())
with open(cert_path + ".part", "wb") as fh:
fh.write(cert_buf)
os.rename(cert_path + ".part", cert_path)
return Certificate(open(cert_path))
@notify
def sign2(self, request, overwrite=False, delete=True, lifetime=None):
"""
Sign directly using private key, this is usually done by root.
Basic constraints and certificate lifetime are copied from openssl.cnf,
lifetime may be overridden on the command line,
other extensions are copied as is.
"""
cert = raw_sign(
crypto.load_privatekey(crypto.FILETYPE_PEM, open(self.private_key).read()),
self.certificate._obj,
request._obj,
self.basic_constraints,
lifetime=lifetime or self.lifetime)
path = os.path.join(self.signed_dir, request.common_name + ".pem")
if os.path.exists(path):
if overwrite:
self.revoke(request.common_name)
else:
raise FileExistsError("File %s already exists!" % path)
buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
with open(path + ".part", "wb") as fh: with open(path + ".part", "wb") as fh:
fh.write(buf) fh.write(buf)
os.rename(path + ".part", path) os.rename(path + ".part", path)
click.echo("Wrote certififcate to: %s" % path) click.echo("Wrote certificate to: %s" % path)
os.unlink(request.path) if delete:
click.echo("Deleted request: %s" % request.path) os.unlink(request.path)
click.echo("Deleted request: %s" % request.path)
return Certificate(open(path))
def export_crl(self):
sock = self.connect_signer()
sock.send(b"export-crl\n")
for filename in os.listdir(self.revoked_dir):
if not filename.endswith(".pem"):
continue
serial_number = filename[:-4]
# TODO: Assert serial against regex
revoked_path = os.path.join(self.revoked_dir, filename)
# TODO: Skip expired certificates
s = os.stat(revoked_path)
sock.send(("%s:%d\n" % (serial_number, s.st_ctime)).encode("ascii"))
sock.sendall(b"\n")
return sock.recv(32*1024*1024)

View File

@ -17,9 +17,14 @@ setup(
], ],
long_description=open("README.rst").read(), long_description=open("README.rst").read(),
install_requires=[ install_requires=[
"setproctitle",
"click", "click",
"falcon", "falcon",
"jinja2" "jinja2",
"netifaces",
"pyopenssl",
"pycountry",
"humanize"
], ],
scripts=[ scripts=[
"misc/certidude" "misc/certidude"