1
0
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:
2018-05-15 07:45:29 +00:00
parent 728a56a975
commit ce93fbb58b
76 changed files with 1738 additions and 603 deletions

View File

@@ -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"))

View File

@@ -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"))

View File

@@ -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': []
})

View File

@@ -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))
"""

View File

@@ -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,

View File

@@ -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"))

View File

@@ -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)