mirror of
https://github.com/laurivosandi/certidude
synced 2025-10-30 08:59:13 +00:00
Several updates #4
* Improved offline install docs * Migrated token mechanism backend to SQL * Preliminary token mechanism frontend integration * Add clock skew tolerance for OCSP * Add 'ldap computer filter' support for Kerberized machine enroll * Include OCSP and CRL URL-s in certificates, controlled by profile.conf * Better certificate extension handling * Place DH parameters file in /etc/ssl/dhparam.pem * Always talk to CA over port 8443 for 'certidude enroll' * Hardened frontend nginx config * Separate log files for frontend nginx * Better provisioning heuristics * Add sample site.sh config for LEDE image builder * Add more device profiles for LEDE image builder * Various bugfixes and improvements
This commit is contained in:
@@ -17,6 +17,7 @@ class NormalizeMiddleware(object):
|
||||
|
||||
def certidude_app(log_handlers=[]):
|
||||
from certidude import authority, config
|
||||
from certidude.tokens import TokenManager
|
||||
from .signed import SignedCertificateDetailResource
|
||||
from .request import RequestListResource, RequestDetailResource
|
||||
from .lease import LeaseResource, LeaseDetailResource
|
||||
@@ -36,10 +37,20 @@ def certidude_app(log_handlers=[]):
|
||||
app.add_route("/api/signed/{cn}/", SignedCertificateDetailResource(authority))
|
||||
app.add_route("/api/request/{cn}/", RequestDetailResource(authority))
|
||||
app.add_route("/api/request/", RequestListResource(authority))
|
||||
app.add_route("/api/", SessionResource(authority))
|
||||
|
||||
token_resource = None
|
||||
token_manager = None
|
||||
if config.USER_ENROLLMENT_ALLOWED: # TODO: add token enable/disable flag for config
|
||||
app.add_route("/api/token/", TokenResource(authority))
|
||||
if config.TOKEN_BACKEND == "sql":
|
||||
token_manager = TokenManager(config.TOKEN_DATABASE)
|
||||
token_resource = TokenResource(authority, token_manager)
|
||||
app.add_route("/api/token/", token_resource)
|
||||
elif not config.TOKEN_BACKEND:
|
||||
pass
|
||||
else:
|
||||
raise NotImplementedError("Token backend '%s' not supported" % config.TOKEN_BACKEND)
|
||||
|
||||
app.add_route("/api/", SessionResource(authority, token_manager))
|
||||
|
||||
# Extended attributes for scripting etc.
|
||||
app.add_route("/api/signed/{cn}/attr/", AttributeResource(authority, namespace="machine"))
|
||||
|
||||
@@ -11,4 +11,5 @@ class LogResource(RelationalMixin):
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
# TODO: Add last id parameter
|
||||
return self.iterfetch("select * from log order by created desc")
|
||||
return self.iterfetch("select * from log order by created desc limit ?",
|
||||
req.get_param_as_int("limit"))
|
||||
|
||||
@@ -4,8 +4,8 @@ import os
|
||||
from asn1crypto.util import timezone
|
||||
from asn1crypto import ocsp
|
||||
from base64 import b64decode
|
||||
from certidude import config
|
||||
from datetime import datetime
|
||||
from certidude import config, const
|
||||
from datetime import datetime, timedelta
|
||||
from oscrypto import asymmetric
|
||||
from .utils import AuthorityHandler
|
||||
from .utils.firewall import whitelist_subnets
|
||||
@@ -88,7 +88,8 @@ class OCSPResource(AuthorityHandler):
|
||||
'serial_number': serial,
|
||||
},
|
||||
'cert_status': status,
|
||||
'this_update': now,
|
||||
'this_update': now - const.CLOCK_SKEW_TOLERANCE,
|
||||
'next_update': now + timedelta(minutes=15) + const.CLOCK_SKEW_TOLERANCE,
|
||||
'single_extensions': []
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from base64 import b64decode
|
||||
from certidude import config, push, errors
|
||||
from certidude.decorators import csrf_protection, MyEncoder
|
||||
from certidude.profile import SignatureProfile
|
||||
from certidude.user import DirectoryConnection
|
||||
from datetime import datetime
|
||||
from oscrypto import asymmetric
|
||||
from oscrypto.errors import SignatureError
|
||||
@@ -84,13 +85,28 @@ class RequestListResource(AuthorityHandler):
|
||||
"Bad request",
|
||||
"Common name %s differs from Kerberos credential %s!" % (common_name, machine))
|
||||
|
||||
# Automatic enroll with Kerberos machine cerdentials
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
cert, resp.body = self.authority._sign(csr, body,
|
||||
profile=config.PROFILES["rw"], overwrite=overwrite_allowed)
|
||||
logger.info("Automatically enrolled Kerberos authenticated machine %s from %s",
|
||||
machine, req.context.get("remote_addr"))
|
||||
return
|
||||
hit = False
|
||||
with DirectoryConnection() as conn:
|
||||
ft = config.LDAP_COMPUTER_FILTER % ("%s$" % machine)
|
||||
attribs = "cn",
|
||||
r = conn.search_s(config.LDAP_BASE, 2, ft, attribs)
|
||||
for dn, entry in r:
|
||||
if not dn:
|
||||
continue
|
||||
else:
|
||||
hit = True
|
||||
break
|
||||
|
||||
if hit:
|
||||
# Automatic enroll with Kerberos machine cerdentials
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
cert, resp.body = self.authority._sign(csr, body,
|
||||
profile=config.PROFILES["rw"], overwrite=overwrite_allowed)
|
||||
logger.info("Automatically enrolled Kerberos authenticated machine %s (%s) from %s",
|
||||
machine, dn, req.context.get("remote_addr"))
|
||||
return
|
||||
else:
|
||||
logger.error("Kerberos authenticated machine %s didn't fit the 'ldap computer filter' criteria %s" % (machine, ft))
|
||||
|
||||
|
||||
"""
|
||||
|
||||
@@ -18,9 +18,13 @@ 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" %
|
||||
const.HOSTNAME.encode("ascii"))
|
||||
const.HOSTNAME)
|
||||
|
||||
class SessionResource(AuthorityHandler):
|
||||
def __init__(self, authority, token_manager):
|
||||
AuthorityHandler.__init__(self, authority)
|
||||
self.token_manager = token_manager
|
||||
|
||||
@csrf_protection
|
||||
@serialize
|
||||
@login_required
|
||||
@@ -97,7 +101,7 @@ class SessionResource(AuthorityHandler):
|
||||
signer_username = None
|
||||
|
||||
# TODO: dedup
|
||||
yield dict(
|
||||
serialized = dict(
|
||||
serial = "%x" % cert.serial_number,
|
||||
organizational_unit = cert.subject.native.get("organizational_unit_name"),
|
||||
common_name = common_name,
|
||||
@@ -109,12 +113,37 @@ class SessionResource(AuthorityHandler):
|
||||
lease = lease,
|
||||
tags = tags,
|
||||
attributes = attributes or None,
|
||||
extensions = dict([
|
||||
(e["extn_id"].native, e["extn_value"].native)
|
||||
for e in cert["tbs_certificate"]["extensions"]
|
||||
if e["extn_id"].native in ("extended_key_usage",)])
|
||||
responder_url = None
|
||||
)
|
||||
|
||||
for e in cert["tbs_certificate"]["extensions"].native:
|
||||
if e["extn_id"] == "key_usage":
|
||||
serialized["key_usage"] = e["extn_value"]
|
||||
elif e["extn_id"] == "extended_key_usage":
|
||||
serialized["extended_key_usage"] = e["extn_value"]
|
||||
elif e["extn_id"] == "basic_constraints":
|
||||
serialized["basic_constraints"] = e["extn_value"]
|
||||
elif e["extn_id"] == "crl_distribution_points":
|
||||
for c in e["extn_value"]:
|
||||
serialized["revoked_url"] = c["distribution_point"]
|
||||
break
|
||||
serialized["extended_key_usage"] = e["extn_value"]
|
||||
elif e["extn_id"] == "authority_information_access":
|
||||
for a in e["extn_value"]:
|
||||
if a["access_method"] == "ocsp":
|
||||
serialized["responder_url"] = a["access_location"]
|
||||
else:
|
||||
raise NotImplementedError("Don't know how to handle AIA access method %s" % a["access_method"])
|
||||
elif e["extn_id"] == "authority_key_identifier":
|
||||
pass
|
||||
elif e["extn_id"] == "key_identifier":
|
||||
pass
|
||||
elif e["extn_id"] == "subject_alt_name":
|
||||
serialized["subject_alt_name"], = e["extn_value"]
|
||||
else:
|
||||
raise NotImplementedError("Don't know how to handle extension %s" % e["extn_id"])
|
||||
yield serialized
|
||||
|
||||
logger.info("Logged in authority administrator %s from %s with %s" % (
|
||||
req.context.get("user"), req.context.get("remote_addr"), req.context.get("user_agent")))
|
||||
return dict(
|
||||
@@ -130,10 +159,12 @@ class SessionResource(AuthorityHandler):
|
||||
routers = [j[0] for j in self.authority.list_signed(
|
||||
common_name=config.SERVICE_ROUTERS)]
|
||||
),
|
||||
builder = dict(
|
||||
profiles = config.IMAGE_BUILDER_PROFILES or None
|
||||
),
|
||||
authority = dict(
|
||||
builder = dict(
|
||||
profiles = config.IMAGE_BUILDER_PROFILES
|
||||
),
|
||||
hostname = const.FQDN,
|
||||
tokens = self.token_manager.list() if self.token_manager else None,
|
||||
tagging = [dict(name=t[0], type=t[1], title=t[2]) for t in config.TAG_TYPES],
|
||||
lease = dict(
|
||||
offline = 600, # Seconds from last seen activity to consider lease offline, OpenVPN reneg-sec option
|
||||
@@ -145,32 +176,39 @@ class SessionResource(AuthorityHandler):
|
||||
distinguished_name = cert_to_dn(self.authority.certificate),
|
||||
md5sum = hashlib.md5(self.authority.certificate_buf).hexdigest(),
|
||||
blob = self.authority.certificate_buf.decode("ascii"),
|
||||
organization = self.authority.certificate["tbs_certificate"]["subject"].native.get("organization_name"),
|
||||
signed = self.authority.certificate["tbs_certificate"]["validity"]["not_before"].native.replace(tzinfo=None),
|
||||
expires = self.authority.certificate["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None)
|
||||
),
|
||||
mailer = dict(
|
||||
name = config.MAILER_NAME,
|
||||
address = config.MAILER_ADDRESS
|
||||
) if config.MAILER_ADDRESS else None,
|
||||
machine_enrollment_subnets=config.MACHINE_ENROLLMENT_SUBNETS,
|
||||
user_enrollment_allowed=config.USER_ENROLLMENT_ALLOWED,
|
||||
user_multiple_certificates=config.USER_MULTIPLE_CERTIFICATES,
|
||||
events = config.EVENT_SOURCE_SUBSCRIBE % config.EVENT_SOURCE_TOKEN,
|
||||
requests=serialize_requests(self.authority.list_requests),
|
||||
signed=serialize_certificates(self.authority.list_signed),
|
||||
revoked=serialize_revoked(self.authority.list_revoked),
|
||||
admin_users = User.objects.filter_admins(),
|
||||
user_subnets = config.USER_SUBNETS or None,
|
||||
autosign_subnets = config.AUTOSIGN_SUBNETS or None,
|
||||
request_subnets = config.REQUEST_SUBNETS or None,
|
||||
admin_subnets=config.ADMIN_SUBNETS or None,
|
||||
signature = dict(
|
||||
revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME,
|
||||
profiles = sorted([p.serialize() for p in config.PROFILES.values()], key=lambda p:p.get("slug")),
|
||||
|
||||
)
|
||||
),
|
||||
authorization = dict(
|
||||
admin_users = User.objects.filter_admins(),
|
||||
|
||||
user_subnets = config.USER_SUBNETS or None,
|
||||
autosign_subnets = config.AUTOSIGN_SUBNETS or None,
|
||||
request_subnets = config.REQUEST_SUBNETS or None,
|
||||
machine_enrollment_subnets=config.MACHINE_ENROLLMENT_SUBNETS or None,
|
||||
admin_subnets=config.ADMIN_SUBNETS or None,
|
||||
|
||||
ocsp_subnets = config.OCSP_SUBNETS or None,
|
||||
crl_subnets = config.CRL_SUBNETS or None,
|
||||
scep_subnets = config.SCEP_SUBNETS or None,
|
||||
),
|
||||
features=dict(
|
||||
ocsp=bool(config.OCSP_SUBNETS),
|
||||
crl=bool(config.CRL_SUBNETS),
|
||||
token=bool(config.TOKEN_URL),
|
||||
tagging=True,
|
||||
leases=True,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import click
|
||||
import codecs
|
||||
import falcon
|
||||
import logging
|
||||
import hashlib
|
||||
import os
|
||||
import string
|
||||
from asn1crypto import pem
|
||||
from asn1crypto.csr import CertificationRequest
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from time import time
|
||||
from certidude import mailer
|
||||
from certidude import mailer, const
|
||||
from certidude.tokens import TokenManager
|
||||
from certidude.relational import RelationalMixin
|
||||
from certidude.decorators import serialize
|
||||
from certidude.user import User
|
||||
from certidude import config
|
||||
@@ -15,33 +20,25 @@ from .utils.firewall import login_required, authorize_admin
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TokenResource(AuthorityHandler):
|
||||
def __init__(self, authority, manager):
|
||||
AuthorityHandler.__init__(self, authority)
|
||||
self.manager = manager
|
||||
|
||||
def on_get(self, req, resp):
|
||||
return
|
||||
|
||||
def on_put(self, req, resp):
|
||||
# Consume token
|
||||
now = time()
|
||||
timestamp = req.get_param_as_int("t", required=True)
|
||||
username = req.get_param("u", required=True)
|
||||
user = User.objects.get(username)
|
||||
csum = hashlib.sha256()
|
||||
csum.update(config.TOKEN_SECRET)
|
||||
csum.update(username.encode("ascii"))
|
||||
csum.update(str(timestamp).encode("ascii"))
|
||||
|
||||
margin = 300 # Tolerate 5 minute clock skew as Kerberos does
|
||||
if csum.hexdigest() != req.get_param("c", required=True):
|
||||
raise falcon.HTTPForbidden("Forbidden", "Invalid token supplied, did you copy-paste link correctly?")
|
||||
if now < timestamp - margin:
|
||||
raise falcon.HTTPForbidden("Forbidden", "Token not valid yet, are you sure server clock is correct?")
|
||||
if now > timestamp + margin + config.TOKEN_LIFETIME:
|
||||
raise falcon.HTTPForbidden("Forbidden", "Token expired")
|
||||
|
||||
# At this point consider token to be legitimate
|
||||
try:
|
||||
username, mail, created, expires, profile = self.manager.consume(req.get_param("token", required=True))
|
||||
except RelationalMixin.DoesNotExist:
|
||||
raise falcon.HTTPForbidden("Forbidden", "No such token or token expired")
|
||||
body = req.stream.read(req.content_length)
|
||||
header, _, der_bytes = pem.unarmor(body)
|
||||
csr = CertificationRequest.load(der_bytes)
|
||||
common_name = csr["certification_request_info"]["subject"].native["common_name"]
|
||||
assert common_name == username or common_name.startswith(username + "@"), "Invalid common name %s" % common_name
|
||||
try:
|
||||
_, resp.body = self.authority._sign(csr, body, profile="default",
|
||||
_, resp.body = self.authority._sign(csr, body, profile=config.PROFILES.get(profile),
|
||||
overwrite=config.TOKEN_OVERWRITE_PERMITTED)
|
||||
resp.set_header("Content-Type", "application/x-pem-file")
|
||||
logger.info("Autosigned %s as proven by token ownership", common_name)
|
||||
@@ -56,40 +53,7 @@ class TokenResource(AuthorityHandler):
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_post(self, req, resp):
|
||||
# Generate token
|
||||
issuer = req.context.get("user")
|
||||
username = req.get_param("username")
|
||||
secondary = req.get_param("mail")
|
||||
|
||||
if username:
|
||||
# Otherwise try to look up user so we can derive their e-mail address
|
||||
user = User.objects.get(username)
|
||||
else:
|
||||
# If no username is specified, assume it's intended for someone outside domain
|
||||
username = "guest-%s" % hashlib.sha256(secondary.encode("ascii")).hexdigest()[-8:]
|
||||
if not secondary:
|
||||
raise
|
||||
|
||||
timestamp = int(time())
|
||||
csum = hashlib.sha256()
|
||||
csum.update(config.TOKEN_SECRET)
|
||||
csum.update(username.encode("ascii"))
|
||||
csum.update(str(timestamp).encode("ascii"))
|
||||
args = "u=%s&t=%d&c=%s&i=%s" % (username, timestamp, csum.hexdigest(), issuer.name)
|
||||
|
||||
# Token lifetime in local time, to select timezone: dpkg-reconfigure tzdata
|
||||
token_created = datetime.fromtimestamp(timestamp)
|
||||
token_expires = datetime.fromtimestamp(timestamp + config.TOKEN_LIFETIME)
|
||||
try:
|
||||
with open("/etc/timezone") as fh:
|
||||
token_timezone = fh.read().strip()
|
||||
except EnvironmentError:
|
||||
token_timezone = None
|
||||
url = "%s#%s" % (config.TOKEN_URL, args)
|
||||
context = globals()
|
||||
context.update(locals())
|
||||
mailer.send("token.md", to=user, **context)
|
||||
return {
|
||||
"token": args,
|
||||
"url": url,
|
||||
}
|
||||
self.manager.issue(
|
||||
issuer = req.context.get("user"),
|
||||
subject = User.objects.get(req.get_param("username", required=True)),
|
||||
subject_mail = req.get_param("mail"))
|
||||
|
||||
@@ -110,7 +110,7 @@ def authenticate(optional=False):
|
||||
if kerberized:
|
||||
if not req.auth.startswith("Negotiate "):
|
||||
raise falcon.HTTPBadRequest("Bad request",
|
||||
"Bad header, expected Negotiate: %s" % req.auth)
|
||||
"Bad header, expected Negotiate")
|
||||
|
||||
os.environ["KRB5_KTNAME"] = config.KERBEROS_KEYTAB
|
||||
|
||||
@@ -158,7 +158,7 @@ def authenticate(optional=False):
|
||||
|
||||
else:
|
||||
if not req.auth.startswith("Basic "):
|
||||
raise falcon.HTTPBadRequest("Bad request", "Bad header, expected Basic: %s" % req.auth)
|
||||
raise falcon.HTTPBadRequest("Bad request", "Bad header, expected Basic")
|
||||
basic, token = req.auth.split(" ", 1)
|
||||
user, passwd = b64decode(token).decode("ascii").split(":", 1)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user