mirror of
https://github.com/laurivosandi/certidude
synced 2024-12-22 08:15:18 +00:00
Refactor codebase
* Replace PyOpenSSL with cryptography.io * Rename constants to const * Drop support for uwsgi * Use systemd to launch certidude server * Signer automatically spawned as part of server * Update requirements.txt * Clean up certidude client configuration handling * Add automatic enroll with Kerberos machine cerdentials
This commit is contained in:
parent
15858083b3
commit
b4d006227a
@ -1,4 +1,6 @@
|
|||||||
include README.rst
|
include README.rst
|
||||||
|
include certidude/templates/*.sh
|
||||||
|
include certidude/templates/*.service
|
||||||
include certidude/templates/*.ovpn
|
include certidude/templates/*.ovpn
|
||||||
include certidude/templates/*.conf
|
include certidude/templates/*.conf
|
||||||
include certidude/templates/*.ini
|
include certidude/templates/*.ini
|
||||||
|
27
README.rst
27
README.rst
@ -79,7 +79,8 @@ To install Certidude:
|
|||||||
python-pysqlite2 python-mysql.connector python-ldap \
|
python-pysqlite2 python-mysql.connector python-ldap \
|
||||||
build-essential libffi-dev libssl-dev libkrb5-dev \
|
build-essential libffi-dev libssl-dev libkrb5-dev \
|
||||||
ldap-utils krb5-user \
|
ldap-utils krb5-user \
|
||||||
libsasl2-modules-gssapi-mit
|
libsasl2-modules-gssapi-mit \
|
||||||
|
libsasl2-dev libldap2-dev
|
||||||
pip install certidude
|
pip install certidude
|
||||||
|
|
||||||
|
|
||||||
@ -103,17 +104,18 @@ If necessary tweak machine's fully qualified hostname in ``/etc/hosts``:
|
|||||||
127.0.0.1 localhost
|
127.0.0.1 localhost
|
||||||
127.0.1.1 ca.example.com ca
|
127.0.1.1 ca.example.com ca
|
||||||
|
|
||||||
Then proceed to install `nchan <https://nchan.slact.net/>`_ and ``uwsgi``:
|
Then proceed to install `nchan <https://nchan.slact.net/>`_:
|
||||||
|
|
||||||
.. code:: bash
|
.. code:: bash
|
||||||
|
|
||||||
wget https://nchan.slact.net/download/nginx-common.deb https://nchan.slact.net/download/nginx-extras.deb
|
wget https://nchan.slact.net/download/nginx-common.deb \
|
||||||
|
https://nchan.slact.net/download/nginx-extras.deb
|
||||||
dpkg -i nginx-common.deb nginx-extras.deb
|
dpkg -i nginx-common.deb nginx-extras.deb
|
||||||
apt-get install nginx uwsgi uwsgi-plugin-python
|
apt-get -f install
|
||||||
|
|
||||||
Certidude can set up certificate authority relatively easily.
|
Certidude can set up certificate authority relatively easily.
|
||||||
Following will set up certificate authority in ``/var/lib/certidude/hostname.domain.tld``,
|
Following will set up certificate authority in ``/var/lib/certidude/hostname.domain.tld``,
|
||||||
configure uWSGI in ``/etc/uwsgi/apps-available/certidude.ini``,
|
configure gunicorn service for your platform,
|
||||||
nginx in ``/etc/nginx/sites-available/certidude.conf``,
|
nginx in ``/etc/nginx/sites-available/certidude.conf``,
|
||||||
cronjobs in ``/etc/cron.hourly/certidude`` and much more:
|
cronjobs in ``/etc/cron.hourly/certidude`` and much more:
|
||||||
|
|
||||||
@ -170,7 +172,8 @@ Install dependencies:
|
|||||||
|
|
||||||
apt-get install samba-common-bin krb5-user ldap-utils
|
apt-get install samba-common-bin krb5-user ldap-utils
|
||||||
|
|
||||||
Reset Samba client configuration in ``/etc/samba/smb.conf``:
|
Reset Samba client configuration in ``/etc/samba/smb.conf``, adjust
|
||||||
|
workgroup and realm accordingly:
|
||||||
|
|
||||||
.. code:: ini
|
.. code:: ini
|
||||||
|
|
||||||
@ -190,6 +193,13 @@ Reset Kerberos configuration in ``/etc/krb5.conf``:
|
|||||||
dns_lookup_realm = true
|
dns_lookup_realm = true
|
||||||
dns_lookup_kdc = true
|
dns_lookup_kdc = true
|
||||||
|
|
||||||
|
Reset LDAP configuration in /etc/ldap/ldap.conf:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
BASE dc=example,dc=com
|
||||||
|
URI ldap://dc1.example.com
|
||||||
|
|
||||||
Initialize Kerberos credentials:
|
Initialize Kerberos credentials:
|
||||||
|
|
||||||
.. code:: bash
|
.. code:: bash
|
||||||
@ -230,6 +240,11 @@ Adjust admin filter according to your setup.
|
|||||||
Also make sure there is cron.hourly job for creating GSSAPI credential cache -
|
Also make sure there is cron.hourly job for creating GSSAPI credential cache -
|
||||||
that's necessary for querying LDAP using Certidude machine's credentials.
|
that's necessary for querying LDAP using Certidude machine's credentials.
|
||||||
|
|
||||||
|
Common pitfalls:
|
||||||
|
|
||||||
|
* Following error message may mean that the IP address of the web server does not match the IP address used to join
|
||||||
|
the CA machine to domain, eg when you're running CA behind SSL terminating web server:
|
||||||
|
Bad credentials: Unspecified GSS failure. Minor code may provide more information (851968)
|
||||||
|
|
||||||
Automating certificate setup
|
Automating certificate setup
|
||||||
----------------------------
|
----------------------------
|
||||||
|
@ -12,7 +12,7 @@ from certidude.auth import login_required, authorize_admin
|
|||||||
from certidude.user import User
|
from certidude.user import User
|
||||||
from certidude.decorators import serialize, event_source, csrf_protection
|
from certidude.decorators import serialize, event_source, csrf_protection
|
||||||
from certidude.wrappers import Request, Certificate
|
from certidude.wrappers import Request, Certificate
|
||||||
from certidude import constants, config
|
from certidude import const, config
|
||||||
|
|
||||||
logger = logging.getLogger("api")
|
logger = logging.getLogger("api")
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ class CertificateAuthorityResource(object):
|
|||||||
resp.stream = open(config.AUTHORITY_CERTIFICATE_PATH, "rb")
|
resp.stream = open(config.AUTHORITY_CERTIFICATE_PATH, "rb")
|
||||||
resp.append_header("Content-Type", "application/x-x509-ca-cert")
|
resp.append_header("Content-Type", "application/x-x509-ca-cert")
|
||||||
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" %
|
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" %
|
||||||
constants.HOSTNAME.encode("ascii"))
|
const.HOSTNAME.encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
class SessionResource(object):
|
class SessionResource(object):
|
||||||
@ -112,7 +112,7 @@ class NormalizeMiddleware(object):
|
|||||||
assert not req.get_param("unicode") or req.get_param("unicode") == u"✓", "Unicode sanity check failed"
|
assert not req.get_param("unicode") or req.get_param("unicode") == u"✓", "Unicode sanity check failed"
|
||||||
req.context["remote_addr"] = ipaddress.ip_address(req.env["REMOTE_ADDR"].decode("utf-8"))
|
req.context["remote_addr"] = ipaddress.ip_address(req.env["REMOTE_ADDR"].decode("utf-8"))
|
||||||
|
|
||||||
def process_response(self, req, resp, resource):
|
def process_response(self, req, resp, resource=None):
|
||||||
# wtf falcon?!
|
# wtf falcon?!
|
||||||
if isinstance(resp.location, unicode):
|
if isinstance(resp.location, unicode):
|
||||||
resp.location = resp.location.encode("ascii")
|
resp.location = resp.location.encode("ascii")
|
||||||
@ -125,7 +125,6 @@ def certidude_app():
|
|||||||
from .request import RequestListResource, RequestDetailResource
|
from .request import RequestListResource, RequestDetailResource
|
||||||
from .lease import LeaseResource
|
from .lease import LeaseResource
|
||||||
from .whois import WhoisResource
|
from .whois import WhoisResource
|
||||||
from .log import LogResource
|
|
||||||
from .tag import TagResource, TagDetailResource
|
from .tag import TagResource, TagDetailResource
|
||||||
from .cfg import ConfigResource, ScriptResource
|
from .cfg import ConfigResource, ScriptResource
|
||||||
|
|
||||||
@ -149,19 +148,6 @@ def certidude_app():
|
|||||||
if config.USER_CERTIFICATE_ENROLLMENT:
|
if config.USER_CERTIFICATE_ENROLLMENT:
|
||||||
app.add_route("/api/bundle/", BundleResource())
|
app.add_route("/api/bundle/", BundleResource())
|
||||||
|
|
||||||
log_handlers = []
|
|
||||||
if config.LOGGING_BACKEND == "sql":
|
|
||||||
from certidude.mysqllog import LogHandler
|
|
||||||
uri = config.cp.get("logging", "database")
|
|
||||||
log_handlers.append(LogHandler(uri))
|
|
||||||
app.add_route("/api/log/", LogResource(uri))
|
|
||||||
elif config.LOGGING_BACKEND == "syslog":
|
|
||||||
from logging.handlers import SyslogHandler
|
|
||||||
log_handlers.append(SysLogHandler())
|
|
||||||
# Browsing syslog via HTTP is obviously not possible out of the box
|
|
||||||
elif config.LOGGING_BACKEND:
|
|
||||||
raise ValueError("Invalid logging.backend = %s" % config.LOGGING_BACKEND)
|
|
||||||
|
|
||||||
if config.TAGGING_BACKEND == "sql":
|
if config.TAGGING_BACKEND == "sql":
|
||||||
uri = config.cp.get("tagging", "database")
|
uri = config.cp.get("tagging", "database")
|
||||||
app.add_route("/api/tag/", TagResource(uri))
|
app.add_route("/api/tag/", TagResource(uri))
|
||||||
@ -171,23 +157,5 @@ def certidude_app():
|
|||||||
elif config.TAGGING_BACKEND:
|
elif config.TAGGING_BACKEND:
|
||||||
raise ValueError("Invalid tagging.backend = %s" % config.TAGGING_BACKEND)
|
raise ValueError("Invalid tagging.backend = %s" % config.TAGGING_BACKEND)
|
||||||
|
|
||||||
if config.PUSH_PUBLISH:
|
|
||||||
from certidude.push import PushLogHandler
|
|
||||||
log_handlers.append(PushLogHandler())
|
|
||||||
|
|
||||||
for facility in "api", "cli":
|
|
||||||
logger = logging.getLogger(facility)
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
for handler in log_handlers:
|
|
||||||
logger.addHandler(handler)
|
|
||||||
|
|
||||||
logging.getLogger("cli").debug("Started Certidude at %s", constants.FQDN)
|
|
||||||
|
|
||||||
import atexit
|
|
||||||
|
|
||||||
def exit_handler():
|
|
||||||
logging.getLogger("cli").debug("Shutting down Certidude")
|
|
||||||
|
|
||||||
atexit.register(exit_handler)
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
from certidude import config, authority
|
from certidude import config, authority
|
||||||
|
@ -36,8 +36,6 @@ join
|
|||||||
tag on device_tag.tag_id = tag.id
|
tag on device_tag.tag_id = tag.id
|
||||||
join
|
join
|
||||||
device on device_tag.device_id = device.id
|
device on device_tag.device_id = device.id
|
||||||
where
|
|
||||||
device.cn = %s
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@ -63,7 +61,7 @@ class ConfigResource(RelationalMixin):
|
|||||||
@login_required
|
@login_required
|
||||||
@authorize_admin
|
@authorize_admin
|
||||||
def on_get(self, req, resp):
|
def on_get(self, req, resp):
|
||||||
return self.iterfetch(SQL_SELECT_RULES)
|
return self.iterfetch(SQL_SELECT_TAGS)
|
||||||
|
|
||||||
|
|
||||||
class ScriptResource(RelationalMixin):
|
class ScriptResource(RelationalMixin):
|
||||||
|
@ -10,6 +10,9 @@ from certidude.decorators import serialize, csrf_protection
|
|||||||
from certidude.wrappers import Request, Certificate
|
from certidude.wrappers import Request, Certificate
|
||||||
from certidude.firewall import whitelist_subnets, whitelist_content_types
|
from certidude.firewall import whitelist_subnets, whitelist_content_types
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
|
||||||
logger = logging.getLogger("api")
|
logger = logging.getLogger("api")
|
||||||
|
|
||||||
class RequestListResource(object):
|
class RequestListResource(object):
|
||||||
@ -29,6 +32,7 @@ class RequestListResource(object):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
body = req.stream.read(req.content_length)
|
body = req.stream.read(req.content_length)
|
||||||
|
|
||||||
csr = Request(body)
|
csr = Request(body)
|
||||||
|
|
||||||
if not csr.common_name:
|
if not csr.common_name:
|
||||||
@ -38,6 +42,19 @@ class RequestListResource(object):
|
|||||||
"Bad request",
|
"Bad request",
|
||||||
"No common name specified!")
|
"No common name specified!")
|
||||||
|
|
||||||
|
machine = req.context.get("machine")
|
||||||
|
if machine:
|
||||||
|
if csr.common_name != machine:
|
||||||
|
raise falcon.HTTPBadRequest(
|
||||||
|
"Bad request",
|
||||||
|
"Common name %s differs from Kerberos credential %s!" % (csr.common_name, machine))
|
||||||
|
if csr.signable:
|
||||||
|
# Automatic enroll with Kerberos machine cerdentials
|
||||||
|
resp.set_header("Content-Type", "application/x-x509-user-cert")
|
||||||
|
resp.body = authority.sign(csr, overwrite=True).dump()
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
# Check if this request has been already signed and return corresponding certificte if it has been signed
|
# Check if this request has been already signed and return corresponding certificte if it has been signed
|
||||||
try:
|
try:
|
||||||
cert = authority.get_signed(csr.common_name)
|
cert = authority.get_signed(csr.common_name)
|
||||||
@ -51,8 +68,8 @@ class RequestListResource(object):
|
|||||||
|
|
||||||
# TODO: check for revoked certificates and return HTTP 410 Gone
|
# TODO: check for revoked certificates and return HTTP 410 Gone
|
||||||
|
|
||||||
# Process automatic signing if the IP address is whitelisted and autosigning was requested
|
# Process automatic signing if the IP address is whitelisted, autosigning was requested and certificate can be automatically signed
|
||||||
if req.get_param_as_bool("autosign"):
|
if req.get_param_as_bool("autosign") and csr.signable:
|
||||||
for subnet in config.AUTOSIGN_SUBNETS:
|
for subnet in config.AUTOSIGN_SUBNETS:
|
||||||
if req.context.get("remote_addr") in subnet:
|
if req.context.get("remote_addr") in subnet:
|
||||||
try:
|
try:
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import falcon
|
import falcon
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from certidude import constants
|
from certidude import const
|
||||||
from certidude.authority import export_crl, list_revoked
|
from certidude.authority import export_crl, list_revoked
|
||||||
from certidude.decorators import MyEncoder
|
from certidude.decorators import MyEncoder
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
@ -21,7 +21,7 @@ class RevocationListResource(object):
|
|||||||
resp.set_header("Content-Type", "application/x-pkcs7-crl")
|
resp.set_header("Content-Type", "application/x-pkcs7-crl")
|
||||||
resp.append_header(
|
resp.append_header(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
("attachment; filename=%s.crl" % constants.HOSTNAME).encode("ascii"))
|
("attachment; filename=%s.crl" % const.HOSTNAME).encode("ascii"))
|
||||||
# Convert PEM to DER
|
# Convert PEM to DER
|
||||||
resp.body = x509.load_pem_x509_crl(export_crl(),
|
resp.body = x509.load_pem_x509_crl(export_crl(),
|
||||||
default_backend()).public_bytes(Encoding.DER)
|
default_backend()).public_bytes(Encoding.DER)
|
||||||
@ -29,7 +29,7 @@ class RevocationListResource(object):
|
|||||||
resp.set_header("Content-Type", "application/x-pem-file")
|
resp.set_header("Content-Type", "application/x-pem-file")
|
||||||
resp.append_header(
|
resp.append_header(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
("attachment; filename=%s-crl.pem" % constants.HOSTNAME).encode("ascii"))
|
("attachment; filename=%s-crl.pem" % const.HOSTNAME).encode("ascii"))
|
||||||
resp.body = export_crl()
|
resp.body = export_crl()
|
||||||
elif req.accept.startswith("application/json"):
|
elif req.accept.startswith("application/json"):
|
||||||
resp.set_header("Content-Type", "application/json")
|
resp.set_header("Content-Type", "application/json")
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
import falcon
|
import falcon
|
||||||
import kerberos
|
import kerberos # If this fails pip install kerberos
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
from certidude.user import User
|
from certidude.user import User
|
||||||
from certidude.firewall import whitelist_subnets
|
from certidude.firewall import whitelist_subnets
|
||||||
from certidude import config, constants
|
from certidude import config, const
|
||||||
|
|
||||||
logger = logging.getLogger("api")
|
logger = logging.getLogger("api")
|
||||||
|
|
||||||
@ -23,32 +23,34 @@ if "kerberos" in config.AUTHENTICATION_BACKENDS:
|
|||||||
exit(248)
|
exit(248)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
principal = kerberos.getServerPrincipalDetails("HTTP", constants.FQDN)
|
principal = kerberos.getServerPrincipalDetails("HTTP", const.FQDN)
|
||||||
except kerberos.KrbError as exc:
|
except kerberos.KrbError as exc:
|
||||||
click.echo("Failed to initialize Kerberos, service principal is HTTP/%s, reason: %s" % (
|
click.echo("Failed to initialize Kerberos, service principal is HTTP/%s, reason: %s" % (
|
||||||
constants.FQDN, exc), err=True)
|
const.FQDN, exc), err=True)
|
||||||
exit(249)
|
exit(249)
|
||||||
else:
|
else:
|
||||||
click.echo("Kerberos enabled, service principal is HTTP/%s" % constants.FQDN)
|
click.echo("Kerberos enabled, service principal is HTTP/%s" % const.FQDN)
|
||||||
|
|
||||||
|
|
||||||
def authenticate(optional=False):
|
def authenticate(optional=False):
|
||||||
def wrapper(func):
|
def wrapper(func):
|
||||||
def kerberos_authenticate(resource, req, resp, *args, **kwargs):
|
def kerberos_authenticate(resource, req, resp, *args, **kwargs):
|
||||||
if optional and not req.get_param_as_bool("authenticate"):
|
# Try pre-emptive authentication
|
||||||
return func(resource, req, resp, *args, **kwargs)
|
|
||||||
|
|
||||||
if not req.auth:
|
if not req.auth:
|
||||||
resp.append_header("WWW-Authenticate", "Negotiate")
|
if optional:
|
||||||
|
req.context["user"] = None
|
||||||
|
return func(resource, req, resp, *args, **kwargs)
|
||||||
|
|
||||||
logger.debug(u"No Kerberos ticket offered while attempting to access %s from %s",
|
logger.debug(u"No Kerberos ticket offered while attempting to access %s from %s",
|
||||||
req.env["PATH_INFO"], req.context.get("remote_addr"))
|
req.env["PATH_INFO"], req.context.get("remote_addr"))
|
||||||
raise falcon.HTTPUnauthorized("Unauthorized",
|
raise falcon.HTTPUnauthorized("Unauthorized",
|
||||||
"No Kerberos ticket offered, are you sure you've logged in with domain user account?")
|
"No Kerberos ticket offered, are you sure you've logged in with domain user account?",
|
||||||
|
["Negotiate"])
|
||||||
|
|
||||||
token = ''.join(req.auth.split()[1:])
|
token = ''.join(req.auth.split()[1:])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result, context = kerberos.authGSSServerInit("HTTP@" + constants.FQDN)
|
result, context = kerberos.authGSSServerInit("HTTP@" + const.FQDN)
|
||||||
except kerberos.GSSError as ex:
|
except kerberos.GSSError as ex:
|
||||||
# TODO: logger.error
|
# TODO: logger.error
|
||||||
raise falcon.HTTPForbidden("Forbidden",
|
raise falcon.HTTPForbidden("Forbidden",
|
||||||
@ -59,22 +61,30 @@ def authenticate(optional=False):
|
|||||||
except kerberos.GSSError as ex:
|
except kerberos.GSSError as ex:
|
||||||
kerberos.authGSSServerClean(context)
|
kerberos.authGSSServerClean(context)
|
||||||
logger.error(u"Kerberos authentication failed from %s. "
|
logger.error(u"Kerberos authentication failed from %s. "
|
||||||
"Bad credentials: %s (%d)",
|
"GSSAPI error: %s (%d), perhaps the clock skew it too large?",
|
||||||
req.context.get("remote_addr"),
|
req.context.get("remote_addr"),
|
||||||
ex.args[0][0], ex.args[0][1])
|
ex.args[0][0], ex.args[0][1])
|
||||||
raise falcon.HTTPForbidden("Forbidden",
|
raise falcon.HTTPForbidden("Forbidden",
|
||||||
"Bad credentials: %s (%d)" % (ex.args[0][0], ex.args[0][1]))
|
"GSSAPI error: %s (%d), perhaps the clock skew it too large?" % (ex.args[0][0], ex.args[0][1]))
|
||||||
except kerberos.KrbError as ex:
|
except kerberos.KrbError as ex:
|
||||||
kerberos.authGSSServerClean(context)
|
kerberos.authGSSServerClean(context)
|
||||||
logger.error(u"Kerberos authentication failed from %s. "
|
logger.error(u"Kerberos authentication failed from %s. "
|
||||||
"Bad credentials: %s (%d)",
|
"Kerberos error: %s (%d)",
|
||||||
req.context.get("remote_addr"),
|
req.context.get("remote_addr"),
|
||||||
ex.args[0][0], ex.args[0][1])
|
ex.args[0][0], ex.args[0][1])
|
||||||
raise falcon.HTTPForbidden("Forbidden",
|
raise falcon.HTTPForbidden("Forbidden",
|
||||||
"Bad credentials: %s" % (ex.args[0],))
|
"Kerberos error: %s" % (ex.args[0],))
|
||||||
|
|
||||||
user = kerberos.authGSSServerUserName(context)
|
user = kerberos.authGSSServerUserName(context)
|
||||||
req.context["user"] = User.objects.get(user)
|
|
||||||
|
if "$@" in user and optional:
|
||||||
|
# Extract machine hostname
|
||||||
|
# TODO: Assert LDAP group membership
|
||||||
|
req.context["machine"], _ = user.lower().split("$@", 1)
|
||||||
|
req.context["user"] = None
|
||||||
|
else:
|
||||||
|
# Attempt to look up real user
|
||||||
|
req.context["user"] = User.objects.get(user)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
kerberos.authGSSServerClean(context)
|
kerberos.authGSSServerClean(context)
|
||||||
@ -114,7 +124,7 @@ def authenticate(optional=False):
|
|||||||
if not req.auth:
|
if not req.auth:
|
||||||
resp.append_header("WWW-Authenticate", "Basic")
|
resp.append_header("WWW-Authenticate", "Basic")
|
||||||
raise falcon.HTTPUnauthorized("Forbidden",
|
raise falcon.HTTPUnauthorized("Forbidden",
|
||||||
"Please authenticate with %s domain account or supply UPN" % constants.DOMAIN)
|
"Please authenticate with %s domain account or supply UPN" % const.DOMAIN)
|
||||||
|
|
||||||
if not req.auth.startswith("Basic "):
|
if not req.auth.startswith("Basic "):
|
||||||
raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth)
|
raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth)
|
||||||
@ -128,13 +138,13 @@ def authenticate(optional=False):
|
|||||||
conn = ldap.initialize(server)
|
conn = ldap.initialize(server)
|
||||||
conn.set_option(ldap.OPT_REFERRALS, 0)
|
conn.set_option(ldap.OPT_REFERRALS, 0)
|
||||||
try:
|
try:
|
||||||
conn.simple_bind_s(user if "@" in user else "%s@%s" % (user, constants.DOMAIN), passwd)
|
conn.simple_bind_s(user if "@" in user else "%s@%s" % (user, const.DOMAIN), passwd)
|
||||||
except ldap.LDAPError, e:
|
except ldap.LDAPError, e:
|
||||||
resp.append_header("WWW-Authenticate", "Basic")
|
resp.append_header("WWW-Authenticate", "Basic")
|
||||||
logger.critical(u"LDAP bind authentication failed for user %s from %s",
|
logger.critical(u"LDAP bind authentication failed for user %s from %s",
|
||||||
repr(user), req.context.get("remote_addr"))
|
repr(user), req.context.get("remote_addr"))
|
||||||
raise falcon.HTTPUnauthorized("Forbidden",
|
raise falcon.HTTPUnauthorized("Forbidden",
|
||||||
"Please authenticate with %s domain account or supply UPN" % constants.DOMAIN)
|
"Please authenticate with %s domain account or supply UPN" % const.DOMAIN)
|
||||||
|
|
||||||
req.context["ldap_conn"] = conn
|
req.context["ldap_conn"] = conn
|
||||||
break
|
break
|
||||||
@ -154,8 +164,7 @@ def authenticate(optional=False):
|
|||||||
return func(resource, req, resp, *args, **kwargs)
|
return func(resource, req, resp, *args, **kwargs)
|
||||||
|
|
||||||
if not req.auth:
|
if not req.auth:
|
||||||
resp.append_header("WWW-Authenticate", "Basic")
|
raise falcon.HTTPUnauthorized("Forbidden", "Please authenticate", ("Basic",))
|
||||||
raise falcon.HTTPUnauthorized("Forbidden", "Please authenticate")
|
|
||||||
|
|
||||||
if not req.auth.startswith("Basic "):
|
if not req.auth.startswith("Basic "):
|
||||||
raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth)
|
raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth)
|
||||||
@ -168,7 +177,7 @@ def authenticate(optional=False):
|
|||||||
if not simplepam.authenticate(user, passwd, "sshd"):
|
if not simplepam.authenticate(user, passwd, "sshd"):
|
||||||
logger.critical(u"Basic authentication failed for user %s from %s",
|
logger.critical(u"Basic authentication failed for user %s from %s",
|
||||||
repr(user), req.context.get("remote_addr"))
|
repr(user), req.context.get("remote_addr"))
|
||||||
raise falcon.HTTPUnauthorized("Forbidden", "Invalid password")
|
raise falcon.HTTPForbidden("Forbidden", "Invalid password")
|
||||||
|
|
||||||
req.context["user"] = User.objects.get(user)
|
req.context["user"] = User.objects.get(user)
|
||||||
return func(resource, req, resp, *args, **kwargs)
|
return func(resource, req, resp, *args, **kwargs)
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
import socket
|
|
||||||
import requests
|
import requests
|
||||||
from OpenSSL import crypto
|
import socket
|
||||||
from certidude import config, push, mailer
|
from datetime import datetime, timedelta
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.x509.oid import NameOID, ExtensionOID, AuthorityInformationAccessOID
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from certidude import config, push, mailer, const
|
||||||
from certidude.wrappers import Certificate, Request
|
from certidude.wrappers import Certificate, Request
|
||||||
from certidude.signer import raw_sign
|
|
||||||
from certidude import errors
|
from certidude import errors
|
||||||
|
|
||||||
RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])(@(([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])(@(([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]))?$"
|
||||||
@ -46,7 +51,7 @@ def publish_certificate(func):
|
|||||||
headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"})
|
headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"})
|
||||||
|
|
||||||
# For deleting request in the web view, use pubkey modulo
|
# For deleting request in the web view, use pubkey modulo
|
||||||
push.publish("request-signed", csr.common_name)
|
push.publish("request-signed", cert.common_name)
|
||||||
return cert
|
return cert
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
@ -73,8 +78,16 @@ def store_request(buf, overwrite=False):
|
|||||||
"""
|
"""
|
||||||
Store CSR for later processing
|
Store CSR for later processing
|
||||||
"""
|
"""
|
||||||
request = crypto.load_certificate_request(crypto.FILETYPE_PEM, buf)
|
|
||||||
common_name = request.get_subject().CN
|
if not buf: return # No certificate supplied
|
||||||
|
csr = x509.load_pem_x509_csr(buf, backend=default_backend())
|
||||||
|
for name in csr.subject:
|
||||||
|
if name.oid == NameOID.COMMON_NAME:
|
||||||
|
common_name = name.value
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError("No common name in %s" % csr.subject)
|
||||||
|
|
||||||
request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
|
request_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
|
||||||
|
|
||||||
if not re.match(RE_HOSTNAME, common_name):
|
if not re.match(RE_HOSTNAME, common_name):
|
||||||
@ -98,7 +111,7 @@ def store_request(buf, overwrite=False):
|
|||||||
|
|
||||||
def signer_exec(cmd, *bits):
|
def signer_exec(cmd, *bits):
|
||||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
sock.connect(config.SIGNER_SOCKET_PATH)
|
sock.connect(const.SIGNER_SOCKET_PATH)
|
||||||
sock.send(cmd.encode("ascii"))
|
sock.send(cmd.encode("ascii"))
|
||||||
sock.send(b"\n")
|
sock.send(b"\n")
|
||||||
for bit in bits:
|
for bit in bits:
|
||||||
@ -141,7 +154,7 @@ def list_revoked(directory=config.REVOKED_DIR):
|
|||||||
|
|
||||||
def export_crl():
|
def export_crl():
|
||||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
sock.connect(config.SIGNER_SOCKET_PATH)
|
sock.connect(const.SIGNER_SOCKET_PATH)
|
||||||
sock.send(b"export-crl\n")
|
sock.send(b"export-crl\n")
|
||||||
for filename in os.listdir(config.REVOKED_DIR):
|
for filename in os.listdir(config.REVOKED_DIR):
|
||||||
if not filename.endswith(".pem"):
|
if not filename.endswith(".pem"):
|
||||||
@ -177,32 +190,49 @@ def generate_pkcs12_bundle(common_name, key_size=4096, owner=None):
|
|||||||
"""
|
"""
|
||||||
Generate private key, sign certificate and return PKCS#12 bundle
|
Generate private key, sign certificate and return PKCS#12 bundle
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Construct private key
|
# Construct private key
|
||||||
click.echo("Generating %d-bit RSA key..." % key_size)
|
click.echo("Generating %d-bit RSA key..." % key_size)
|
||||||
key = crypto.PKey()
|
|
||||||
key.generate_key(crypto.TYPE_RSA, key_size)
|
|
||||||
|
|
||||||
# Construct CSR
|
key = rsa.generate_private_key(
|
||||||
csr = crypto.X509Req()
|
public_exponent=65537,
|
||||||
csr.set_version(2) # Corresponds to X.509v3
|
key_size=4096,
|
||||||
csr.set_pubkey(key)
|
backend=default_backend()
|
||||||
csr.get_subject().CN = common_name
|
)
|
||||||
|
|
||||||
|
csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([
|
||||||
|
x509.NameAttribute(k, v) for k, v in (
|
||||||
|
(NameOID.COMMON_NAME, common_name),
|
||||||
|
(NameOID.GIVEN_NAME, owner and owner.given_name),
|
||||||
|
(NameOID.SURNAME, owner and owner.surname),
|
||||||
|
) if v
|
||||||
|
]))
|
||||||
|
|
||||||
if owner:
|
if owner:
|
||||||
if owner.given_name:
|
click.echo("Setting e-mail to: %s" % owner.mail)
|
||||||
csr.get_subject().GN = owner.given_name
|
csr = csr.add_extension(
|
||||||
if owner.surname:
|
x509.SubjectAlternativeName([
|
||||||
csr.get_subject().SN = owner.surname
|
x509.RFC822Name(owner.mail)
|
||||||
csr.add_extensions([
|
]),
|
||||||
crypto.X509Extension("subjectAltName", True, "email:%s" % owner.mail.encode("ascii"))])
|
critical=False)
|
||||||
|
|
||||||
buf = crypto.dump_certificate_request(crypto.FILETYPE_PEM, csr)
|
|
||||||
|
|
||||||
# Sign CSR
|
# Sign CSR
|
||||||
cert = sign(Request(buf), overwrite=True)
|
cert = sign(Request(
|
||||||
|
csr.sign(key, hashes.SHA512(), default_backend()).public_bytes(serialization.Encoding.PEM)), overwrite=True)
|
||||||
|
|
||||||
# Generate P12
|
# Generate P12, currently supported only by PyOpenSSL
|
||||||
|
from OpenSSL import crypto
|
||||||
p12 = crypto.PKCS12()
|
p12 = crypto.PKCS12()
|
||||||
p12.set_privatekey( key )
|
p12.set_privatekey(
|
||||||
|
crypto.load_privatekey(
|
||||||
|
crypto.FILETYPE_PEM,
|
||||||
|
key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
p12.set_certificate( cert._obj )
|
p12.set_certificate( cert._obj )
|
||||||
p12.set_ca_certificates([certificate._obj])
|
p12.set_ca_certificates([certificate._obj])
|
||||||
return p12.export(), cert
|
return p12.export(), cert
|
||||||
@ -213,7 +243,6 @@ def sign(req, overwrite=False, delete=True):
|
|||||||
"""
|
"""
|
||||||
Sign certificate signing request via signer process
|
Sign certificate signing request via signer process
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cert_path = os.path.join(config.SIGNED_DIR, req.common_name + ".pem")
|
cert_path = os.path.join(config.SIGNED_DIR, req.common_name + ".pem")
|
||||||
|
|
||||||
# Move existing certificate if necessary
|
# Move existing certificate if necessary
|
||||||
@ -236,35 +265,95 @@ def sign(req, overwrite=False, delete=True):
|
|||||||
|
|
||||||
|
|
||||||
@publish_certificate
|
@publish_certificate
|
||||||
def sign2(request, overwrite=False, delete=True, lifetime=None):
|
def sign2(request, private_key, authority_certificate, overwrite=False, delete=True, lifetime=None):
|
||||||
"""
|
"""
|
||||||
Sign directly using private key, this is usually done by root.
|
Sign directly using private key, this is usually done by root.
|
||||||
Basic constraints and certificate lifetime are copied from config,
|
Basic constraints and certificate lifetime are copied from config,
|
||||||
lifetime may be overridden on the command line,
|
lifetime may be overridden on the command line,
|
||||||
other extensions are copied as is.
|
other extensions are copied as is.
|
||||||
"""
|
"""
|
||||||
cert = raw_sign(
|
|
||||||
crypto.load_privatekey(crypto.FILETYPE_PEM, open(config.AUTHORITY_PRIVATE_KEY_PATH).read()),
|
|
||||||
crypto.load_certificate(crypto.FILETYPE_PEM, open(config.AUTHORITY_CERTIFICATE_PATH).read()),
|
|
||||||
request._obj,
|
|
||||||
config.CERTIFICATE_BASIC_CONSTRAINTS,
|
|
||||||
lifetime=lifetime or config.CERTIFICATE_LIFETIME)
|
|
||||||
|
|
||||||
path = os.path.join(config.SIGNED_DIR, request.common_name + ".pem")
|
certificate_path = os.path.join(config.SIGNED_DIR, request.common_name + ".pem")
|
||||||
if os.path.exists(path):
|
if os.path.exists(certificate_path):
|
||||||
if overwrite:
|
if overwrite:
|
||||||
revoke_certificate(request.common_name)
|
revoke_certificate(request.common_name)
|
||||||
else:
|
else:
|
||||||
raise EnvironmentError("File %s already exists!" % path)
|
raise errors.DuplicateCommonNameError("Valid certificate with common name %s already exists" % request.common_name)
|
||||||
|
|
||||||
buf = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
|
now = datetime.utcnow()
|
||||||
with open(path + ".part", "wb") as fh:
|
request_path = os.path.join(config.REQUESTS_DIR, request.common_name + ".pem")
|
||||||
|
request = x509.load_pem_x509_csr(open(request_path).read(), default_backend())
|
||||||
|
|
||||||
|
cert = x509.CertificateBuilder(
|
||||||
|
).subject_name(x509.Name([n for n in request.subject])
|
||||||
|
).serial_number(random.randint(
|
||||||
|
0x1000000000000000000000000000000000000000,
|
||||||
|
0xffffffffffffffffffffffffffffffffffffffff)
|
||||||
|
).issuer_name(authority_certificate.issuer
|
||||||
|
).public_key(request.public_key()
|
||||||
|
).not_valid_before(now - timedelta(hours=1)
|
||||||
|
).not_valid_after(now + timedelta(days=config.CERTIFICATE_LIFETIME)
|
||||||
|
).add_extension(x509.KeyUsage(
|
||||||
|
digital_signature=True,
|
||||||
|
key_encipherment=True,
|
||||||
|
content_commitment=False,
|
||||||
|
data_encipherment=False,
|
||||||
|
key_agreement=False,
|
||||||
|
key_cert_sign=False,
|
||||||
|
crl_sign=False,
|
||||||
|
encipher_only=False,
|
||||||
|
decipher_only=False), critical=True
|
||||||
|
).add_extension(
|
||||||
|
x509.SubjectKeyIdentifier.from_public_key(request.public_key()),
|
||||||
|
critical=False
|
||||||
|
).add_extension(
|
||||||
|
x509.AuthorityInformationAccess([
|
||||||
|
x509.AccessDescription(
|
||||||
|
AuthorityInformationAccessOID.CA_ISSUERS,
|
||||||
|
x509.UniformResourceIdentifier(
|
||||||
|
config.CERTIFICATE_AUTHORITY_URL)
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
critical=False
|
||||||
|
).add_extension(
|
||||||
|
x509.CRLDistributionPoints([
|
||||||
|
x509.DistributionPoint(
|
||||||
|
full_name=[
|
||||||
|
x509.UniformResourceIdentifier(
|
||||||
|
config.CERTIFICATE_CRL_URL)],
|
||||||
|
relative_name=None,
|
||||||
|
crl_issuer=None,
|
||||||
|
reasons=None)
|
||||||
|
]),
|
||||||
|
critical=False
|
||||||
|
).add_extension(
|
||||||
|
x509.AuthorityKeyIdentifier.from_issuer_public_key(
|
||||||
|
authority_certificate.public_key()),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Append subject alternative name, extended key usage flags etc
|
||||||
|
for extension in request.extensions:
|
||||||
|
if extension.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME:
|
||||||
|
click.echo("Appending subject alt name extension: %s" % extension)
|
||||||
|
cert = cert.add_extension(x509.SubjectAlternativeName(extension.value),
|
||||||
|
critical=extension.critical)
|
||||||
|
if extension.oid == ExtensionOID.EXTENDED_KEY_USAGE:
|
||||||
|
click.echo("Appending extended key usage flags extension: %s" % extension)
|
||||||
|
cert = cert.add_extension(x509.ExtendedKeyUsage(extension.value),
|
||||||
|
critical=extension.critical)
|
||||||
|
|
||||||
|
|
||||||
|
cert = cert.sign(private_key, hashes.SHA512(), default_backend())
|
||||||
|
|
||||||
|
buf = cert.public_bytes(serialization.Encoding.PEM)
|
||||||
|
with open(certificate_path + ".part", "wb") as fh:
|
||||||
fh.write(buf)
|
fh.write(buf)
|
||||||
os.rename(path + ".part", path)
|
os.rename(certificate_path + ".part", certificate_path)
|
||||||
click.echo("Wrote certificate to: %s" % path)
|
click.echo("Wrote certificate to: %s" % certificate_path)
|
||||||
if delete:
|
if delete:
|
||||||
os.unlink(request.path)
|
os.unlink(request_path)
|
||||||
click.echo("Deleted request: %s" % request.path)
|
click.echo("Deleted request: %s" % request_path)
|
||||||
|
|
||||||
return Certificate(open(path))
|
return Certificate(open(certificate_path))
|
||||||
|
|
||||||
|
1367
certidude/cli.py
1367
certidude/cli.py
File diff suppressed because it is too large
Load Diff
@ -5,11 +5,13 @@ import configparser
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
import string
|
import string
|
||||||
|
import const
|
||||||
from random import choice
|
from random import choice
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
cp = configparser.ConfigParser()
|
# Options that are parsed from config file are fetched here
|
||||||
cp.readfp(codecs.open("/etc/certidude/server.conf", "r", "utf8"))
|
|
||||||
|
cp = configparser.RawConfigParser()
|
||||||
|
cp.readfp(codecs.open(const.CONFIG_PATH, "r", "utf8"))
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = set([j for j in
|
AUTHENTICATION_BACKENDS = set([j for j in
|
||||||
cp.get("authentication", "backends").split(" ") if j]) # kerberos, pam, ldap
|
cp.get("authentication", "backends").split(" ") if j]) # kerberos, pam, ldap
|
||||||
@ -28,9 +30,6 @@ AUTOSIGN_SUBNETS = set([ipaddress.ip_network(j) for j in
|
|||||||
REQUEST_SUBNETS = set([ipaddress.ip_network(j) for j in
|
REQUEST_SUBNETS = set([ipaddress.ip_network(j) for j in
|
||||||
cp.get("authorization", "request subnets").split(" ") if j]).union(AUTOSIGN_SUBNETS)
|
cp.get("authorization", "request subnets").split(" ") if j]).union(AUTOSIGN_SUBNETS)
|
||||||
|
|
||||||
SIGNER_SOCKET_PATH = "/run/certidude/signer.sock"
|
|
||||||
SIGNER_PID_PATH = "/run/certidude/signer.pid"
|
|
||||||
|
|
||||||
AUTHORITY_DIR = "/var/lib/certidude"
|
AUTHORITY_DIR = "/var/lib/certidude"
|
||||||
AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path")
|
AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path")
|
||||||
AUTHORITY_CERTIFICATE_PATH = cp.get("authority", "certificate path")
|
AUTHORITY_CERTIFICATE_PATH = cp.get("authority", "certificate path")
|
||||||
@ -49,16 +48,13 @@ USER_MULTIPLE_CERTIFICATES = {
|
|||||||
CERTIFICATE_BASIC_CONSTRAINTS = "CA:FALSE"
|
CERTIFICATE_BASIC_CONSTRAINTS = "CA:FALSE"
|
||||||
CERTIFICATE_KEY_USAGE_FLAGS = "digitalSignature,keyEncipherment"
|
CERTIFICATE_KEY_USAGE_FLAGS = "digitalSignature,keyEncipherment"
|
||||||
CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS = "clientAuth"
|
CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS = "clientAuth"
|
||||||
CERTIFICATE_LIFETIME = int(cp.get("signature", "certificate lifetime"))
|
CERTIFICATE_LIFETIME = cp.getint("signature", "certificate lifetime")
|
||||||
CERTIFICATE_AUTHORITY_URL = cp.get("signature", "certificate url")
|
CERTIFICATE_AUTHORITY_URL = cp.get("signature", "certificate url")
|
||||||
CERTIFICATE_CRL_URL = cp.get("signature", "revoked url")
|
CERTIFICATE_CRL_URL = cp.get("signature", "revoked url")
|
||||||
|
|
||||||
REVOCATION_LIST_LIFETIME = int(cp.get("signature", "revocation list lifetime"))
|
REVOCATION_LIST_LIFETIME = cp.getint("signature", "revocation list lifetime")
|
||||||
|
|
||||||
PUSH_TOKEN = "".join([choice(string.ascii_letters + string.digits) for j in range(0,32)])
|
|
||||||
|
|
||||||
PUSH_TOKEN = "ca"
|
|
||||||
|
|
||||||
|
PUSH_TOKEN = cp.get("push", "token")
|
||||||
PUSH_EVENT_SOURCE = cp.get("push", "event source")
|
PUSH_EVENT_SOURCE = cp.get("push", "event source")
|
||||||
PUSH_LONG_POLL = cp.get("push", "long poll")
|
PUSH_LONG_POLL = cp.get("push", "long poll")
|
||||||
PUSH_PUBLISH = cp.get("push", "publish")
|
PUSH_PUBLISH = cp.get("push", "publish")
|
||||||
|
24
certidude/const.py
Normal file
24
certidude/const.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
import click
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
|
||||||
|
CONFIG_DIR = os.path.expanduser("~/.certidude") if os.getuid() else "/etc/certidude"
|
||||||
|
CONFIG_PATH = os.path.join(CONFIG_DIR, "server.conf")
|
||||||
|
|
||||||
|
CLIENT_CONFIG_PATH = os.path.join(CONFIG_DIR, "client.conf")
|
||||||
|
SERVICES_CONFIG_PATH = os.path.join(CONFIG_DIR, "services.conf")
|
||||||
|
SERVER_LOG_PATH = os.path.join(CONFIG_DIR, "server.log") if os.getuid() else "/var/log/certidude-server.log"
|
||||||
|
SIGNER_SOCKET_PATH = os.path.join(CONFIG_DIR, "signer.sock") if os.getuid() else "/run/certidude/signer.sock"
|
||||||
|
SIGNER_PID_PATH = os.path.join(CONFIG_DIR, "signer.pid") if os.getuid() else "/run/certidude/signer.pid"
|
||||||
|
SIGNER_LOG_PATH = os.path.join(CONFIG_DIR, "signer.log") if os.getuid() else "/var/log/certidude-signer.log"
|
||||||
|
|
||||||
|
FQDN = socket.getaddrinfo(socket.gethostname(), 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME)[0][3]
|
||||||
|
|
||||||
|
if "." in FQDN:
|
||||||
|
HOSTNAME, DOMAIN = FQDN.split(".", 1)
|
||||||
|
else:
|
||||||
|
HOSTNAME, DOMAIN = FQDN, "local"
|
||||||
|
click.echo("Unable to determine domain of this computer, falling back to local")
|
||||||
|
|
||||||
|
EXTENSION_WHITELIST = set(["subjectAltName"])
|
@ -1,13 +0,0 @@
|
|||||||
|
|
||||||
import click
|
|
||||||
import socket
|
|
||||||
|
|
||||||
FQDN = socket.getaddrinfo(socket.gethostname(), 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME)[0][3]
|
|
||||||
|
|
||||||
if "." in FQDN:
|
|
||||||
HOSTNAME, DOMAIN = FQDN.split(".", 1)
|
|
||||||
else:
|
|
||||||
HOSTNAME, DOMAIN = FQDN, "local"
|
|
||||||
click.echo("Unable to determine domain of this computer, falling back to local")
|
|
||||||
|
|
||||||
EXTENSION_WHITELIST = set(["subjectAltName"])
|
|
@ -8,7 +8,7 @@ from datetime import date, time, datetime
|
|||||||
from OpenSSL import crypto
|
from OpenSSL import crypto
|
||||||
from certidude.auth import User
|
from certidude.auth import User
|
||||||
from certidude.wrappers import Request, Certificate
|
from certidude.wrappers import Request, Certificate
|
||||||
from urllib.parse import urlparse
|
from urlparse import urlparse
|
||||||
|
|
||||||
logger = logging.getLogger("api")
|
logger = logging.getLogger("api")
|
||||||
|
|
||||||
@ -23,15 +23,21 @@ def csrf_protection(func):
|
|||||||
|
|
||||||
# For everything else assert referrer
|
# For everything else assert referrer
|
||||||
referrer = req.headers.get("REFERER")
|
referrer = req.headers.get("REFERER")
|
||||||
|
|
||||||
|
|
||||||
if referrer:
|
if referrer:
|
||||||
scheme, netloc, path, params, query, fragment = urlparse(referrer)
|
scheme, netloc, path, params, query, fragment = urlparse(referrer)
|
||||||
if netloc == req.host:
|
if ":" in netloc:
|
||||||
|
host, port = netloc.split(":", 1)
|
||||||
|
else:
|
||||||
|
host, port = netloc, None
|
||||||
|
if host == req.host:
|
||||||
return func(self, req, resp, *args, **kwargs)
|
return func(self, req, resp, *args, **kwargs)
|
||||||
|
|
||||||
# Kaboom!
|
# Kaboom!
|
||||||
logger.warning(u"Prevented clickbait from '%s' with user agent '%s'",
|
logger.warning(u"Prevented clickbait from '%s' with user agent '%s'",
|
||||||
referrer or "-", req.user_agent)
|
referrer or "-", req.user_agent)
|
||||||
raise falcon.HTTPUnauthorized("Forbidden",
|
raise falcon.HTTPForbidden("Forbidden",
|
||||||
"No suitable UA or referrer provided, cross-site scripting disabled")
|
"No suitable UA or referrer provided, cross-site scripting disabled")
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
|
@ -6,14 +6,19 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
from certidude import errors
|
from certidude import errors
|
||||||
from certidude.wrappers import Certificate, Request
|
from certidude.wrappers import Certificate, Request
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding
|
||||||
|
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, AuthorityInformationAccessOID
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
from OpenSSL import crypto
|
from OpenSSL import crypto
|
||||||
|
|
||||||
def certidude_request_certificate(server, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, org_unit=None, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None, ip_address=None, dns=None, bundle=False):
|
def certidude_request_certificate(server, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, extended_key_usage_flags=None, org_unit=None, email_address=None, given_name=None, surname=None, autosign=False, wait=False, ip_address=None, dns=None, bundle=False, insecure=False):
|
||||||
"""
|
"""
|
||||||
Exchange CSR for certificate using Certidude HTTP API server
|
Exchange CSR for certificate using Certidude HTTP API server
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Set up URL-s
|
# Set up URL-s
|
||||||
request_params = set()
|
request_params = set()
|
||||||
if autosign:
|
if autosign:
|
||||||
@ -22,9 +27,10 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
|
|||||||
request_params.add("wait=forever")
|
request_params.add("wait=forever")
|
||||||
|
|
||||||
# Expand ca.example.com
|
# Expand ca.example.com
|
||||||
authority_url = "http://%s/api/certificate/" % server
|
scheme = "http" if insecure else "https" # TODO: Expose in CLI
|
||||||
request_url = "http://%s/api/request/" % server
|
authority_url = "%s://%s/api/certificate/" % (scheme, server)
|
||||||
revoked_url = "http://%s/api/revoked/" % server
|
request_url = "%s://%s/api/request/" % (scheme, server)
|
||||||
|
revoked_url = "%s://%s/api/revoked/" % (scheme, server)
|
||||||
|
|
||||||
if request_params:
|
if request_params:
|
||||||
request_url = request_url + "?" + "&".join(request_params)
|
request_url = request_url + "?" + "&".join(request_params)
|
||||||
@ -103,54 +109,62 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
|
|||||||
|
|
||||||
# Construct private key
|
# Construct private key
|
||||||
click.echo("Generating 4096-bit RSA key...")
|
click.echo("Generating 4096-bit RSA key...")
|
||||||
key = crypto.PKey()
|
key = rsa.generate_private_key(
|
||||||
key.generate_key(crypto.TYPE_RSA, 4096)
|
public_exponent=65537,
|
||||||
|
key_size=4096,
|
||||||
|
backend=default_backend()
|
||||||
|
)
|
||||||
|
|
||||||
# Dump private key
|
# Dump private key
|
||||||
key_partial = tempfile.mktemp(prefix=key_path + ".part")
|
key_partial = tempfile.mktemp(prefix=key_path + ".part")
|
||||||
os.umask(0o077)
|
os.umask(0o077)
|
||||||
with open(key_partial, "wb") as fh:
|
with open(key_partial, "wb") as fh:
|
||||||
fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key))
|
fh.write(key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
))
|
||||||
|
|
||||||
# Construct CSR
|
# Set subject name attributes
|
||||||
csr = crypto.X509Req()
|
names = [x509.NameAttribute(NameOID.COMMON_NAME, common_name.decode("utf-8"))]
|
||||||
csr.set_version(2) # Corresponds to X.509v3
|
|
||||||
csr.set_pubkey(key)
|
|
||||||
csr.get_subject().CN = common_name
|
|
||||||
|
|
||||||
request = Request(csr)
|
|
||||||
|
|
||||||
# Set subject attributes
|
|
||||||
if given_name:
|
if given_name:
|
||||||
request.given_name = given_name
|
names.append(x509.NameAttribute(NameOID.GIVEN_NAME, given_name.decode("utf-8")))
|
||||||
if surname:
|
if surname:
|
||||||
request.surname = surname
|
names.append(x509.NameAttribute(NameOID.SURNAME, surname.decode("utf-8")))
|
||||||
if org_unit:
|
if org_unit:
|
||||||
request.organizational_unit = org_unit
|
names.append(x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT, org_unit.decode("utf-8")))
|
||||||
|
|
||||||
# Collect subject alternative names
|
# Collect subject alternative names
|
||||||
subject_alt_name = set()
|
subject_alt_names = set()
|
||||||
if email_address:
|
if email_address:
|
||||||
subject_alt_name.add("email:%s" % email_address)
|
subject_alt_names.add(x509.RFC822Name(email_address))
|
||||||
if ip_address:
|
if ip_address:
|
||||||
subject_alt_name.add("IP:%s" % ip_address)
|
subject_alt_names.add("IP:%s" % ip_address)
|
||||||
if dns:
|
if dns:
|
||||||
subject_alt_name.add("DNS:%s" % dns)
|
subject_alt_names.add(x509.DNSName(dns))
|
||||||
|
|
||||||
# Set extensions
|
|
||||||
extensions = []
|
|
||||||
if key_usage:
|
|
||||||
extensions.append(("keyUsage", key_usage, True))
|
|
||||||
if extended_key_usage:
|
|
||||||
extensions.append(("extendedKeyUsage", extended_key_usage, False))
|
|
||||||
if subject_alt_name:
|
|
||||||
extensions.append(("subjectAltName", ", ".join(subject_alt_name), False))
|
|
||||||
request.set_extensions(extensions)
|
|
||||||
|
|
||||||
# Dump CSR
|
# Construct CSR
|
||||||
|
csr = x509.CertificateSigningRequestBuilder(
|
||||||
|
).subject_name(x509.Name(names))
|
||||||
|
|
||||||
|
|
||||||
|
if extended_key_usage_flags:
|
||||||
|
click.echo("Adding extended key usage extension: %s" % extended_key_usage_flags)
|
||||||
|
csr = csr.add_extension(x509.ExtendedKeyUsage(
|
||||||
|
extended_key_usage_flags), critical=True)
|
||||||
|
|
||||||
|
if subject_alt_names:
|
||||||
|
click.echo("Adding subject alternative name extension: %s" % subject_alt_names)
|
||||||
|
csr = csr.add_extension(
|
||||||
|
x509.SubjectAlternativeName(subject_alt_names),
|
||||||
|
critical=False)
|
||||||
|
|
||||||
|
|
||||||
|
# Sign & dump CSR
|
||||||
os.umask(0o022)
|
os.umask(0o022)
|
||||||
with open(request_path + ".part", "w") as fh:
|
with open(request_path + ".part", "wb") as f:
|
||||||
fh.write(request.dump())
|
f.write(csr.sign(key, hashes.SHA256(), default_backend()).public_bytes(serialization.Encoding.PEM))
|
||||||
|
|
||||||
click.echo("Writing private key to: %s" % key_path)
|
click.echo("Writing private key to: %s" % key_path)
|
||||||
os.rename(key_partial, key_path)
|
os.rename(key_partial, key_path)
|
||||||
@ -160,37 +174,36 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
|
|||||||
# We have CSR now, save the paths to client.conf so we could:
|
# We have CSR now, save the paths to client.conf so we could:
|
||||||
# Update CRL, renew certificate, maybe something extra?
|
# Update CRL, renew certificate, maybe something extra?
|
||||||
|
|
||||||
if not os.path.exists("/etc/certidude"):
|
|
||||||
os.makedirs("/etc/certidude")
|
|
||||||
|
|
||||||
clients = ConfigParser()
|
|
||||||
if os.path.exists("/etc/certidude/client.conf"):
|
|
||||||
clients.readfp(open("/etc/certidude/client.conf"))
|
|
||||||
|
|
||||||
if clients.has_section(server):
|
|
||||||
click.echo("Section %s already exists in /etc/certidude/client.conf, not reconfiguring" % server)
|
|
||||||
else:
|
|
||||||
clients.add_section(server)
|
|
||||||
clients.set(server, "trigger", "interface up")
|
|
||||||
clients.set(server, "key_path", key_path)
|
|
||||||
clients.set(server, "request_path", request_path)
|
|
||||||
clients.set(server, "certificate_path", certificate_path)
|
|
||||||
clients.set(server, "authority_path", authority_path)
|
|
||||||
clients.set(server, "key_path", key_path)
|
|
||||||
clients.set(server, "revocations_path", revocations_path)
|
|
||||||
clients.write(open("/etc/certidude/client.conf", "w"))
|
|
||||||
click.echo("Section %s added to /etc/certidude/client.conf" % repr(server))
|
|
||||||
|
|
||||||
if os.path.exists(certificate_path):
|
if os.path.exists(certificate_path):
|
||||||
click.echo("Found certificate: %s" % certificate_path)
|
click.echo("Found certificate: %s" % certificate_path)
|
||||||
# TODO: Check certificate validity, download CRL?
|
# TODO: Check certificate validity, download CRL?
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# If machine is joined to domain attempt to present machine credentials for authentication
|
||||||
|
if os.path.exists("/etc/krb5.keytab") and os.path.exists("/etc/samba/smb.conf"):
|
||||||
|
# Get HTTP service ticket
|
||||||
|
from configparser import ConfigParser
|
||||||
|
cp = ConfigParser(delimiters=("="))
|
||||||
|
cp.readfp(open("/etc/samba/smb.conf"))
|
||||||
|
name = cp.get("global", "netbios name")
|
||||||
|
realm = cp.get("global", "realm")
|
||||||
|
os.environ["KRB5CCNAME"]="/tmp/ca.ticket"
|
||||||
|
os.system("kinit -k %s$ -S HTTP/%s@%s -t /etc/krb5.keytab" % (name, server, realm))
|
||||||
|
from requests_kerberos import HTTPKerberosAuth, OPTIONAL
|
||||||
|
auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL, force_preemptive=True)
|
||||||
|
else:
|
||||||
|
auth = None
|
||||||
|
|
||||||
click.echo("Submitting to %s, waiting for response..." % request_url)
|
click.echo("Submitting to %s, waiting for response..." % request_url)
|
||||||
submission = requests.post(request_url,
|
submission = requests.post(request_url,
|
||||||
|
auth=auth,
|
||||||
data=open(request_path),
|
data=open(request_path),
|
||||||
headers={"Content-Type": "application/pkcs10", "Accept": "application/x-x509-user-cert,application/x-pem-file"})
|
headers={"Content-Type": "application/pkcs10", "Accept": "application/x-x509-user-cert,application/x-pem-file"})
|
||||||
|
|
||||||
|
# Destroy service ticket
|
||||||
|
if os.path.exists("/tmp/ca.ticket"):
|
||||||
|
os.system("kdestroy")
|
||||||
|
|
||||||
if submission.status_code == requests.codes.ok:
|
if submission.status_code == requests.codes.ok:
|
||||||
pass
|
pass
|
||||||
if submission.status_code == requests.codes.accepted:
|
if submission.status_code == requests.codes.accepted:
|
||||||
|
@ -8,7 +8,7 @@ from jinja2 import Environment, PackageLoader
|
|||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.base import MIMEBase
|
from email.mime.base import MIMEBase
|
||||||
from urllib.parse import urlparse
|
from urlparse import urlparse
|
||||||
|
|
||||||
env = Environment(loader=PackageLoader("certidude", "templates/mail"))
|
env = Environment(loader=PackageLoader("certidude", "templates/mail"))
|
||||||
|
|
||||||
|
@ -23,12 +23,17 @@ def publish(event_type, event_data):
|
|||||||
url,
|
url,
|
||||||
data=event_data,
|
data=event_data,
|
||||||
headers={"X-EventSource-Event": event_type, "User-Agent": "Certidude API"})
|
headers={"X-EventSource-Event": event_type, "User-Agent": "Certidude API"})
|
||||||
if notification.status_code != requests.codes.created:
|
if notification.status_code == requests.codes.created:
|
||||||
click.echo("Failed to submit event to push server, server responded %d, expected %d" % (
|
pass # Sent to client
|
||||||
notification.status_code, requests.codes.created))
|
elif notification.status_code == requests.codes.accepted:
|
||||||
|
pass # Buffered in nchan
|
||||||
|
else:
|
||||||
|
click.echo("Failed to submit event to push server, server responded %d" % (
|
||||||
|
notification.status_code))
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
click.echo("Failed to submit event to push server, connection error")
|
click.echo("Failed to submit event to push server, connection error")
|
||||||
|
|
||||||
|
|
||||||
class PushLogHandler(logging.Handler):
|
class PushLogHandler(logging.Handler):
|
||||||
"""
|
"""
|
||||||
To be used with Python log handling framework for publishing log entries
|
To be used with Python log handling framework for publishing log entries
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import click
|
import click
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
from urllib.parse import urlparse
|
from urlparse import urlparse
|
||||||
|
|
||||||
SCRIPTS = {}
|
SCRIPTS = {}
|
||||||
|
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
|
|
||||||
|
|
||||||
import random
|
import random
|
||||||
import pwd
|
|
||||||
import socket
|
import socket
|
||||||
import os
|
import os
|
||||||
import asyncore
|
import asyncore
|
||||||
import asynchat
|
import asynchat
|
||||||
from certidude import constants, config
|
from certidude import const, config
|
||||||
from OpenSSL import crypto
|
|
||||||
|
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
from cryptography.hazmat.primitives import hashes, serialization
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
@ -20,99 +17,6 @@ import random
|
|||||||
DN_WHITELIST = NameOID.COMMON_NAME, NameOID.GIVEN_NAME, NameOID.SURNAME, \
|
DN_WHITELIST = NameOID.COMMON_NAME, NameOID.GIVEN_NAME, NameOID.SURNAME, \
|
||||||
NameOID.EMAIL_ADDRESS
|
NameOID.EMAIL_ADDRESS
|
||||||
|
|
||||||
SERIAL_MIN = 0x1000000000000000000000000000000000000000
|
|
||||||
SERIAL_MAX = 0xffffffffffffffffffffffffffffffffffffffff
|
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Initialize X.509 certificate object
|
|
||||||
cert = crypto.X509()
|
|
||||||
cert.set_version(2) # This corresponds to X.509v3
|
|
||||||
|
|
||||||
# Set public key
|
|
||||||
cert.set_pubkey(request.get_pubkey())
|
|
||||||
|
|
||||||
# Set issuer
|
|
||||||
cert.set_issuer(ca_cert.get_subject())
|
|
||||||
|
|
||||||
# Set SKID and AKID extensions
|
|
||||||
cert.add_extensions([
|
|
||||||
crypto.X509Extension(
|
|
||||||
b"subjectKeyIdentifier",
|
|
||||||
False,
|
|
||||||
b"hash",
|
|
||||||
subject = cert),
|
|
||||||
crypto.X509Extension(
|
|
||||||
b"authorityKeyIdentifier",
|
|
||||||
False,
|
|
||||||
b"keyid:always",
|
|
||||||
issuer = ca_cert),
|
|
||||||
crypto.X509Extension(
|
|
||||||
b"authorityInfoAccess",
|
|
||||||
False,
|
|
||||||
("caIssuers;URI: %s" % config.CERTIFICATE_AUTHORITY_URL).encode("ascii")),
|
|
||||||
crypto.X509Extension(
|
|
||||||
b"crlDistributionPoints",
|
|
||||||
False,
|
|
||||||
("URI: %s" % config.CERTIFICATE_CRL_URL).encode("ascii"))
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
# Copy attributes from request
|
|
||||||
cert.get_subject().CN = request.get_subject().CN
|
|
||||||
|
|
||||||
if request.get_subject().SN:
|
|
||||||
cert.get_subject().SN = request.get_subject().SN
|
|
||||||
if request.get_subject().GN:
|
|
||||||
cert.get_subject().GN = request.get_subject().GN
|
|
||||||
|
|
||||||
if request.get_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(-3600)
|
|
||||||
cert.gmtime_adj_notAfter(lifetime * 24 * 60 * 60)
|
|
||||||
|
|
||||||
# Generate random serial
|
|
||||||
cert.set_serial_number(random.randint(SERIAL_MIN, SERIAL_MAX))
|
|
||||||
cert.sign(private_key, 'sha512')
|
|
||||||
return cert
|
|
||||||
|
|
||||||
|
|
||||||
class SignHandler(asynchat.async_chat):
|
class SignHandler(asynchat.async_chat):
|
||||||
def __init__(self, sock, server):
|
def __init__(self, sock, server):
|
||||||
asynchat.async_chat.__init__(self, sock=sock)
|
asynchat.async_chat.__init__(self, sock=sock)
|
||||||
@ -162,7 +66,9 @@ class SignHandler(asynchat.async_chat):
|
|||||||
|
|
||||||
cert = x509.CertificateBuilder(
|
cert = x509.CertificateBuilder(
|
||||||
).subject_name(subject
|
).subject_name(subject
|
||||||
).serial_number(random.randint(SERIAL_MIN, SERIAL_MAX)
|
).serial_number(random.randint(
|
||||||
|
0x1000000000000000000000000000000000000000,
|
||||||
|
0xffffffffffffffffffffffffffffffffffffffff)
|
||||||
).issuer_name(self.server.certificate.issuer
|
).issuer_name(self.server.certificate.issuer
|
||||||
).public_key(request.public_key()
|
).public_key(request.public_key()
|
||||||
).not_valid_before(now - timedelta(hours=1)
|
).not_valid_before(now - timedelta(hours=1)
|
||||||
@ -224,32 +130,31 @@ class SignHandler(asynchat.async_chat):
|
|||||||
def collect_incoming_data(self, data):
|
def collect_incoming_data(self, data):
|
||||||
self.buffer.append(data)
|
self.buffer.append(data)
|
||||||
|
|
||||||
|
import signal
|
||||||
|
import click
|
||||||
|
|
||||||
class SignServer(asyncore.dispatcher):
|
class SignServer(asyncore.dispatcher):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
asyncore.dispatcher.__init__(self)
|
asyncore.dispatcher.__init__(self)
|
||||||
|
|
||||||
# Bind to sockets
|
if os.path.exists(const.SIGNER_SOCKET_PATH):
|
||||||
if os.path.exists(config.SIGNER_SOCKET_PATH):
|
os.unlink(const.SIGNER_SOCKET_PATH)
|
||||||
os.unlink(config.SIGNER_SOCKET_PATH)
|
|
||||||
os.umask(0o007)
|
|
||||||
self.create_socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
self.create_socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
self.bind(config.SIGNER_SOCKET_PATH)
|
self.bind(const.SIGNER_SOCKET_PATH)
|
||||||
self.listen(5)
|
self.listen(5)
|
||||||
|
|
||||||
# Load CA private key and certificate
|
# Load CA private key and certificate
|
||||||
|
click.echo("Signer reading private key from %s" % config.AUTHORITY_PRIVATE_KEY_PATH)
|
||||||
self.private_key = serialization.load_pem_private_key(
|
self.private_key = serialization.load_pem_private_key(
|
||||||
open(config.AUTHORITY_PRIVATE_KEY_PATH).read(),
|
open(config.AUTHORITY_PRIVATE_KEY_PATH).read(),
|
||||||
password=None, # TODO: Ask password for private key?
|
password=None, # TODO: Ask password for private key?
|
||||||
backend=default_backend())
|
backend=default_backend())
|
||||||
|
click.echo("Signer reading certificate from %s" % config.AUTHORITY_CERTIFICATE_PATH)
|
||||||
self.certificate = x509.load_pem_x509_certificate(
|
self.certificate = x509.load_pem_x509_certificate(
|
||||||
open(config.AUTHORITY_CERTIFICATE_PATH).read(),
|
open(config.AUTHORITY_CERTIFICATE_PATH).read(),
|
||||||
backend=default_backend())
|
backend=default_backend())
|
||||||
|
|
||||||
# Drop privileges
|
|
||||||
_, _, uid, gid, gecos, root, shell = pwd.getpwnam("nobody")
|
|
||||||
os.setgid(gid)
|
|
||||||
os.setuid(uid)
|
|
||||||
|
|
||||||
def handle_accept(self):
|
def handle_accept(self):
|
||||||
pair = self.accept()
|
pair = self.accept()
|
||||||
|
@ -14,11 +14,10 @@ backends = pam
|
|||||||
# The accounts backend specifies how the user's given name, surname and e-mail
|
# The accounts backend specifies how the user's given name, surname and e-mail
|
||||||
# address are looked up. In case of 'posix' basically 'getent passwd' is performed,
|
# address are looked up. In case of 'posix' basically 'getent passwd' is performed,
|
||||||
# in case of 'ldap' a search is performed on LDAP server specified in /etc/ldap/ldap.conf
|
# in case of 'ldap' a search is performed on LDAP server specified in /etc/ldap/ldap.conf
|
||||||
# with Kerberos credential cache initialized at path specified by 'ldap gssapi credential cache'
|
# with Kerberos credential cache initialized at path specified by environment variable KRB5CCNAME
|
||||||
|
|
||||||
backend = posix
|
backend = posix
|
||||||
;backend = ldap
|
;backend = ldap
|
||||||
ldap gssapi credential cache = /run/certidude/krb5cc
|
|
||||||
|
|
||||||
[authorization]
|
[authorization]
|
||||||
# The authorization backend specifies how the users are authorized.
|
# The authorization backend specifies how the users are authorized.
|
||||||
@ -66,6 +65,7 @@ certificate url = {{ certificate_url }}
|
|||||||
revoked url = {{ revoked_url }}
|
revoked url = {{ revoked_url }}
|
||||||
|
|
||||||
[push]
|
[push]
|
||||||
|
token = {{ push_token }}
|
||||||
event source = {{ push_server }}/ev/%s
|
event source = {{ push_server }}/ev/%s
|
||||||
long poll = {{ push_server }}/lp/%s
|
long poll = {{ push_server }}/lp/%s
|
||||||
publish = {{ push_server }}/pub?id=%s
|
publish = {{ push_server }}/pub?id=%s
|
||||||
@ -80,7 +80,6 @@ publish = {{ push_server }}/pub?id=%s
|
|||||||
;user certificate enrollment = single allowed
|
;user certificate enrollment = single allowed
|
||||||
user certificate enrollment = multiple allowed
|
user certificate enrollment = multiple allowed
|
||||||
|
|
||||||
|
|
||||||
private key path = {{ ca_key }}
|
private key path = {{ ca_key }}
|
||||||
certificate path = {{ ca_crt }}
|
certificate path = {{ ca_crt }}
|
||||||
|
|
5
certidude/templates/ldap-ticket-renewal.sh
Normal file
5
certidude/templates/ldap-ticket-renewal.sh
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
KRB5CCNAME={{ticket_path}}.part kinit -k {{name}}$ -S ldap/dc1.{{domain}}@{{realm}} -t /etc/krb5.keytab
|
||||||
|
chown certidude:certidude {{ticket_path}}.part
|
||||||
|
mv {{ticket_path}}.part {{ticket_path}}
|
||||||
|
|
@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name {{constants.FQDN}};
|
server_name {{const.FQDN}};
|
||||||
rewrite ^ https://{{constants.FQDN}}$request_uri?;
|
rewrite ^ https://{{const.FQDN}}$request_uri?;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
@ -10,7 +10,7 @@ server {
|
|||||||
add_header X-Frame-Options "DENY";
|
add_header X-Frame-Options "DENY";
|
||||||
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";
|
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name {{constants.FQDN}};
|
server_name {{const.FQDN}};
|
||||||
client_max_body_size 10G;
|
client_max_body_size 10G;
|
||||||
ssl_certificate {{certificate_path}};
|
ssl_certificate {{certificate_path}};
|
||||||
ssl_certificate_key {{key_path}};
|
ssl_certificate_key {{key_path}};
|
||||||
|
@ -1,8 +1,4 @@
|
|||||||
|
|
||||||
upstream certidude_api {
|
|
||||||
server unix:///run/uwsgi/app/certidude/socket;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
server_name {{ common_name }};
|
server_name {{ common_name }};
|
||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
@ -11,8 +7,13 @@ server {
|
|||||||
root {{static_path}};
|
root {{static_path}};
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
include uwsgi_params;
|
proxy_pass http://127.0.0.1/api/;
|
||||||
uwsgi_pass certidude_api;
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_connect_timeout 600;
|
||||||
|
proxy_send_timeout 600;
|
||||||
|
proxy_read_timeout 600;
|
||||||
|
send_timeout 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
{% if not push_server %}
|
{% if not push_server %}
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
client
|
|
||||||
remote {{remote}}
|
|
||||||
remote-cert-tls server
|
|
||||||
proto {{proto}}
|
|
||||||
dev tap
|
|
||||||
nobind
|
|
||||||
key {{key_path}}
|
|
||||||
cert {{certificate_path}}
|
|
||||||
ca {{authority_path}}
|
|
||||||
comp-lzo
|
|
||||||
user nobody
|
|
||||||
group nogroup
|
|
||||||
persist-tun
|
|
||||||
persist-key
|
|
@ -1,22 +0,0 @@
|
|||||||
mode server
|
|
||||||
tls-server
|
|
||||||
proto {{proto}}
|
|
||||||
port {{port}}
|
|
||||||
dev tap
|
|
||||||
local {{local}}
|
|
||||||
key {{key_path}}
|
|
||||||
cert {{certificate_path}}
|
|
||||||
ca {{authority_path}}
|
|
||||||
crl-verify {{revocations_path}}
|
|
||||||
dh {{dhparam_path}}
|
|
||||||
comp-lzo
|
|
||||||
user nobody
|
|
||||||
group nogroup
|
|
||||||
persist-tun
|
|
||||||
persist-key
|
|
||||||
ifconfig-pool-persist /tmp/openvpn-leases.txt
|
|
||||||
ifconfig {{subnet_first}} {{subnet.netmask}}
|
|
||||||
server-bridge {{subnet_first}} {{subnet.netmask}} {{subnet_second}} {{subnet_last}}
|
|
||||||
{% for subnet in route %}
|
|
||||||
push "route {{subnet}}"
|
|
||||||
{% endfor %}
|
|
@ -1,27 +0,0 @@
|
|||||||
# /etc/ipsec.conf - strongSwan IPsec configuration file
|
|
||||||
|
|
||||||
# left/local = client
|
|
||||||
# right/remote = gateway
|
|
||||||
|
|
||||||
config setup
|
|
||||||
|
|
||||||
conn %default
|
|
||||||
ikelifetime=60m
|
|
||||||
keylife=20m
|
|
||||||
rekeymargin=3m
|
|
||||||
keyingtries=1
|
|
||||||
keyexchange=ikev2
|
|
||||||
dpdaction={{dpdaction}}
|
|
||||||
closeaction=restart
|
|
||||||
|
|
||||||
conn client-to-site
|
|
||||||
auto={{auto}}
|
|
||||||
left=%defaultroute # Use IP of default route for listening
|
|
||||||
leftsourceip=%config # Accept server suggested virtual IP as inner address for tunnel
|
|
||||||
leftcert={{certificate_path}} # Client certificate
|
|
||||||
leftid={{common_name}} # Client certificate identifier
|
|
||||||
leftfirewall=yes # Local machine may be behind NAT
|
|
||||||
right={{remote}} # Gateway IP address
|
|
||||||
rightid=%any # Allow any common name
|
|
||||||
rightsubnet=0.0.0.0/0 # Accept all subnets suggested by server
|
|
||||||
|
|
@ -15,7 +15,7 @@ conn %default
|
|||||||
keyexchange=ikev2
|
keyexchange=ikev2
|
||||||
|
|
||||||
conn site-to-clients
|
conn site-to-clients
|
||||||
auto=add
|
auto=ignore
|
||||||
right=%any # Allow connecting from any IP address
|
right=%any # Allow connecting from any IP address
|
||||||
rightsourceip={{subnet}} # Serve virtual IP-s from this pool
|
rightsourceip={{subnet}} # Serve virtual IP-s from this pool
|
||||||
left={{common_name}} # Gateway IP address
|
left={{common_name}} # Gateway IP address
|
||||||
|
15
certidude/templates/systemd.service
Normal file
15
certidude/templates/systemd.service
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Certidude server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Environment=PYTHON_EGG_CACHE=/tmp/.cache
|
||||||
|
Environment=KRB5_KTNAME={{kerberos_keytab}}
|
||||||
|
PIDFile=/run/certidude/server.pid
|
||||||
|
ExecStart={{ certidude_path }} serve {% if listen} -l {{listen}}{% endif %}{% if port %} -p {{port}}{% endif %}
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
ExecStop=/bin/kill -s TERM $MAINPID
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -1,19 +0,0 @@
|
|||||||
[uwsgi]
|
|
||||||
exec-as-root = /usr/local/bin/certidude signer spawn
|
|
||||||
master = true
|
|
||||||
processes = 1
|
|
||||||
vacuum = true
|
|
||||||
uid = {{username}}
|
|
||||||
gid = {{username}}
|
|
||||||
plugins = python27
|
|
||||||
chdir = /tmp
|
|
||||||
module = certidude.wsgi
|
|
||||||
callable = app
|
|
||||||
chmod-socket = 660
|
|
||||||
chown-socket = {{username}}:www-data
|
|
||||||
buffer-size = 32768
|
|
||||||
env = LANG=C.UTF-8
|
|
||||||
env = LC_ALL=C.UTF-8
|
|
||||||
env = KRB5_KTNAME={{kerberos_keytab}}
|
|
||||||
env = KRB5CCNAME=/run/certidude/krb5cc
|
|
||||||
|
|
@ -5,7 +5,7 @@ import ldap
|
|||||||
import ldap.sasl
|
import ldap.sasl
|
||||||
import os
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
from certidude import constants, config
|
from certidude import const, config
|
||||||
|
|
||||||
class User(object):
|
class User(object):
|
||||||
def __init__(self, username, mail, given_name="", surname=""):
|
def __init__(self, username, mail, given_name="", surname=""):
|
||||||
@ -46,7 +46,7 @@ class PosixUserManager(object):
|
|||||||
_, _, _, _, gecos, _, _ = pwd.getpwnam(username)
|
_, _, _, _, gecos, _, _ = pwd.getpwnam(username)
|
||||||
gecos = gecos.decode("utf-8").split(",")
|
gecos = gecos.decode("utf-8").split(",")
|
||||||
full_name = gecos[0]
|
full_name = gecos[0]
|
||||||
mail = username + "@" + constants.DOMAIN
|
mail = username + "@" + const.DOMAIN
|
||||||
if full_name and " " in full_name:
|
if full_name and " " in full_name:
|
||||||
given_name, surname = full_name.split(" ", 1)
|
given_name, surname = full_name.split(" ", 1)
|
||||||
return User(username, mail, given_name, surname)
|
return User(username, mail, given_name, surname)
|
||||||
@ -67,8 +67,8 @@ class DirectoryConnection(object):
|
|||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
# TODO: Implement simple bind
|
# TODO: Implement simple bind
|
||||||
if not os.path.exists(config.LDAP_GSSAPI_CRED_CACHE):
|
if not os.path.exists(config.LDAP_GSSAPI_CRED_CACHE):
|
||||||
raise ValueError("Ticket cache not initialized, unable to "
|
raise ValueError("Ticket cache at %s not initialized, unable to "
|
||||||
"authenticate with computer account against LDAP server!")
|
"authenticate with computer account against LDAP server!" % config.LDAP_GSSAPI_CRED_CACHE)
|
||||||
os.environ["KRB5CCNAME"] = config.LDAP_GSSAPI_CRED_CACHE
|
os.environ["KRB5CCNAME"] = config.LDAP_GSSAPI_CRED_CACHE
|
||||||
for server in config.LDAP_SERVERS:
|
for server in config.LDAP_SERVERS:
|
||||||
self.conn = ldap.initialize(server)
|
self.conn = ldap.initialize(server)
|
||||||
@ -106,7 +106,7 @@ class ActiveDirectoryUserManager(object):
|
|||||||
else:
|
else:
|
||||||
given_name, surname = cn, ""
|
given_name, surname = cn, ""
|
||||||
|
|
||||||
mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + constants.DOMAIN,)
|
mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + const.DOMAIN,)
|
||||||
return User(username.decode("utf-8"), mail.decode("utf-8"),
|
return User(username.decode("utf-8"), mail.decode("utf-8"),
|
||||||
given_name.decode("utf-8"), surname.decode("utf-8"))
|
given_name.decode("utf-8"), surname.decode("utf-8"))
|
||||||
raise User.DoesNotExist("User %s does not exist" % username)
|
raise User.DoesNotExist("User %s does not exist" % username)
|
||||||
@ -121,7 +121,7 @@ class ActiveDirectoryUserManager(object):
|
|||||||
continue
|
continue
|
||||||
username, = entry.get("sAMAccountName")
|
username, = entry.get("sAMAccountName")
|
||||||
cn, = entry.get("cn")
|
cn, = entry.get("cn")
|
||||||
mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + constants.DOMAIN,)
|
mail, = entry.get("mail") or entry.get("userPrincipalName") or (username + "@" + const.DOMAIN,)
|
||||||
if entry.get("givenName") and entry.get("sn"):
|
if entry.get("givenName") and entry.get("sn"):
|
||||||
given_name, = entry.get("givenName")
|
given_name, = entry.get("givenName")
|
||||||
surname, = entry.get("sn")
|
surname, = entry.get("sn")
|
||||||
|
@ -3,7 +3,7 @@ import hashlib
|
|||||||
import re
|
import re
|
||||||
import click
|
import click
|
||||||
import io
|
import io
|
||||||
from certidude import constants
|
from certidude import const
|
||||||
from OpenSSL import crypto
|
from OpenSSL import crypto
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@ -228,7 +228,7 @@ class Request(CertificateBase):
|
|||||||
@property
|
@property
|
||||||
def signable(self):
|
def signable(self):
|
||||||
for key, value, data in self.extensions:
|
for key, value, data in self.extensions:
|
||||||
if key not in constants.EXTENSION_WHITELIST:
|
if key not in const.EXTENSION_WHITELIST:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
"""
|
|
||||||
certidude.wsgi
|
|
||||||
~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Certidude web app factory for WSGI-compatible web servers
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
from certidude.api import certidude_app
|
|
||||||
|
|
||||||
# TODO: set up /run/certidude/api paths and permissions
|
|
||||||
|
|
||||||
app = certidude_app()
|
|
@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python
|
||||||
|
|
||||||
from certidude.cli import entry_point
|
from certidude.cli import entry_point
|
||||||
|
|
||||||
|
@ -1,21 +1,25 @@
|
|||||||
cffi==1.2.1
|
|
||||||
click==5.1
|
|
||||||
configparser
|
|
||||||
cryptography==1.0
|
|
||||||
falcon==0.3.0
|
|
||||||
humanize==0.5.1
|
|
||||||
idna==2.0
|
|
||||||
ipaddress==1.0.16
|
|
||||||
ipsecparse==0.1.0
|
|
||||||
Jinja2==2.8
|
Jinja2==2.8
|
||||||
Markdown==2.6.5
|
Markdown==2.6.6
|
||||||
pyasn1==0.1.8
|
MarkupSafe==0.23
|
||||||
pycrypto==2.6.1
|
argparse==1.2.1
|
||||||
pykerberos==1.1.8
|
certifi==2016.2.28
|
||||||
pyOpenSSL==0.15.1
|
cffi==1.7.0
|
||||||
python-ldap==2.4.10
|
click==6.6
|
||||||
python-mimeparse==0.1.4
|
configparser==3.5.0
|
||||||
requests==2.2.1
|
cryptography==1.4
|
||||||
setproctitle==1.1.9
|
enum34==1.1.6
|
||||||
simplepam==0.1.5
|
falcon==1.0.0
|
||||||
six==1.9.0
|
humanize==0.5.1
|
||||||
|
ipaddress==1.0.16
|
||||||
|
Markdown==2.6.6
|
||||||
|
ndg-httpsclient==0.4.2
|
||||||
|
pyOpenSSL==16.0.0
|
||||||
|
pyasn1==0.1.9
|
||||||
|
pycparser==2.14
|
||||||
|
python-ldap==2.4.25
|
||||||
|
python-mimeparse==1.5.2
|
||||||
|
requests==2.10.0
|
||||||
|
setproctitle==1.1.10
|
||||||
|
six==1.10.0
|
||||||
|
urllib3==1.16
|
||||||
|
wsgiref==0.1.2
|
||||||
|
6
setup.py
6
setup.py
@ -10,7 +10,7 @@ setup(
|
|||||||
author_email = "lauri.vosandi@gmail.com",
|
author_email = "lauri.vosandi@gmail.com",
|
||||||
description = "Certidude is a novel X.509 Certificate Authority management tool aiming to support PKCS#11 and in far future WebCrypto.",
|
description = "Certidude is a novel X.509 Certificate Authority management tool aiming to support PKCS#11 and in far future WebCrypto.",
|
||||||
license = "MIT",
|
license = "MIT",
|
||||||
keywords = "falcon http jinja2 x509 pkcs11 webcrypto",
|
keywords = "falcon http jinja2 x509 pkcs11 webcrypto kerberos ldap",
|
||||||
url = "http://github.com/laurivosandi/certidude",
|
url = "http://github.com/laurivosandi/certidude",
|
||||||
packages=[
|
packages=[
|
||||||
"certidude",
|
"certidude",
|
||||||
@ -23,13 +23,9 @@ setup(
|
|||||||
"falcon",
|
"falcon",
|
||||||
"jinja2",
|
"jinja2",
|
||||||
"pyopenssl",
|
"pyopenssl",
|
||||||
"pycountry",
|
|
||||||
"humanize",
|
"humanize",
|
||||||
"pycrypto",
|
|
||||||
"cryptography",
|
"cryptography",
|
||||||
"markupsafe",
|
"markupsafe",
|
||||||
"ldap3",
|
|
||||||
"pykerberos",
|
|
||||||
],
|
],
|
||||||
scripts=[
|
scripts=[
|
||||||
"misc/certidude"
|
"misc/certidude"
|
||||||
|
Loading…
Reference in New Issue
Block a user