Implemented essential functionality
@ -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
|
||||||
|
128
README.rst
@ -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
|
||||||
|
227
certidude/api.py
@ -3,41 +3,51 @@ 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])$"
|
||||||
|
|
||||||
def omit(**kwargs):
|
def omit(**kwargs):
|
||||||
return dict([(key,value) for (key, value) in kwargs.items() if value])
|
return dict([(key,value) for (key, value) in kwargs.items() if value])
|
||||||
|
|
||||||
def pop_certificate_authority(func):
|
def pop_certificate_authority(func):
|
||||||
def wrapped(self, req, resp, *args, **kwargs):
|
def wrapped(self, req, resp, *args, **kwargs):
|
||||||
kwargs["ca"] = self.config.instantiate_authority(kwargs["ca"])
|
kwargs["ca"] = self.config.instantiate_authority(kwargs["ca"])
|
||||||
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"]):
|
||||||
raise falcon.HTTPBadRequest("Invalid CN", "Common name supplied with request didn't pass the validation regex")
|
raise falcon.HTTPBadRequest("Invalid CN", "Common name supplied with request didn't pass the validation regex")
|
||||||
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
|
||||||
@ -92,7 +113,7 @@ class SignedCertificateDetailResource(CertificateAuthorityBase):
|
|||||||
raise falcon.HTTPNotFound()
|
raise falcon.HTTPNotFound()
|
||||||
resp.stream = open(path, "rb")
|
resp.stream = open(path, "rb")
|
||||||
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % cn)
|
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % cn)
|
||||||
|
|
||||||
@pop_certificate_authority
|
@pop_certificate_authority
|
||||||
@validate_common_name
|
@validate_common_name
|
||||||
def on_delete(self, req, resp, ca, cn):
|
def on_delete(self, req, resp, ca, cn):
|
||||||
@ -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,22 +149,25 @@ 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
|
||||||
@validate_common_name
|
@validate_common_name
|
||||||
def on_patch(self, req, resp, ca, cn):
|
def on_patch(self, req, resp, ca, cn):
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
common_name = csr.get_subject().CN
|
if cert.pubkey == csr.pubkey:
|
||||||
|
resp.status = falcon.HTTP_FOUND
|
||||||
if not re.match(RE_HOSTNAME, common_name):
|
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name)
|
||||||
raise falcon.HTTPBadRequest("Invalid CN", "Common name supplied with CSR did not match validation regex")
|
return
|
||||||
|
|
||||||
path = os.path.join(ca.request_dir, common_name + ".pem")
|
# TODO: check for revoked certificates and return HTTP 410 Gone
|
||||||
with open(path, "wb") as fh:
|
|
||||||
fh.write(crypto.dump_certificate_request(
|
# Process automatic signing if the IP address is whitelisted and autosigning was requested
|
||||||
crypto.FILETYPE_PEM, csr))
|
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
|
||||||
|
|
||||||
|
# Attempt to save the request otherwise
|
||||||
|
try:
|
||||||
|
request = ca.store_request(body)
|
||||||
|
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)
|
||||||
|
|
||||||
|
714
certidude/cli.py
@ -1,54 +1,480 @@
|
|||||||
#!/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:
|
||||||
raise NotImplementedError("Hardware token support not yet implemented!")
|
raise NotImplementedError("Hardware token support not yet implemented!")
|
||||||
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.echo(" ✓ Certificate valid %s" % (ca.certificate.not_after - NOW))
|
@click.command("list", help="List certificates")
|
||||||
elif NOW > ca.certificate.not_after:
|
@click.argument("ca", nargs=-1)
|
||||||
click.echo(" ✗ Certificate expired")
|
@click.option("--show-key-type", "-k", default=False, is_flag=True, help="Show key type and length")
|
||||||
|
@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,8 +677,9 @@ 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)
|
||||||
|
|
||||||
for serial, reason, timestamp in ca.get_revoked():
|
for serial, reason, timestamp in ca.get_revoked():
|
||||||
@ -164,8 +687,8 @@ def cert_list(ca):
|
|||||||
|
|
||||||
for certificate in ca.get_signed():
|
for certificate in ca.get_signed():
|
||||||
mapping[certificate.serial] = certificate, None
|
mapping[certificate.serial] = certificate, None
|
||||||
|
|
||||||
for serial, (certificate, reason) in sorted(mapping.items(), key=lambda j:j[0]):
|
for serial, (certificate, reason) in sorted(mapping.items(), key=lambda j:j[0]):
|
||||||
if not reason:
|
if not reason:
|
||||||
click.echo(" %03d. %s %s" % (serial, certificate.subject.CN, (certificate.not_after-NOW)))
|
click.echo(" %03d. %s %s" % (serial, certificate.subject.CN, (certificate.not_after-NOW)))
|
||||||
else:
|
else:
|
||||||
@ -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
@ -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
@ -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)
|
||||||
|
|
12
certidude/templates/client-to-site.ovpn
Normal 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
|
||||||
|
|
@ -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 |
21
certidude/templates/iconmonstr-email-2-icon.svg
Normal 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 |
11
certidude/templates/iconmonstr-flag-3-icon.svg
Normal 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 |
15
certidude/templates/iconmonstr-key-2-icon.svg
Normal 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 |
@ -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 |
@ -9,17 +9,22 @@
|
|||||||
<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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 1em 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
button, .button {
|
button, .button {
|
||||||
color: #000;
|
color: #000;
|
||||||
float: right;
|
float: right;
|
||||||
@ -31,50 +36,53 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.monospace {
|
button:disabled, .button:disabled {
|
||||||
font-family: 'Ubuntu Mono', monospace;
|
color: #888;
|
||||||
font-size: 80%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.monospace {
|
||||||
|
font-family: 'Ubuntu Mono', courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
display: block;
|
display: block;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #44c;
|
color: #44c;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer a {
|
footer a {
|
||||||
color: #aaf;
|
color: #aaf;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,body {
|
html,body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 0 1em 0;
|
padding: 0 0 1em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #222;
|
background: #222;
|
||||||
background-image: url('http://fc00.deviantart.net/fs71/i/2013/078/9/6/free_hexa_pattern_cc0_by_black_light_studio-d4ig12f.png');
|
background-image: url('http://fc00.deviantart.net/fs71/i/2013/078/9/6/free_hexa_pattern_cc0_by_black_light_studio-d4ig12f.png');
|
||||||
background-position: center;
|
background-position: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment {
|
.comment {
|
||||||
color: #aaf;
|
color: #aaf;
|
||||||
}
|
}
|
||||||
|
|
||||||
table th, table td {
|
table th, table td {
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, th {
|
h1, h2, th {
|
||||||
font-family: 'Gentium';
|
font-family: 'Gentium';
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 22pt;
|
font-size: 22pt;
|
||||||
@ -83,17 +91,17 @@
|
|||||||
h2 {
|
h2 {
|
||||||
font-size: 18pt;
|
font-size: 18pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 svg {
|
h2 svg {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p, td, footer, li, button {
|
p, td, footer, li, button {
|
||||||
font-family: 'PT Sans Narrow';
|
font-family: 'PT Sans Narrow';
|
||||||
font-size: 14pt;
|
font-size: 14pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #000;
|
border: 1px solid #000;
|
||||||
@ -104,10 +112,10 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin: 0 0;
|
margin: 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container {
|
|
||||||
|
|
||||||
margin: 1em;
|
#container {
|
||||||
|
max-width: 60em;
|
||||||
|
margin: 1em auto;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
@ -115,7 +123,7 @@
|
|||||||
border-color: #aaa;
|
border-color: #aaa;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
@ -123,14 +131,7 @@
|
|||||||
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>
|
||||||
<body>
|
<body>
|
||||||
@ -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>
|
||||||
|
|
||||||
|
32
certidude/templates/openssl.cnf
Normal 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
|
16
certidude/templates/site-to-client.ovpn
Normal 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}}
|
||||||
|
|
@ -1,62 +1,111 @@
|
|||||||
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args):
|
def __init__(self, *args):
|
||||||
self._config = RawConfigParser()
|
self._config = RawConfigParser()
|
||||||
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():
|
||||||
if "$" in value:
|
if "$" in value:
|
||||||
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):
|
|
||||||
pub_asn1=crypto.dump_privatekey(crypto.FILETYPE_ASN1, self._obj.get_pubkey())
|
|
||||||
pub_der=asn1.DerSequence()
|
|
||||||
pub_der.decode(pub_asn1)
|
|
||||||
return pub_der[1]
|
|
||||||
|
|
||||||
def get_pubkey_hex(self):
|
|
||||||
h = "%x" % self.get_pubkey()
|
|
||||||
assert len(h) * 4 == self.key_length(), "%s is not %s" % (len(h)*4, self.key_length())
|
|
||||||
return ":".join(re.findall("..", "%x" % self.get_pubkey()))
|
|
||||||
|
|
||||||
def get_pubkey_fingerprint(self):
|
@property
|
||||||
|
def extensions(self):
|
||||||
|
for e in self._obj.get_extensions():
|
||||||
|
yield e.get_short_name().decode("ascii"), str(e), e.get_data()
|
||||||
|
|
||||||
|
def set_extensions(self, extensions):
|
||||||
|
# X509Req().add_extensions() first invocation takes only effect?!
|
||||||
|
assert self._obj.get_extensions() == [], "Extensions already set!"
|
||||||
|
|
||||||
|
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 Certificate(CertificateBase):
|
|
||||||
def __init__(self, filename, authority=None):
|
|
||||||
self.path = os.path.realpath(filename)
|
|
||||||
try:
|
|
||||||
self._obj = crypto.load_certificate(crypto.FILETYPE_PEM, open(filename).read())
|
|
||||||
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):
|
|
||||||
for i in range(1, self._obj.get_extension_count()):
|
|
||||||
ext = self._obj.get_extension(i)
|
|
||||||
yield ext.get_short_name(), str(ext)
|
|
||||||
|
|
||||||
def digest(self):
|
|
||||||
return self._obj.digest("md5").decode("ascii")
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return self.serial == other.serial
|
|
||||||
|
|
||||||
def __gt__(self, other):
|
|
||||||
return self.serial > other.serial
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return self.serial < other.serial
|
|
||||||
|
|
||||||
def __gte__(self, other):
|
|
||||||
return self.serial >= other.serial
|
|
||||||
|
|
||||||
def __lte__(self, other):
|
|
||||||
return self.serial <= other.serial
|
|
||||||
|
|
||||||
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):
|
class Request(CertificateBase):
|
||||||
def __init__(self, request_path):
|
def __init__(self, mixed=None):
|
||||||
self.path = request_path
|
self.buf = None
|
||||||
self._obj = crypto.load_certificate_request(crypto.FILETYPE_PEM, open(self.path).read())
|
self.path = NotImplemented
|
||||||
self.subject = self._obj.get_subject()
|
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")
|
||||||
|
|
||||||
"""
|
|
||||||
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):
|
def __repr__(self):
|
||||||
return "Request(%s)" % repr(self.path)
|
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):
|
||||||
|
def __init__(self, mixed):
|
||||||
|
self.buf = NotImplemented
|
||||||
|
self.path = NotImplemented
|
||||||
|
self.changed = NotImplemented
|
||||||
|
|
||||||
|
if isinstance(mixed, io.TextIOWrapper):
|
||||||
|
self.path = mixed.name
|
||||||
|
_, _, _, _, _, _, _, _, _, ctime = os.stat(self.path)
|
||||||
|
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):
|
||||||
|
return self._obj.digest("md5").decode("ascii")
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.serial_number == other.serial_number
|
||||||
|
|
||||||
|
def __gt__(self, other):
|
||||||
|
return self.signed > other.signed
|
||||||
|
|
||||||
|
def __lt__(self, other):
|
||||||
|
return self.signed < other.signed
|
||||||
|
|
||||||
|
def __gte__(self, other):
|
||||||
|
return self.signed >= other.signed
|
||||||
|
|
||||||
|
def __lte__(self, other):
|
||||||
|
return self.signed <= other.signed
|
||||||
|
|
||||||
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])
|
||||||
if isinstance(certificate, str):
|
self.certificate = Certificate(open(certificate))
|
||||||
self.certificate = Certificate(certificate, self)
|
self.mailer = Mailer(outbox) if outbox else None
|
||||||
else:
|
self.lifetime = lifetime
|
||||||
self.certificate = certificate
|
self.basic_constraints = basic_constraints
|
||||||
|
self.key_usage = key_usage
|
||||||
|
self.extended_key_usage = extended_key_usage
|
||||||
|
|
||||||
if isinstance(serial, str):
|
def autosign_allowed(self, addr):
|
||||||
self.serial_counter=SerialCounter(serial)
|
for j in self.autosign_whitelist:
|
||||||
else:
|
if j.endswith(".") and addr.startswith(j):
|
||||||
self.serial_counter=serial
|
return True
|
||||||
|
elif j == addr:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
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")
|
def revoke(self, cn):
|
||||||
revocation.set_serial(("%x" % certificate.serial).encode("ascii"))
|
cert = Certificate(open(os.path.join(self.signed_dir, cn + ".pem")))
|
||||||
if self.revoked_dir:
|
revoked_filename = os.path.join(self.revoked_dir, "%s.pem" % cert.serial_number)
|
||||||
os.rename(certificate.path, self.revoked_dir)
|
os.rename(cert.path, revoked_filename)
|
||||||
else:
|
|
||||||
os.unlink(certificate.path)
|
def get_revoked(self):
|
||||||
crl.add_revoked(revocation)
|
for root, dirs, files in os.walk(self.revoked_dir):
|
||||||
|
for filename in files:
|
||||||
@lock_crl
|
if filename.endswith(".pem"):
|
||||||
def get_revoked(self, crl):
|
yield Certificate(open(os.path.join(root, filename)))
|
||||||
for revocation in crl.get_revoked() or ():
|
break
|
||||||
yield int(revocation.get_serial(), 16), \
|
|
||||||
revocation.get_reason().decode("ascii"), \
|
|
||||||
datetime.strptime(revocation.get_rev_date().decode("ascii"), "%Y%m%d%H%M%SZ")
|
|
||||||
|
|
||||||
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)))
|
||||||
def sign(self, request, lifetime=5*365*24*60*60):
|
break
|
||||||
cert = crypto.X509()
|
|
||||||
cert.add_extensions([
|
def get_request(self, cn):
|
||||||
crypto.X509Extension(
|
return Request(open(os.path.join(self.request_dir, cn + ".pem")))
|
||||||
b"basicConstraints",
|
|
||||||
True,
|
def store_request(self, buf, overwrite=False):
|
||||||
b"CA:FALSE, pathlen:0"),
|
request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf)
|
||||||
crypto.X509Extension(
|
common_name = request.get_subject().CN
|
||||||
b"keyUsage",
|
request_path = os.path.join(self.request_dir, common_name + ".pem")
|
||||||
True,
|
|
||||||
b"digitalSignature, keyEncipherment"),
|
# If there is cert, check if it's the same
|
||||||
crypto.X509Extension(
|
if os.path.exists(request_path):
|
||||||
b"subjectKeyIdentifier",
|
if open(request_path, "rb").read() != buf:
|
||||||
False,
|
print("Request already exists, not creating new request")
|
||||||
b"hash",
|
raise FileExistsError("Request already exists")
|
||||||
subject = self.certificate._obj),
|
else:
|
||||||
crypto.X509Extension(
|
with open(request_path + ".part", "wb") as fh:
|
||||||
b"authorityKeyIdentifier",
|
fh.write(buf)
|
||||||
False,
|
os.rename(request_path + ".part", request_path)
|
||||||
b"keyid:always",
|
|
||||||
issuer = self.certificate._obj)])
|
return Request(open(request_path))
|
||||||
cert.set_pubkey(request._obj.get_pubkey())
|
|
||||||
cert.set_subject(request._obj.get_subject())
|
def request_exists(self, cn):
|
||||||
cert.gmtime_adj_notBefore(0)
|
return os.path.exists(os.path.join(self.request_dir, cn + ".pem"))
|
||||||
cert.gmtime_adj_notAfter(lifetime)
|
|
||||||
cert.set_serial_number(self.serial_counter.increment())
|
def delete_request(self, cn):
|
||||||
|
os.unlink(os.path.join(self.request_dir, cn + ".pem"))
|
||||||
pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, open(self.private_key).read())
|
|
||||||
cert.sign(pkey, 'sha1')
|
def create_bundle(self, common_name, organizational_unit=None, email_address=None, overwrite=True):
|
||||||
|
req = Request.create()
|
||||||
path = os.path.join(self.signed_dir, request.subject.CN + ".pem")
|
req.country = self.certificate.country
|
||||||
assert not os.path.exists(path), "File %s already exists!" % path
|
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)
|
||||||
|
|
||||||
|
7
setup.py
@ -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"
|
||||||
|