diff --git a/certidude/api/__init__.py b/certidude/api/__init__.py index 444808b..f5b1658 100644 --- a/certidude/api/__init__.py +++ b/certidude/api/__init__.py @@ -7,12 +7,12 @@ import os import hashlib from datetime import datetime from xattr import listxattr, getxattr -from certidude.auth import login_required from certidude.common import cert_to_dn from certidude.user import User from certidude.decorators import serialize, csrf_protection from certidude import const, config, authority from .utils import AuthorityHandler +from .utils.firewall import login_required, authorize_admin logger = logging.getLogger(__name__) @@ -30,6 +30,7 @@ class SessionResource(AuthorityHandler): @csrf_protection @serialize @login_required + @authorize_admin def on_get(self, req, resp): def serialize_requests(g): @@ -43,7 +44,6 @@ class SessionResource(AuthorityHandler): except IOError: submission_hostname = None yield dict( - server = self.authority.server_flags(common_name), submitted = submitted, common_name = common_name, address = submission_address, @@ -55,7 +55,7 @@ class SessionResource(AuthorityHandler): ) def serialize_revoked(g): - for common_name, path, buf, cert, signed, expired, revoked, reason in g(): + for common_name, path, buf, cert, signed, expired, revoked, reason in g(limit=5): yield dict( serial = "%x" % cert.serial_number, common_name = common_name, @@ -121,10 +121,7 @@ class SessionResource(AuthorityHandler): if e["extn_id"].native in ("extended_key_usage",)]) ) - if req.context.get("user").is_admin(): - logger.info("Logged in authority administrator %s from %s" % (req.context.get("user"), req.context.get("remote_addr"))) - else: - logger.info("Logged in authority user %s from %s" % (req.context.get("user"), req.context.get("remote_addr"))) + logger.info("Logged in authority administrator %s from %s" % (req.context.get("user"), req.context.get("remote_addr"))) return dict( user = dict( name=req.context.get("user").name, @@ -175,14 +172,15 @@ class SessionResource(AuthorityHandler): profiles = sorted([p.serialize() for p in config.PROFILES.values()], key=lambda p:p.get("slug")), ) - ) if req.context.get("user").is_admin() else None, + ), features=dict( ocsp=bool(config.OCSP_SUBNETS), crl=bool(config.CRL_SUBNETS), token=bool(config.TOKEN_URL), tagging=True, leases=True, - logging=config.LOGGING_BACKEND)) + logging=config.LOGGING_BACKEND) + ) class StaticResource(object): diff --git a/certidude/api/attrib.py b/certidude/api/attrib.py index bc34045..86d4581 100644 --- a/certidude/api/attrib.py +++ b/certidude/api/attrib.py @@ -4,9 +4,7 @@ import re from xattr import setxattr, listxattr, removexattr from certidude import push from certidude.decorators import serialize, csrf_protection -from certidude.auth import login_required, authorize_admin - -from .utils.firewall import whitelist_subject +from .utils.firewall import login_required, authorize_admin, whitelist_subject logger = logging.getLogger(__name__) diff --git a/certidude/api/builder.py b/certidude/api/builder.py index bcf23bc..eb4d44b 100644 --- a/certidude/api/builder.py +++ b/certidude/api/builder.py @@ -5,9 +5,10 @@ import logging import os import subprocess from certidude import config, const, authority -from certidude.auth import login_required, authorize_admin from certidude.common import cert_to_dn +from ipaddress import ip_network from jinja2 import Template +from .utils.firewall import login_required, authorize_admin logger = logging.getLogger(__name__) @@ -17,6 +18,7 @@ class ImageBuilderResource(object): def on_get(self, req, resp, profile, suggested_filename): router = [j[0] for j in authority.list_signed( common_name=config.cp2.get(profile, "router"))][0] + subnets = set([ip_network(j) for j in config.cp2.get(profile, "subnets").split(" ")]) model = config.cp2.get(profile, "model") build_script_path = config.cp2.get(profile, "command") overlay_path = config.cp2.get(profile, "overlay") @@ -40,6 +42,9 @@ class ImageBuilderResource(object): cwd=os.path.dirname(os.path.realpath(build_script_path)), env={"PROFILE": model, "PATH":"/usr/sbin:/usr/bin:/sbin:/bin", "ROUTER": router, + "IKE": config.cp2.get(profile, "ike"), + "ESP": config.cp2.get(profile, "esp"), + "SUBNETS": ",".join(str(j) for j in subnets), "AUTHORITY_CERTIFICATE_ALGORITHM": authority.public_key.algorithm, "AUTHORITY_CERTIFICATE_DISTINGUISHED_NAME": cert_to_dn(authority.certificate), "BUILD":build, "OVERLAY":build + "/overlay/"}, diff --git a/certidude/api/lease.py b/certidude/api/lease.py index 0bfe0aa..42c77a6 100644 --- a/certidude/api/lease.py +++ b/certidude/api/lease.py @@ -5,9 +5,9 @@ import re import xattr from datetime import datetime from certidude import config, push -from certidude.auth import login_required, authorize_admin, authorize_server from certidude.decorators import serialize from .utils import AuthorityHandler +from .utils.firewall import login_required, authorize_admin, authorize_server logger = logging.getLogger(__name__) diff --git a/certidude/api/log.py b/certidude/api/log.py index 1d925a3..78424fc 100644 --- a/certidude/api/log.py +++ b/certidude/api/log.py @@ -1,7 +1,7 @@ -from certidude.auth import login_required, authorize_admin from certidude.decorators import serialize from certidude.relational import RelationalMixin +from .utils.firewall import login_required, authorize_admin class LogResource(RelationalMixin): SQL_CREATE_TABLES = "log_tables.sql" diff --git a/certidude/api/request.py b/certidude/api/request.py index 79b968a..50b1faa 100644 --- a/certidude/api/request.py +++ b/certidude/api/request.py @@ -8,7 +8,6 @@ from asn1crypto import pem, x509 from asn1crypto.csr import CertificationRequest from base64 import b64decode from certidude import config, push, errors -from certidude.auth import login_required, login_optional, authorize_admin from certidude.decorators import csrf_protection, MyEncoder from certidude.profile import SignatureProfile from datetime import datetime @@ -16,7 +15,8 @@ from oscrypto import asymmetric from oscrypto.errors import SignatureError from xattr import getxattr, setxattr from .utils import AuthorityHandler -from .utils.firewall import whitelist_subnets, whitelist_content_types +from .utils.firewall import whitelist_subnets, whitelist_content_types, \ + login_required, login_optional, authorize_admin logger = logging.getLogger(__name__) @@ -219,7 +219,6 @@ class RequestDetailResource(AuthorityHandler): resp.body = json.dumps(dict( submitted = submitted, common_name = cn, - server = self.authority.server_flags(cn), address = getxattr(path, "user.request.address").decode("ascii"), # TODO: move to authority.py md5sum = hashlib.md5(buf).hexdigest(), sha1sum = hashlib.sha1(buf).hexdigest(), diff --git a/certidude/api/signed.py b/certidude/api/signed.py index 72a43f5..11aa166 100644 --- a/certidude/api/signed.py +++ b/certidude/api/signed.py @@ -3,10 +3,10 @@ import falcon import logging import json import hashlib -from certidude.auth import login_required, authorize_admin from certidude.decorators import csrf_protection from xattr import listxattr, getxattr from .utils import AuthorityHandler +from .utils.firewall import login_required, authorize_admin logger = logging.getLogger(__name__) diff --git a/certidude/api/tag.py b/certidude/api/tag.py index 8ea7454..557c66f 100644 --- a/certidude/api/tag.py +++ b/certidude/api/tag.py @@ -1,9 +1,9 @@ import logging from xattr import getxattr, removexattr, setxattr from certidude import push -from certidude.auth import login_required, authorize_admin from certidude.decorators import serialize, csrf_protection from .utils import AuthorityHandler +from .utils.firewall import login_required, authorize_admin logger = logging.getLogger(__name__) diff --git a/certidude/api/token.py b/certidude/api/token.py index 6d94b42..38feba3 100644 --- a/certidude/api/token.py +++ b/certidude/api/token.py @@ -9,8 +9,8 @@ from certidude import mailer from certidude.decorators import serialize from certidude.user import User from certidude import config -from certidude.auth import login_required, authorize_admin from .utils import AuthorityHandler +from .utils.firewall import login_required, authorize_admin logger = logging.getLogger(__name__) diff --git a/certidude/api/utils/firewall.py b/certidude/api/utils/firewall.py index d714b68..1e69be1 100644 --- a/certidude/api/utils/firewall.py +++ b/certidude/api/utils/firewall.py @@ -1,7 +1,16 @@ import falcon import logging +import binascii +import click +import gssapi +import os +import re +import socket from asn1crypto import pem, x509 +from base64 import b64decode +from certidude.user import User +from certidude import config, const logger = logging.getLogger("api") @@ -68,3 +77,200 @@ def whitelist_subject(func): else: return func(self, req, resp, cn, *args, **kwargs) return wrapped + + +def authenticate(optional=False): + def wrapper(func): + def kerberos_authenticate(resource, req, resp, *args, **kwargs): + # Try pre-emptive authentication + if not req.auth: + if optional: + req.context["user"] = None + return func(resource, req, resp, *args, **kwargs) + + logger.debug("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?", + ["Negotiate"]) + + os.environ["KRB5_KTNAME"] = config.KERBEROS_KEYTAB + + try: + server_creds = gssapi.creds.Credentials( + usage='accept', + name=gssapi.names.Name('HTTP/%s'% const.FQDN)) + except gssapi.raw.exceptions.BadNameError: + logger.error("Failed initialize HTTP service principal, possibly bad permissions for %s or /etc/krb5.conf" % + config.KERBEROS_KEYTAB) + raise + + context = gssapi.sec_contexts.SecurityContext(creds=server_creds) + + if not req.auth.startswith("Negotiate "): + raise falcon.HTTPBadRequest("Bad request", "Bad header, expected Negotiate: %s" % req.auth) + + token = ''.join(req.auth.split()[1:]) + + try: + context.step(b64decode(token)) + except binascii.Error: # base64 errors + raise falcon.HTTPBadRequest("Bad request", "Malformed token") + except gssapi.raw.exceptions.BadMechanismError: + raise falcon.HTTPBadRequest("Bad request", "Unsupported authentication mechanism (NTLM?) was offered. Please make sure you've logged into the computer with domain user account. The web interface should not prompt for username or password.") + + try: + username, realm = str(context.initiator_name).split("@") + except AttributeError: # TODO: Better exception + raise falcon.HTTPForbidden("Failed to determine username, are you trying to log in with correct domain account?") + + if realm != config.KERBEROS_REALM: + raise falcon.HTTPForbidden("Forbidden", + "Cross-realm trust not supported") + + if username.endswith("$") and optional: + # Extract machine hostname + # TODO: Assert LDAP group membership + req.context["machine"] = username[:-1].lower() + req.context["user"] = None + else: + # Attempt to look up real user + req.context["user"] = User.objects.get(username) + + logger.debug("Succesfully authenticated user %s for %s from %s", + req.context["user"], req.env["PATH_INFO"], req.context["remote_addr"]) + return func(resource, req, resp, *args, **kwargs) + + + def ldap_authenticate(resource, req, resp, *args, **kwargs): + """ + Authenticate against LDAP with WWW Basic Auth credentials + """ + + if optional and not req.get_param_as_bool("authenticate"): + return func(resource, req, resp, *args, **kwargs) + + import ldap + + if not req.auth: + raise falcon.HTTPUnauthorized("Unauthorized", + "No authentication header provided", + ("Basic",)) + + if not req.auth.startswith("Basic "): + raise falcon.HTTPBadRequest("Bad request", "Bad header, expected Basic: %s" % req.auth) + + from base64 import b64decode + basic, token = req.auth.split(" ", 1) + user, passwd = b64decode(token).decode("ascii").split(":", 1) + + upn = "%s@%s" % (user, const.DOMAIN) + click.echo("Connecting to %s as %s" % (config.LDAP_AUTHENTICATION_URI, upn)) + conn = ldap.initialize(config.LDAP_AUTHENTICATION_URI, bytes_mode=False) + conn.set_option(ldap.OPT_REFERRALS, 0) + + try: + conn.simple_bind_s(upn, passwd) + except ldap.STRONG_AUTH_REQUIRED: + logger.critical("LDAP server demands encryption, use ldaps:// instead of ldaps://") + raise + except ldap.SERVER_DOWN: + logger.critical("Failed to connect LDAP server at %s, are you sure LDAP server's CA certificate has been copied to this machine?", + config.LDAP_AUTHENTICATION_URI) + raise + except ldap.INVALID_CREDENTIALS: + logger.critical("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 username" % const.DOMAIN, + ("Basic",)) + + req.context["ldap_conn"] = conn + req.context["user"] = User.objects.get(user) + retval = func(resource, req, resp, *args, **kwargs) + conn.unbind_s() + return retval + + + def pam_authenticate(resource, req, resp, *args, **kwargs): + """ + Authenticate against PAM with WWW Basic Auth credentials + """ + + if optional and not req.get_param_as_bool("authenticate"): + return func(resource, req, resp, *args, **kwargs) + + if not req.auth: + raise falcon.HTTPUnauthorized("Forbidden", "Please authenticate", ("Basic",)) + + if not req.auth.startswith("Basic "): + raise falcon.HTTPBadRequest("Bad request", "Bad header: %s" % req.auth) + + basic, token = req.auth.split(" ", 1) + user, passwd = b64decode(token).decode("ascii").split(":", 1) + + import simplepam + if not simplepam.authenticate(user, passwd, "sshd"): + logger.critical("Basic authentication failed for user %s from %s, " + "are you sure server process has read access to /etc/shadow?", + repr(user), req.context.get("remote_addr")) + raise falcon.HTTPUnauthorized("Forbidden", "Invalid password", ("Basic",)) + + req.context["user"] = User.objects.get(user) + return func(resource, req, resp, *args, **kwargs) + + def wrapped(resource, req, resp, *args, **kwargs): + # If LDAP enabled and device is not Kerberos capable fall + # back to LDAP bind authentication + if "ldap" in config.AUTHENTICATION_BACKENDS: + if "Android" in req.user_agent or "iPhone" in req.user_agent: + return ldap_authenticate(resource, req, resp, *args, **kwargs) + if "kerberos" in config.AUTHENTICATION_BACKENDS: + return kerberos_authenticate(resource, req, resp, *args, **kwargs) + elif config.AUTHENTICATION_BACKENDS == {"pam"}: + return pam_authenticate(resource, req, resp, *args, **kwargs) + elif config.AUTHENTICATION_BACKENDS == {"ldap"}: + return ldap_authenticate(resource, req, resp, *args, **kwargs) + else: + raise NotImplementedError("Authentication backend %s not supported" % config.AUTHENTICATION_BACKENDS) + return wrapped + return wrapper + + +def login_required(func): + return authenticate()(func) + +def login_optional(func): + return authenticate(optional=True)(func) + +def authorize_admin(func): + @whitelist_subnets(config.ADMIN_SUBNETS) + def wrapped(resource, req, resp, *args, **kwargs): + if req.context.get("user").is_admin(): + req.context["admin_authorized"] = True + return func(resource, req, resp, *args, **kwargs) + logger.info("User '%s' not authorized to access administrative API", req.context.get("user").name) + raise falcon.HTTPForbidden("Forbidden", "User not authorized to perform administrative operations") + return wrapped + +def authorize_server(func): + """ + Make sure the request originator has a certificate with server flags + """ + from asn1crypto import pem, x509 + def wrapped(resource, req, resp, *args, **kwargs): + buf = req.get_header("X-SSL-CERT") + if not buf: + logger.info("No TLS certificate presented to access administrative API call") + raise falcon.HTTPForbidden("Forbidden", "Machine not authorized to perform the operation") + + header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii")) + cert = x509.Certificate.load(der_bytes) # TODO: validate serial + for extension in cert["tbs_certificate"]["extensions"]: + if extension["extn_id"].native == "extended_key_usage": + if "server_auth" in extension["extn_value"].native: + req.context["machine"] = cert.subject.native["common_name"] + return func(resource, req, resp, *args, **kwargs) + logger.info("TLS authenticated machine '%s' not authorized to access administrative API", cert.subject.native["common_name"]) + raise falcon.HTTPForbidden("Forbidden", "Machine not authorized to perform the operation") + return wrapped diff --git a/certidude/auth.py b/certidude/auth.py deleted file mode 100644 index 0daecfb..0000000 --- a/certidude/auth.py +++ /dev/null @@ -1,209 +0,0 @@ - -import binascii -import click -import gssapi -import falcon -import logging -import os -import re -import socket -from base64 import b64decode -from certidude.user import User -from certidude import config, const - -logger = logging.getLogger("api") - -def authenticate(optional=False): - def wrapper(func): - def kerberos_authenticate(resource, req, resp, *args, **kwargs): - # Try pre-emptive authentication - if not req.auth: - if optional: - req.context["user"] = None - return func(resource, req, resp, *args, **kwargs) - - logger.debug("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?", - ["Negotiate"]) - - os.environ["KRB5_KTNAME"] = config.KERBEROS_KEYTAB - - try: - server_creds = gssapi.creds.Credentials( - usage='accept', - name=gssapi.names.Name('HTTP/%s'% const.FQDN)) - except gssapi.raw.exceptions.BadNameError: - logger.error("Failed initialize HTTP service principal, possibly bad permissions for %s or /etc/krb5.conf" % - config.KERBEROS_KEYTAB) - raise - - context = gssapi.sec_contexts.SecurityContext(creds=server_creds) - - if not req.auth.startswith("Negotiate "): - raise falcon.HTTPBadRequest("Bad request", "Bad header: %s" % req.auth) - - token = ''.join(req.auth.split()[1:]) - - try: - context.step(b64decode(token)) - except binascii.Error: # base64 errors - raise falcon.HTTPBadRequest("Bad request", "Malformed token") - except gssapi.raw.exceptions.BadMechanismError: - raise falcon.HTTPBadRequest("Bad request", "Unsupported authentication mechanism (NTLM?) was offered. Please make sure you've logged into the computer with domain user account. The web interface should not prompt for username or password.") - - try: - username, realm = str(context.initiator_name).split("@") - except AttributeError: # TODO: Better exception - raise falcon.HTTPForbidden("Failed to determine username, are you trying to log in with correct domain account?") - - if realm != config.KERBEROS_REALM: - raise falcon.HTTPForbidden("Forbidden", - "Cross-realm trust not supported") - - if username.endswith("$") and optional: - # Extract machine hostname - # TODO: Assert LDAP group membership - req.context["machine"] = username[:-1].lower() - req.context["user"] = None - else: - # Attempt to look up real user - req.context["user"] = User.objects.get(username) - - logger.debug("Succesfully authenticated user %s for %s from %s", - req.context["user"], req.env["PATH_INFO"], req.context["remote_addr"]) - return func(resource, req, resp, *args, **kwargs) - - - def ldap_authenticate(resource, req, resp, *args, **kwargs): - """ - Authenticate against LDAP with WWW Basic Auth credentials - """ - - if optional and not req.get_param_as_bool("authenticate"): - return func(resource, req, resp, *args, **kwargs) - - import ldap - - if not req.auth: - raise falcon.HTTPUnauthorized("Unauthorized", - "No authentication header provided", - ("Basic",)) - - if not req.auth.startswith("Basic "): - raise falcon.HTTPBadRequest("Bad request", "Bad header: %s" % req.auth) - - from base64 import b64decode - basic, token = req.auth.split(" ", 1) - user, passwd = b64decode(token).decode("ascii").split(":", 1) - - upn = "%s@%s" % (user, const.DOMAIN) - click.echo("Connecting to %s as %s" % (config.LDAP_AUTHENTICATION_URI, upn)) - conn = ldap.initialize(config.LDAP_AUTHENTICATION_URI, bytes_mode=False) - conn.set_option(ldap.OPT_REFERRALS, 0) - - try: - conn.simple_bind_s(upn, passwd) - except ldap.STRONG_AUTH_REQUIRED: - logger.critical("LDAP server demands encryption, use ldaps:// instead of ldaps://") - raise - except ldap.SERVER_DOWN: - logger.critical("Failed to connect LDAP server at %s, are you sure LDAP server's CA certificate has been copied to this machine?", - config.LDAP_AUTHENTICATION_URI) - raise - except ldap.INVALID_CREDENTIALS: - logger.critical("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 username" % const.DOMAIN, - ("Basic",)) - - req.context["ldap_conn"] = conn - req.context["user"] = User.objects.get(user) - retval = func(resource, req, resp, *args, **kwargs) - conn.unbind_s() - return retval - - - def pam_authenticate(resource, req, resp, *args, **kwargs): - """ - Authenticate against PAM with WWW Basic Auth credentials - """ - - if optional and not req.get_param_as_bool("authenticate"): - return func(resource, req, resp, *args, **kwargs) - - if not req.auth: - raise falcon.HTTPUnauthorized("Forbidden", "Please authenticate", ("Basic",)) - - if not req.auth.startswith("Basic "): - raise falcon.HTTPBadRequest("Bad request", "Bad header: %s" % req.auth) - - basic, token = req.auth.split(" ", 1) - user, passwd = b64decode(token).decode("ascii").split(":", 1) - - import simplepam - if not simplepam.authenticate(user, passwd, "sshd"): - logger.critical("Basic authentication failed for user %s from %s, " - "are you sure server process has read access to /etc/shadow?", - repr(user), req.context.get("remote_addr")) - raise falcon.HTTPUnauthorized("Forbidden", "Invalid password", ("Basic",)) - - req.context["user"] = User.objects.get(user) - return func(resource, req, resp, *args, **kwargs) - - def wrapped(resource, req, resp, *args, **kwargs): - # If LDAP enabled and device is not Kerberos capable fall - # back to LDAP bind authentication - if "ldap" in config.AUTHENTICATION_BACKENDS: - if "Android" in req.user_agent or "iPhone" in req.user_agent: - return ldap_authenticate(resource, req, resp, *args, **kwargs) - if "kerberos" in config.AUTHENTICATION_BACKENDS: - return kerberos_authenticate(resource, req, resp, *args, **kwargs) - elif config.AUTHENTICATION_BACKENDS == {"pam"}: - return pam_authenticate(resource, req, resp, *args, **kwargs) - elif config.AUTHENTICATION_BACKENDS == {"ldap"}: - return ldap_authenticate(resource, req, resp, *args, **kwargs) - else: - raise NotImplementedError("Authentication backend %s not supported" % config.AUTHENTICATION_BACKENDS) - return wrapped - return wrapper - - -def login_required(func): - return authenticate()(func) - -def login_optional(func): - return authenticate(optional=True)(func) - -def authorize_admin(func): - def wrapped(resource, req, resp, *args, **kwargs): - if req.context.get("user").is_admin(): - req.context["admin_authorized"] = True - return func(resource, req, resp, *args, **kwargs) - logger.info("User '%s' not authorized to access administrative API", req.context.get("user").name) - raise falcon.HTTPForbidden("Forbidden", "User not authorized to perform administrative operations") - return wrapped - -def authorize_server(func): - """ - Make sure the request originator has a certificate with server flags - """ - from asn1crypto import pem, x509 - def wrapped(resource, req, resp, *args, **kwargs): - buf = req.get_header("X-SSL-CERT") - if not buf: - logger.info("No TLS certificate presented to access administrative API call") - raise falcon.HTTPForbidden("Forbidden", "Machine not authorized to perform the operation") - - header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii")) - cert = x509.Certificate.load(der_bytes) # TODO: validate serial - for extension in cert["tbs_certificate"]["extensions"]: - if extension["extn_id"].native == "extended_key_usage": - if "server_auth" in extension["extn_value"].native: - req.context["machine"] = cert.subject.native["common_name"] - return func(resource, req, resp, *args, **kwargs) - logger.info("TLS authenticated machine '%s' not authorized to access administrative API", cert.subject.native["common_name"]) - raise falcon.HTTPForbidden("Forbidden", "Machine not authorized to perform the operation") - return wrapped diff --git a/certidude/authority.py b/certidude/authority.py index e66ad63..70cda66 100644 --- a/certidude/authority.py +++ b/certidude/authority.py @@ -48,7 +48,7 @@ with open(config.AUTHORITY_PRIVATE_KEY_PATH, "rb") as fh: header, _, key_der_bytes = pem.unarmor(key_buf) private_key = asymmetric.load_private_key(key_der_bytes) -def self_enroll(): +def self_enroll(skip_notify=False): assert os.getuid() == 0 and os.getgid() == 0, "Can self-enroll only as root" from certidude import const @@ -87,7 +87,7 @@ def self_enroll(): click.echo("Writing request to %s" % path) with open(path, "wb") as fh: fh.write(pem_armor_csr(request)) # Write CSR with certidude permissions - authority.sign(common_name, skip_push=True, overwrite=True, profile=config.PROFILES["srv"]) + authority.sign(common_name, skip_notify=skip_notify, skip_push=True, overwrite=True, profile=config.PROFILES["srv"]) sys.exit(0) else: os.waitpid(pid, 0) @@ -243,22 +243,10 @@ def revoke(common_name, reason): attach_cert = buf, "application/x-pem-file", common_name + ".crt" mailer.send("certificate-revoked.md", attachments=(attach_cert,), - serial_hex="%040x" % cert.serial_number, + serial_hex="%x" % cert.serial_number, common_name=common_name) return revoked_path -def server_flags(cn): - if config.USER_ENROLLMENT_ALLOWED and not config.USER_MULTIPLE_CERTIFICATES: - # Common name set to username, used for only HTTPS client validation anyway - return False - if "@" in cn: - # username@hostname is user certificate anyway, can't be server - return False - if "." in cn: - # CN is hostname, if contains dot has to be FQDN, hence a server - return True - return False - def list_requests(directory=config.REQUESTS_DIR): for filename in os.listdir(directory): @@ -297,12 +285,16 @@ def list_signed(directory=config.SIGNED_DIR, common_name=None): path, buf, cert, signed, expires = get_signed(basename) yield basename, path, buf, cert, signed, expires -def list_revoked(directory=config.REVOKED_DIR): - for filename in os.listdir(directory): +def list_revoked(directory=config.REVOKED_DIR, limit=0): + for filename in sorted(os.listdir(directory), reverse=True): if filename.endswith(".pem"): common_name = filename[:-4] path, buf, cert, signed, expired, revoked, reason = get_revoked(common_name) yield cert.subject.native["common_name"], path, buf, cert, signed, expired, revoked, reason + if limit: + limit -= 1 + if limit <= 0: + return def list_server_names(): @@ -324,7 +316,10 @@ def export_crl(pem=True): serial_number = filename[:-4] # TODO: Assert serial against regex revoked_path = os.path.join(config.REVOKED_DIR, filename) - reason = getxattr(revoked_path, "user.revocation.reason").decode("ascii") # TODO: dedup + try: + reason = getxattr(revoked_path, "user.revocation.reason").decode("ascii") # TODO: dedup + except IOError: # TODO: make sure it's not required + reason = "key_compromise" # TODO: Skip expired certificates s = os.stat(revoked_path) @@ -404,7 +399,7 @@ def _sign(csr, buf, profile, skip_notify=False, skip_push=False, overwrite=False if overwrite: # TODO: is this the best approach? - prev_serial_hex = "%040x" % prev.serial_number + prev_serial_hex = "%x" % prev.serial_number revoked_path = os.path.join(config.REVOKED_DIR, "%s.pem" % prev_serial_hex) os.rename(cert_path, revoked_path) attachments += [(prev_buf, "application/x-pem-file", "deprecated.crt" if renew else "overwritten.crt")] @@ -433,7 +428,7 @@ def _sign(csr, buf, profile, skip_notify=False, skip_push=False, overwrite=False os.rename(cert_path + ".part", cert_path) attachments.append((end_entity_cert_buf, "application/x-pem-file", common_name + ".crt")) - cert_serial_hex = "%040x" % end_entity_cert.serial_number + cert_serial_hex = "%x" % end_entity_cert.serial_number # Create symlink link_name = os.path.join(config.SIGNED_BY_SERIAL_DIR, "%040x.pem" % end_entity_cert.serial_number) diff --git a/certidude/cli.py b/certidude/cli.py index e72e838..429c171 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -24,7 +24,6 @@ from glob import glob from ipaddress import ip_network from oscrypto import asymmetric - logger = logging.getLogger(__name__) # http://www.mad-hacking.net/documentation/linux/security/ssl-tls/creating-ca.xml @@ -569,6 +568,10 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): nm_config.set("vpn", "key", key_path) nm_config.set("vpn", "cert", certificate_path) nm_config.set("vpn", "ca", authority_path) + nm_config.set("vpn", "tls-cipher", "TLS-%s-WITH-AES-128-GCM-SHA384" % ( + "ECDHE-ECDSA" if authority_public_key.algorithm == "ec" else "DHE-RSA")) + nm_config.set("vpn", "cipher", "AES-128-GCM") + nm_config.set("vpn", "auth", "SHA384") nm_config.add_section("ipv4") nm_config.set("ipv4", "method", "auto") nm_config.set("ipv4", "never-default", "true") @@ -617,6 +620,11 @@ def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): nm_config.set("vpn", "userkey", key_path) nm_config.set("vpn", "usercert", certificate_path) nm_config.set("vpn", "certificate", authority_path) + dhgroup = "ecp384" if authority_public_key.algorithm == "ec" else "modp2048" + nm_config.set("vpn", "ike", "aes256-sha384-prfsha384-" + dhgroup) + nm_config.set("vpn", "esp", "aes128gcm16-aes128gmac-" + dhgroup) + nm_config.set("vpn", "proposal", "yes") + nm_config.add_section("ipv4") nm_config.set("ipv4", "method", "auto") @@ -982,13 +990,12 @@ def certidude_setup_openvpn_networkmanager(authority, remote, common_name, **pat @click.option("--organizational-unit", "-ou", default="Certificate Authority") @click.option("--push-server", help="Push server, by default http://%s" % const.FQDN) @click.option("--directory", help="Directory for authority files") -@click.option("--server-flags", is_flag=True, help="Add TLS Server and IKE Intermediate extended key usage flags") @click.option("--outbox", default="smtp://smtp.%s" % const.DOMAIN, help="SMTP server, smtp://smtp.%s by default" % const.DOMAIN) @click.option("--skip-packages", is_flag=True, help="Don't attempt to install apt/pip/npm packages") @click.option("--elliptic-curve", "-e", is_flag=True, help="Generate EC instead of RSA keypair") @fqdn_required -def certidude_setup_authority(username, kerberos_keytab, nginx_config, organization, organizational_unit, common_name, directory, authority_lifetime, push_server, outbox, server_flags, title, skip_packages, elliptic_curve): - assert subprocess.check_output(["/usr/bin/lsb_release", "-cs"]) == b"xenial\n", "Only Ubuntu 16.04 supported at the moment" +def certidude_setup_authority(username, kerberos_keytab, nginx_config, organization, organizational_unit, common_name, directory, authority_lifetime, push_server, outbox, title, skip_packages, elliptic_curve): + assert subprocess.check_output(["/usr/bin/lsb_release", "-cs"]) in (b"trusty\n", b"xenial\n", b"bionic\n"), "Only Ubuntu 16.04 supported at the moment" assert os.getuid() == 0 and os.getgid() == 0, "Authority can be set up only by root" import pwd @@ -1047,6 +1054,9 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, organizat ca_cert = os.path.join(directory, "ca_cert.pem") sqlite_path = os.path.join(directory, "meta", "db.sqlite") + # Builder variables + dhgroup = "ecp384" if elliptic_curve else "modp2048" + try: pwd.getpwnam("certidude") click.echo("User 'certidude' already exists") @@ -1084,6 +1094,9 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, organizat else: click.echo("Warning: /etc/krb5.keytab or /etc/samba/smb.conf not found, Kerberos unconfigured") + letsencrypt_fullchain = "/etc/letsencrypt/live/%s/fullchain.pem" % common_name + letsencrypt_privkey = "/etc/letsencrypt/live/%s/privkey.pem" % common_name + letsencrypt = os.path.exists(letsencrypt_fullchain) doc_path = os.path.join(os.path.realpath(os.path.dirname(os.path.dirname(__file__))), "doc") script_dir = os.path.join(os.path.realpath(os.path.dirname(__file__)), "templates", "script") @@ -1140,9 +1153,9 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, organizat os.system("npm install --silent -g nunjucks@2.5.2 nunjucks-date@1.2.0 node-forge bootstrap@4.0.0-alpha.6 jquery timeago tether font-awesome qrcode-svg") # Compile nunjucks templates - cmd = 'nunjucks-precompile --include ".html$" --include ".svg" %s > %s.part' % (static_path, bundle_js) + cmd = 'nunjucks-precompile --include ".html$" --include ".ps1$" --include ".sh$" --include ".svg" %s > %s.part' % (static_path, bundle_js) click.echo("Compiling templates: %s" % cmd) - os.system(cmd) + assert os.system(cmd) == 0 # Assemble bundle.js click.echo("Assembling %s" % bundle_js) @@ -1265,8 +1278,17 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, organizat else: os.waitpid(bootstrap_pid, 0) from certidude import authority - authority.self_enroll() + authority.self_enroll(skip_notify=True) assert os.getuid() == 0 and os.getgid() == 0, "Enroll contaminated environment" + click.echo("Enabling and starting Certidude backend") + os.system("systemctl enable certidude") + os.system("systemctl restart certidude") + click.echo("Enabling and starting nginx") + os.system("systemctl enable nginx") + os.system("systemctl start nginx") + os.system("systemctl reload nginx") + click.echo() + click.echo("To enable e-mail notifications install Postfix as sattelite system and set mailer address in %s" % const.SERVER_CONFIG_PATH) click.echo() click.echo("Use following commands to inspect the newly created files:") @@ -1274,11 +1296,6 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, organizat click.echo(" openssl x509 -text -noout -in %s | less" % ca_cert) click.echo(" openssl rsa -check -in %s" % ca_key) click.echo(" openssl verify -CAfile %s %s" % (ca_cert, ca_cert)) - click.echo() - click.echo("To enable and start the service:") - click.echo() - click.echo(" systemctl enable certidude") - click.echo(" systemctl start certidude") return 0 diff --git a/certidude/common.py b/certidude/common.py index 01b1d10..f543476 100644 --- a/certidude/common.py +++ b/certidude/common.py @@ -23,22 +23,12 @@ def cn_to_dn(common_name, namespace, o=None, ou=None): from asn1crypto.x509 import Name, RelativeDistinguishedName, NameType, DirectoryString, RDNSequence, NameTypeAndValue, UTF8String, DNSName rdns = [] - rdns.append(RelativeDistinguishedName([ - NameTypeAndValue({ - 'type': NameType.map("common_name"), - 'value': DirectoryString( - name="utf8_string", - value=UTF8String(common_name)) - }) - ])) - if ou: + for dc in reversed(namespace.split(".")): rdns.append(RelativeDistinguishedName([ NameTypeAndValue({ - 'type': NameType.map("organizational_unit_name"), - 'value': DirectoryString( - name="utf8_string", - value=UTF8String(ou)) + 'type': NameType.map("domain_component"), + 'value': DNSName(value=dc) }) ])) @@ -52,14 +42,25 @@ def cn_to_dn(common_name, namespace, o=None, ou=None): }) ])) - for dc in namespace.split("."): + if ou: rdns.append(RelativeDistinguishedName([ NameTypeAndValue({ - 'type': NameType.map("domain_component"), - 'value': DNSName(value=dc) + 'type': NameType.map("organizational_unit_name"), + 'value': DirectoryString( + name="utf8_string", + value=UTF8String(ou)) }) ])) + rdns.append(RelativeDistinguishedName([ + NameTypeAndValue({ + 'type': NameType.map("common_name"), + 'value': DirectoryString( + name="utf8_string", + value=UTF8String(common_name)) + }) + ])) + return Name(name='', value=RDNSequence(rdns)) def selinux_fixup(path): diff --git a/certidude/config.py b/certidude/config.py index 6e26130..956017d 100644 --- a/certidude/config.py +++ b/certidude/config.py @@ -27,7 +27,7 @@ LDAP_BASE = cp.get("accounts", "ldap base") USER_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "user subnets").split(" ") if j]) ADMIN_SUBNETS = set([ipaddress.ip_network(j) for j in - cp.get("authorization", "admin subnets").split(" ") if j]).union(USER_SUBNETS) + cp.get("authorization", "admin subnets").split(" ") if j]) AUTOSIGN_SUBNETS = set([ipaddress.ip_network(j) for j in cp.get("authorization", "autosign subnets").split(" ") if j]) REQUEST_SUBNETS = set([ipaddress.ip_network(j) for j in diff --git a/certidude/decorators.py b/certidude/decorators.py index 638151c..1eaaea0 100644 --- a/certidude/decorators.py +++ b/certidude/decorators.py @@ -42,7 +42,7 @@ def csrf_protection(func): class MyEncoder(json.JSONEncoder): def default(self, obj): - from certidude.auth import User + from certidude.user import User if isinstance(obj, ipaddress._IPAddressBase): return str(obj) if isinstance(obj, set): diff --git a/certidude/static/index.html b/certidude/static/index.html index 726cabb..0787ec0 100644 --- a/certidude/static/index.html +++ b/certidude/static/index.html @@ -19,40 +19,23 @@
-Loading certificate authority...
-