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 certidude/templates/*.sh
|
||||
include certidude/templates/*.service
|
||||
include certidude/templates/*.ovpn
|
||||
include certidude/templates/*.conf
|
||||
include certidude/templates/*.ini
|
||||
|
27
README.rst
27
README.rst
@ -79,7 +79,8 @@ To install Certidude:
|
||||
python-pysqlite2 python-mysql.connector python-ldap \
|
||||
build-essential libffi-dev libssl-dev libkrb5-dev \
|
||||
ldap-utils krb5-user \
|
||||
libsasl2-modules-gssapi-mit
|
||||
libsasl2-modules-gssapi-mit \
|
||||
libsasl2-dev libldap2-dev
|
||||
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.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
|
||||
|
||||
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
|
||||
apt-get install nginx uwsgi uwsgi-plugin-python
|
||||
apt-get -f install
|
||||
|
||||
Certidude can set up certificate authority relatively easily.
|
||||
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``,
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@ -190,6 +193,13 @@ Reset Kerberos configuration in ``/etc/krb5.conf``:
|
||||
dns_lookup_realm = 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:
|
||||
|
||||
.. 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 -
|
||||
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
|
||||
----------------------------
|
||||
|
@ -12,7 +12,7 @@ from certidude.auth import login_required, authorize_admin
|
||||
from certidude.user import User
|
||||
from certidude.decorators import serialize, event_source, csrf_protection
|
||||
from certidude.wrappers import Request, Certificate
|
||||
from certidude import constants, config
|
||||
from certidude import const, config
|
||||
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
@ -35,7 +35,7 @@ class CertificateAuthorityResource(object):
|
||||
resp.stream = open(config.AUTHORITY_CERTIFICATE_PATH, "rb")
|
||||
resp.append_header("Content-Type", "application/x-x509-ca-cert")
|
||||
resp.append_header("Content-Disposition", "attachment; filename=%s.crt" %
|
||||
constants.HOSTNAME.encode("ascii"))
|
||||
const.HOSTNAME.encode("ascii"))
|
||||
|
||||
|
||||
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"
|
||||
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?!
|
||||
if isinstance(resp.location, unicode):
|
||||
resp.location = resp.location.encode("ascii")
|
||||
@ -125,7 +125,6 @@ def certidude_app():
|
||||
from .request import RequestListResource, RequestDetailResource
|
||||
from .lease import LeaseResource
|
||||
from .whois import WhoisResource
|
||||
from .log import LogResource
|
||||
from .tag import TagResource, TagDetailResource
|
||||
from .cfg import ConfigResource, ScriptResource
|
||||
|
||||
@ -149,19 +148,6 @@ def certidude_app():
|
||||
if config.USER_CERTIFICATE_ENROLLMENT:
|
||||
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":
|
||||
uri = config.cp.get("tagging", "database")
|
||||
app.add_route("/api/tag/", TagResource(uri))
|
||||
@ -171,23 +157,5 @@ def certidude_app():
|
||||
elif 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
|
||||
|
@ -1,4 +1,5 @@
|
||||
|
||||
|
||||
import logging
|
||||
import hashlib
|
||||
from certidude import config, authority
|
||||
|
@ -36,8 +36,6 @@ join
|
||||
tag on device_tag.tag_id = tag.id
|
||||
join
|
||||
device on device_tag.device_id = device.id
|
||||
where
|
||||
device.cn = %s
|
||||
"""
|
||||
|
||||
|
||||
@ -63,7 +61,7 @@ class ConfigResource(RelationalMixin):
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
return self.iterfetch(SQL_SELECT_RULES)
|
||||
return self.iterfetch(SQL_SELECT_TAGS)
|
||||
|
||||
|
||||
class ScriptResource(RelationalMixin):
|
||||
|
@ -10,6 +10,9 @@ from certidude.decorators import serialize, csrf_protection
|
||||
from certidude.wrappers import Request, Certificate
|
||||
from certidude.firewall import whitelist_subnets, whitelist_content_types
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
class RequestListResource(object):
|
||||
@ -29,6 +32,7 @@ class RequestListResource(object):
|
||||
"""
|
||||
|
||||
body = req.stream.read(req.content_length)
|
||||
|
||||
csr = Request(body)
|
||||
|
||||
if not csr.common_name:
|
||||
@ -38,6 +42,19 @@ class RequestListResource(object):
|
||||
"Bad request",
|
||||
"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
|
||||
try:
|
||||
cert = authority.get_signed(csr.common_name)
|
||||
@ -51,8 +68,8 @@ class RequestListResource(object):
|
||||
|
||||
# TODO: check for revoked certificates and return HTTP 410 Gone
|
||||
|
||||
# Process automatic signing if the IP address is whitelisted and autosigning was requested
|
||||
if req.get_param_as_bool("autosign"):
|
||||
# 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") and csr.signable:
|
||||
for subnet in config.AUTOSIGN_SUBNETS:
|
||||
if req.context.get("remote_addr") in subnet:
|
||||
try:
|
||||
|
@ -2,7 +2,7 @@
|
||||
import falcon
|
||||
import json
|
||||
import logging
|
||||
from certidude import constants
|
||||
from certidude import const
|
||||
from certidude.authority import export_crl, list_revoked
|
||||
from certidude.decorators import MyEncoder
|
||||
from cryptography import x509
|
||||
@ -21,7 +21,7 @@ class RevocationListResource(object):
|
||||
resp.set_header("Content-Type", "application/x-pkcs7-crl")
|
||||
resp.append_header(
|
||||
"Content-Disposition",
|
||||
("attachment; filename=%s.crl" % constants.HOSTNAME).encode("ascii"))
|
||||
("attachment; filename=%s.crl" % const.HOSTNAME).encode("ascii"))
|
||||
# Convert PEM to DER
|
||||
resp.body = x509.load_pem_x509_crl(export_crl(),
|
||||
default_backend()).public_bytes(Encoding.DER)
|
||||
@ -29,7 +29,7 @@ class RevocationListResource(object):
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
resp.append_header(
|
||||
"Content-Disposition",
|
||||
("attachment; filename=%s-crl.pem" % constants.HOSTNAME).encode("ascii"))
|
||||
("attachment; filename=%s-crl.pem" % const.HOSTNAME).encode("ascii"))
|
||||
resp.body = export_crl()
|
||||
elif req.accept.startswith("application/json"):
|
||||
resp.set_header("Content-Type", "application/json")
|
||||
|
@ -1,14 +1,14 @@
|
||||
|
||||
import click
|
||||
import falcon
|
||||
import kerberos
|
||||
import kerberos # If this fails pip install kerberos
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
from certidude.user import User
|
||||
from certidude.firewall import whitelist_subnets
|
||||
from certidude import config, constants
|
||||
from certidude import config, const
|
||||
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
@ -23,32 +23,34 @@ if "kerberos" in config.AUTHENTICATION_BACKENDS:
|
||||
exit(248)
|
||||
|
||||
try:
|
||||
principal = kerberos.getServerPrincipalDetails("HTTP", constants.FQDN)
|
||||
principal = kerberos.getServerPrincipalDetails("HTTP", const.FQDN)
|
||||
except kerberos.KrbError as exc:
|
||||
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)
|
||||
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 wrapper(func):
|
||||
def kerberos_authenticate(resource, req, resp, *args, **kwargs):
|
||||
if optional and not req.get_param_as_bool("authenticate"):
|
||||
return func(resource, req, resp, *args, **kwargs)
|
||||
|
||||
# Try pre-emptive authentication
|
||||
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",
|
||||
req.env["PATH_INFO"], req.context.get("remote_addr"))
|
||||
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:])
|
||||
|
||||
try:
|
||||
result, context = kerberos.authGSSServerInit("HTTP@" + constants.FQDN)
|
||||
result, context = kerberos.authGSSServerInit("HTTP@" + const.FQDN)
|
||||
except kerberos.GSSError as ex:
|
||||
# TODO: logger.error
|
||||
raise falcon.HTTPForbidden("Forbidden",
|
||||
@ -59,22 +61,30 @@ def authenticate(optional=False):
|
||||
except kerberos.GSSError as ex:
|
||||
kerberos.authGSSServerClean(context)
|
||||
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"),
|
||||
ex.args[0][0], ex.args[0][1])
|
||||
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:
|
||||
kerberos.authGSSServerClean(context)
|
||||
logger.error(u"Kerberos authentication failed from %s. "
|
||||
"Bad credentials: %s (%d)",
|
||||
"Kerberos error: %s (%d)",
|
||||
req.context.get("remote_addr"),
|
||||
ex.args[0][0], ex.args[0][1])
|
||||
raise falcon.HTTPForbidden("Forbidden",
|
||||
"Bad credentials: %s" % (ex.args[0],))
|
||||
"Kerberos error: %s" % (ex.args[0],))
|
||||
|
||||
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:
|
||||
kerberos.authGSSServerClean(context)
|
||||
@ -114,7 +124,7 @@ def authenticate(optional=False):
|
||||
if not req.auth:
|
||||
resp.append_header("WWW-Authenticate", "Basic")
|
||||
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 "):
|
||||
raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth)
|
||||
@ -128,13 +138,13 @@ def authenticate(optional=False):
|
||||
conn = ldap.initialize(server)
|
||||
conn.set_option(ldap.OPT_REFERRALS, 0)
|
||||
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:
|
||||
resp.append_header("WWW-Authenticate", "Basic")
|
||||
logger.critical(u"LDAP bind authentication failed for user %s from %s",
|
||||
repr(user), req.context.get("remote_addr"))
|
||||
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
|
||||
break
|
||||
@ -154,8 +164,7 @@ def authenticate(optional=False):
|
||||
return func(resource, req, resp, *args, **kwargs)
|
||||
|
||||
if not req.auth:
|
||||
resp.append_header("WWW-Authenticate", "Basic")
|
||||
raise falcon.HTTPUnauthorized("Forbidden", "Please authenticate")
|
||||
raise falcon.HTTPUnauthorized("Forbidden", "Please authenticate", ("Basic",))
|
||||
|
||||
if not req.auth.startswith("Basic "):
|
||||
raise falcon.HTTPForbidden("Forbidden", "Bad header: %s" % req.auth)
|
||||
@ -168,7 +177,7 @@ def authenticate(optional=False):
|
||||
if not simplepam.authenticate(user, passwd, "sshd"):
|
||||
logger.critical(u"Basic authentication failed for user %s from %s",
|
||||
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)
|
||||
return func(resource, req, resp, *args, **kwargs)
|
||||
|
@ -1,13 +1,18 @@
|
||||
|
||||
import click
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import socket
|
||||
import requests
|
||||
from OpenSSL import crypto
|
||||
from certidude import config, push, mailer
|
||||
import socket
|
||||
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.signer import raw_sign
|
||||
from certidude import errors
|
||||
|
||||
RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])(@(([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"})
|
||||
|
||||
# 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 wrapped
|
||||
|
||||
@ -73,8 +78,16 @@ def store_request(buf, overwrite=False):
|
||||
"""
|
||||
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")
|
||||
|
||||
if not re.match(RE_HOSTNAME, common_name):
|
||||
@ -98,7 +111,7 @@ def store_request(buf, overwrite=False):
|
||||
|
||||
def signer_exec(cmd, *bits):
|
||||
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(b"\n")
|
||||
for bit in bits:
|
||||
@ -141,7 +154,7 @@ def list_revoked(directory=config.REVOKED_DIR):
|
||||
|
||||
def export_crl():
|
||||
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")
|
||||
for filename in os.listdir(config.REVOKED_DIR):
|
||||
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
|
||||
"""
|
||||
|
||||
# Construct private key
|
||||
click.echo("Generating %d-bit RSA key..." % key_size)
|
||||
key = crypto.PKey()
|
||||
key.generate_key(crypto.TYPE_RSA, key_size)
|
||||
|
||||
# Construct CSR
|
||||
csr = crypto.X509Req()
|
||||
csr.set_version(2) # Corresponds to X.509v3
|
||||
csr.set_pubkey(key)
|
||||
csr.get_subject().CN = common_name
|
||||
key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=4096,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
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.given_name:
|
||||
csr.get_subject().GN = owner.given_name
|
||||
if owner.surname:
|
||||
csr.get_subject().SN = owner.surname
|
||||
csr.add_extensions([
|
||||
crypto.X509Extension("subjectAltName", True, "email:%s" % owner.mail.encode("ascii"))])
|
||||
|
||||
buf = crypto.dump_certificate_request(crypto.FILETYPE_PEM, csr)
|
||||
click.echo("Setting e-mail to: %s" % owner.mail)
|
||||
csr = csr.add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.RFC822Name(owner.mail)
|
||||
]),
|
||||
critical=False)
|
||||
|
||||
# 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.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_ca_certificates([certificate._obj])
|
||||
return p12.export(), cert
|
||||
@ -213,7 +243,6 @@ def sign(req, overwrite=False, delete=True):
|
||||
"""
|
||||
Sign certificate signing request via signer process
|
||||
"""
|
||||
|
||||
cert_path = os.path.join(config.SIGNED_DIR, req.common_name + ".pem")
|
||||
|
||||
# Move existing certificate if necessary
|
||||
@ -236,35 +265,95 @@ def sign(req, overwrite=False, delete=True):
|
||||
|
||||
|
||||
@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.
|
||||
Basic constraints and certificate lifetime are copied from config,
|
||||
lifetime may be overridden on the command line,
|
||||
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")
|
||||
if os.path.exists(path):
|
||||
certificate_path = os.path.join(config.SIGNED_DIR, request.common_name + ".pem")
|
||||
if os.path.exists(certificate_path):
|
||||
if overwrite:
|
||||
revoke_certificate(request.common_name)
|
||||
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)
|
||||
with open(path + ".part", "wb") as fh:
|
||||
now = datetime.utcnow()
|
||||
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)
|
||||
os.rename(path + ".part", path)
|
||||
click.echo("Wrote certificate to: %s" % path)
|
||||
os.rename(certificate_path + ".part", certificate_path)
|
||||
click.echo("Wrote certificate to: %s" % certificate_path)
|
||||
if delete:
|
||||
os.unlink(request.path)
|
||||
click.echo("Deleted request: %s" % request.path)
|
||||
os.unlink(request_path)
|
||||
click.echo("Deleted request: %s" % request_path)
|
||||
|
||||
return Certificate(open(path))
|
||||
return Certificate(open(certificate_path))
|
||||
|
||||
|
1369
certidude/cli.py
1369
certidude/cli.py
File diff suppressed because it is too large
Load Diff
@ -5,11 +5,13 @@ import configparser
|
||||
import ipaddress
|
||||
import os
|
||||
import string
|
||||
import const
|
||||
from random import choice
|
||||
from urllib.parse import urlparse
|
||||
|
||||
cp = configparser.ConfigParser()
|
||||
cp.readfp(codecs.open("/etc/certidude/server.conf", "r", "utf8"))
|
||||
# Options that are parsed from config file are fetched here
|
||||
|
||||
cp = configparser.RawConfigParser()
|
||||
cp.readfp(codecs.open(const.CONFIG_PATH, "r", "utf8"))
|
||||
|
||||
AUTHENTICATION_BACKENDS = set([j for j in
|
||||
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
|
||||
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_PRIVATE_KEY_PATH = cp.get("authority", "private key path")
|
||||
AUTHORITY_CERTIFICATE_PATH = cp.get("authority", "certificate path")
|
||||
@ -49,16 +48,13 @@ USER_MULTIPLE_CERTIFICATES = {
|
||||
CERTIFICATE_BASIC_CONSTRAINTS = "CA:FALSE"
|
||||
CERTIFICATE_KEY_USAGE_FLAGS = "digitalSignature,keyEncipherment"
|
||||
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_CRL_URL = cp.get("signature", "revoked url")
|
||||
|
||||
REVOCATION_LIST_LIFETIME = int(cp.get("signature", "revocation list lifetime"))
|
||||
|
||||
PUSH_TOKEN = "".join([choice(string.ascii_letters + string.digits) for j in range(0,32)])
|
||||
|
||||
PUSH_TOKEN = "ca"
|
||||
REVOCATION_LIST_LIFETIME = cp.getint("signature", "revocation list lifetime")
|
||||
|
||||
PUSH_TOKEN = cp.get("push", "token")
|
||||
PUSH_EVENT_SOURCE = cp.get("push", "event source")
|
||||
PUSH_LONG_POLL = cp.get("push", "long poll")
|
||||
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 certidude.auth import User
|
||||
from certidude.wrappers import Request, Certificate
|
||||
from urllib.parse import urlparse
|
||||
from urlparse import urlparse
|
||||
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
@ -23,15 +23,21 @@ def csrf_protection(func):
|
||||
|
||||
# For everything else assert referrer
|
||||
referrer = req.headers.get("REFERER")
|
||||
|
||||
|
||||
if 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)
|
||||
|
||||
# Kaboom!
|
||||
logger.warning(u"Prevented clickbait from '%s' with user agent '%s'",
|
||||
referrer or "-", req.user_agent)
|
||||
raise falcon.HTTPUnauthorized("Forbidden",
|
||||
raise falcon.HTTPForbidden("Forbidden",
|
||||
"No suitable UA or referrer provided, cross-site scripting disabled")
|
||||
return wrapped
|
||||
|
||||
|
@ -6,14 +6,19 @@ import subprocess
|
||||
import tempfile
|
||||
from certidude import errors
|
||||
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 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
|
||||
"""
|
||||
|
||||
# Set up URL-s
|
||||
request_params = set()
|
||||
if autosign:
|
||||
@ -22,9 +27,10 @@ def certidude_request_certificate(server, key_path, request_path, certificate_pa
|
||||
request_params.add("wait=forever")
|
||||
|
||||
# Expand ca.example.com
|
||||
authority_url = "http://%s/api/certificate/" % server
|
||||
request_url = "http://%s/api/request/" % server
|
||||
revoked_url = "http://%s/api/revoked/" % server
|
||||
scheme = "http" if insecure else "https" # TODO: Expose in CLI
|
||||
authority_url = "%s://%s/api/certificate/" % (scheme, server)
|
||||
request_url = "%s://%s/api/request/" % (scheme, server)
|
||||
revoked_url = "%s://%s/api/revoked/" % (scheme, server)
|
||||
|
||||
if 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
|
||||
click.echo("Generating 4096-bit RSA key...")
|
||||
key = crypto.PKey()
|
||||
key.generate_key(crypto.TYPE_RSA, 4096)
|
||||
key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=4096,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
# Dump private key
|
||||
key_partial = tempfile.mktemp(prefix=key_path + ".part")
|
||||
os.umask(0o077)
|
||||
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
|
||||
csr = crypto.X509Req()
|
||||
csr.set_version(2) # Corresponds to X.509v3
|
||||
csr.set_pubkey(key)
|
||||
csr.get_subject().CN = common_name
|
||||
|
||||
request = Request(csr)
|
||||
|
||||
# Set subject attributes
|
||||
# Set subject name attributes
|
||||
names = [x509.NameAttribute(NameOID.COMMON_NAME, common_name.decode("utf-8"))]
|
||||
if given_name:
|
||||
request.given_name = given_name
|
||||
names.append(x509.NameAttribute(NameOID.GIVEN_NAME, given_name.decode("utf-8")))
|
||||
if surname:
|
||||
request.surname = surname
|
||||
names.append(x509.NameAttribute(NameOID.SURNAME, surname.decode("utf-8")))
|
||||
if org_unit:
|
||||
request.organizational_unit = org_unit
|
||||
names.append(x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT, org_unit.decode("utf-8")))
|
||||
|
||||
# Collect subject alternative names
|
||||
subject_alt_name = set()
|
||||
subject_alt_names = set()
|
||||
if email_address:
|
||||
subject_alt_name.add("email:%s" % email_address)
|
||||
subject_alt_names.add(x509.RFC822Name(email_address))
|
||||
if ip_address:
|
||||
subject_alt_name.add("IP:%s" % ip_address)
|
||||
subject_alt_names.add("IP:%s" % ip_address)
|
||||
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)
|
||||
with open(request_path + ".part", "w") as fh:
|
||||
fh.write(request.dump())
|
||||
with open(request_path + ".part", "wb") as f:
|
||||
f.write(csr.sign(key, hashes.SHA256(), default_backend()).public_bytes(serialization.Encoding.PEM))
|
||||
|
||||
click.echo("Writing private key to: %s" % 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:
|
||||
# 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):
|
||||
click.echo("Found certificate: %s" % certificate_path)
|
||||
# TODO: Check certificate validity, download CRL?
|
||||
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)
|
||||
submission = requests.post(request_url,
|
||||
auth=auth,
|
||||
data=open(request_path),
|
||||
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:
|
||||
pass
|
||||
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.text import MIMEText
|
||||
from email.mime.base import MIMEBase
|
||||
from urllib.parse import urlparse
|
||||
from urlparse import urlparse
|
||||
|
||||
env = Environment(loader=PackageLoader("certidude", "templates/mail"))
|
||||
|
||||
|
@ -23,12 +23,17 @@ def publish(event_type, event_data):
|
||||
url,
|
||||
data=event_data,
|
||||
headers={"X-EventSource-Event": event_type, "User-Agent": "Certidude API"})
|
||||
if notification.status_code != requests.codes.created:
|
||||
click.echo("Failed to submit event to push server, server responded %d, expected %d" % (
|
||||
notification.status_code, requests.codes.created))
|
||||
if notification.status_code == requests.codes.created:
|
||||
pass # Sent to client
|
||||
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:
|
||||
click.echo("Failed to submit event to push server, connection error")
|
||||
|
||||
|
||||
class PushLogHandler(logging.Handler):
|
||||
"""
|
||||
To be used with Python log handling framework for publishing log entries
|
||||
|
@ -3,7 +3,7 @@
|
||||
import click
|
||||
import re
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
from urlparse import urlparse
|
||||
|
||||
SCRIPTS = {}
|
||||
|
||||
|
@ -1,14 +1,11 @@
|
||||
|
||||
|
||||
import random
|
||||
import pwd
|
||||
import socket
|
||||
import os
|
||||
import asyncore
|
||||
import asynchat
|
||||
from certidude import constants, config
|
||||
from OpenSSL import crypto
|
||||
|
||||
from certidude import const, config
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
@ -20,99 +17,6 @@ import random
|
||||
DN_WHITELIST = NameOID.COMMON_NAME, NameOID.GIVEN_NAME, NameOID.SURNAME, \
|
||||
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):
|
||||
def __init__(self, sock, server):
|
||||
asynchat.async_chat.__init__(self, sock=sock)
|
||||
@ -162,7 +66,9 @@ class SignHandler(asynchat.async_chat):
|
||||
|
||||
cert = x509.CertificateBuilder(
|
||||
).subject_name(subject
|
||||
).serial_number(random.randint(SERIAL_MIN, SERIAL_MAX)
|
||||
).serial_number(random.randint(
|
||||
0x1000000000000000000000000000000000000000,
|
||||
0xffffffffffffffffffffffffffffffffffffffff)
|
||||
).issuer_name(self.server.certificate.issuer
|
||||
).public_key(request.public_key()
|
||||
).not_valid_before(now - timedelta(hours=1)
|
||||
@ -224,32 +130,31 @@ class SignHandler(asynchat.async_chat):
|
||||
def collect_incoming_data(self, data):
|
||||
self.buffer.append(data)
|
||||
|
||||
import signal
|
||||
import click
|
||||
|
||||
class SignServer(asyncore.dispatcher):
|
||||
def __init__(self):
|
||||
asyncore.dispatcher.__init__(self)
|
||||
|
||||
# Bind to sockets
|
||||
if os.path.exists(config.SIGNER_SOCKET_PATH):
|
||||
os.unlink(config.SIGNER_SOCKET_PATH)
|
||||
os.umask(0o007)
|
||||
if os.path.exists(const.SIGNER_SOCKET_PATH):
|
||||
os.unlink(const.SIGNER_SOCKET_PATH)
|
||||
|
||||
self.create_socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.bind(config.SIGNER_SOCKET_PATH)
|
||||
self.bind(const.SIGNER_SOCKET_PATH)
|
||||
self.listen(5)
|
||||
|
||||
# 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(
|
||||
open(config.AUTHORITY_PRIVATE_KEY_PATH).read(),
|
||||
password=None, # TODO: Ask password for private key?
|
||||
backend=default_backend())
|
||||
click.echo("Signer reading certificate from %s" % config.AUTHORITY_CERTIFICATE_PATH)
|
||||
self.certificate = x509.load_pem_x509_certificate(
|
||||
open(config.AUTHORITY_CERTIFICATE_PATH).read(),
|
||||
backend=default_backend())
|
||||
|
||||
# Drop privileges
|
||||
_, _, uid, gid, gecos, root, shell = pwd.getpwnam("nobody")
|
||||
os.setgid(gid)
|
||||
os.setuid(uid)
|
||||
|
||||
def handle_accept(self):
|
||||
pair = self.accept()
|
||||
|
@ -14,11 +14,10 @@ backends = pam
|
||||
# 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,
|
||||
# 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 = ldap
|
||||
ldap gssapi credential cache = /run/certidude/krb5cc
|
||||
|
||||
[authorization]
|
||||
# The authorization backend specifies how the users are authorized.
|
||||
@ -66,6 +65,7 @@ certificate url = {{ certificate_url }}
|
||||
revoked url = {{ revoked_url }}
|
||||
|
||||
[push]
|
||||
token = {{ push_token }}
|
||||
event source = {{ push_server }}/ev/%s
|
||||
long poll = {{ push_server }}/lp/%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 = multiple allowed
|
||||
|
||||
|
||||
private key path = {{ ca_key }}
|
||||
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 {
|
||||
listen 80;
|
||||
server_name {{constants.FQDN}};
|
||||
rewrite ^ https://{{constants.FQDN}}$request_uri?;
|
||||
server_name {{const.FQDN}};
|
||||
rewrite ^ https://{{const.FQDN}}$request_uri?;
|
||||
}
|
||||
|
||||
server {
|
||||
@ -10,7 +10,7 @@ server {
|
||||
add_header X-Frame-Options "DENY";
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";
|
||||
listen 443 ssl;
|
||||
server_name {{constants.FQDN}};
|
||||
server_name {{const.FQDN}};
|
||||
client_max_body_size 10G;
|
||||
ssl_certificate {{certificate_path}};
|
||||
ssl_certificate_key {{key_path}};
|
||||
|
@ -1,8 +1,4 @@
|
||||
|
||||
upstream certidude_api {
|
||||
server unix:///run/uwsgi/app/certidude/socket;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name {{ common_name }};
|
||||
listen 80 default_server;
|
||||
@ -11,8 +7,13 @@ server {
|
||||
root {{static_path}};
|
||||
|
||||
location /api/ {
|
||||
include uwsgi_params;
|
||||
uwsgi_pass certidude_api;
|
||||
proxy_pass http://127.0.0.1/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 %}
|
||||
|
@ -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
|
||||
|
||||
conn site-to-clients
|
||||
auto=add
|
||||
auto=ignore
|
||||
right=%any # Allow connecting from any IP address
|
||||
rightsourceip={{subnet}} # Serve virtual IP-s from this pool
|
||||
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 os
|
||||
import pwd
|
||||
from certidude import constants, config
|
||||
from certidude import const, config
|
||||
|
||||
class User(object):
|
||||
def __init__(self, username, mail, given_name="", surname=""):
|
||||
@ -46,7 +46,7 @@ class PosixUserManager(object):
|
||||
_, _, _, _, gecos, _, _ = pwd.getpwnam(username)
|
||||
gecos = gecos.decode("utf-8").split(",")
|
||||
full_name = gecos[0]
|
||||
mail = username + "@" + constants.DOMAIN
|
||||
mail = username + "@" + const.DOMAIN
|
||||
if full_name and " " in full_name:
|
||||
given_name, surname = full_name.split(" ", 1)
|
||||
return User(username, mail, given_name, surname)
|
||||
@ -67,8 +67,8 @@ class DirectoryConnection(object):
|
||||
def __enter__(self):
|
||||
# TODO: Implement simple bind
|
||||
if not os.path.exists(config.LDAP_GSSAPI_CRED_CACHE):
|
||||
raise ValueError("Ticket cache not initialized, unable to "
|
||||
"authenticate with computer account against LDAP server!")
|
||||
raise ValueError("Ticket cache at %s not initialized, unable to "
|
||||
"authenticate with computer account against LDAP server!" % config.LDAP_GSSAPI_CRED_CACHE)
|
||||
os.environ["KRB5CCNAME"] = config.LDAP_GSSAPI_CRED_CACHE
|
||||
for server in config.LDAP_SERVERS:
|
||||
self.conn = ldap.initialize(server)
|
||||
@ -106,7 +106,7 @@ class ActiveDirectoryUserManager(object):
|
||||
else:
|
||||
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"),
|
||||
given_name.decode("utf-8"), surname.decode("utf-8"))
|
||||
raise User.DoesNotExist("User %s does not exist" % username)
|
||||
@ -121,7 +121,7 @@ class ActiveDirectoryUserManager(object):
|
||||
continue
|
||||
username, = entry.get("sAMAccountName")
|
||||
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"):
|
||||
given_name, = entry.get("givenName")
|
||||
surname, = entry.get("sn")
|
||||
|
@ -3,7 +3,7 @@ import hashlib
|
||||
import re
|
||||
import click
|
||||
import io
|
||||
from certidude import constants
|
||||
from certidude import const
|
||||
from OpenSSL import crypto
|
||||
from datetime import datetime
|
||||
|
||||
@ -228,7 +228,7 @@ class Request(CertificateBase):
|
||||
@property
|
||||
def signable(self):
|
||||
for key, value, data in self.extensions:
|
||||
if key not in constants.EXTENSION_WHITELIST:
|
||||
if key not in const.EXTENSION_WHITELIST:
|
||||
return False
|
||||
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
|
||||
|
||||
|
@ -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
|
||||
Markdown==2.6.5
|
||||
pyasn1==0.1.8
|
||||
pycrypto==2.6.1
|
||||
pykerberos==1.1.8
|
||||
pyOpenSSL==0.15.1
|
||||
python-ldap==2.4.10
|
||||
python-mimeparse==0.1.4
|
||||
requests==2.2.1
|
||||
setproctitle==1.1.9
|
||||
simplepam==0.1.5
|
||||
six==1.9.0
|
||||
Markdown==2.6.6
|
||||
MarkupSafe==0.23
|
||||
argparse==1.2.1
|
||||
certifi==2016.2.28
|
||||
cffi==1.7.0
|
||||
click==6.6
|
||||
configparser==3.5.0
|
||||
cryptography==1.4
|
||||
enum34==1.1.6
|
||||
falcon==1.0.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",
|
||||
description = "Certidude is a novel X.509 Certificate Authority management tool aiming to support PKCS#11 and in far future WebCrypto.",
|
||||
license = "MIT",
|
||||
keywords = "falcon http jinja2 x509 pkcs11 webcrypto",
|
||||
keywords = "falcon http jinja2 x509 pkcs11 webcrypto kerberos ldap",
|
||||
url = "http://github.com/laurivosandi/certidude",
|
||||
packages=[
|
||||
"certidude",
|
||||
@ -23,13 +23,9 @@ setup(
|
||||
"falcon",
|
||||
"jinja2",
|
||||
"pyopenssl",
|
||||
"pycountry",
|
||||
"humanize",
|
||||
"pycrypto",
|
||||
"cryptography",
|
||||
"markupsafe",
|
||||
"ldap3",
|
||||
"pykerberos",
|
||||
],
|
||||
scripts=[
|
||||
"misc/certidude"
|
||||
|
Loading…
Reference in New Issue
Block a user