1
0
mirror of https://github.com/laurivosandi/certidude synced 2025-10-30 08:59:13 +00:00

Major refactor

* Migrate to Python 3
* Update token generator mechanism
* Switch to Bootstrap 4
* Switch from Iconmonstr to Font Awesome icons
* Rename default CA common name to "Certidude at ca.example.lan"
* Add self-enroll for the TLS server certificates
* TLS client auth for lease updating
* Compile assets from npm packages to /var/lib/certidude/ca.example.lan/assets
This commit is contained in:
2017-12-30 13:57:48 +00:00
parent d32ec224d7
commit 59bedc1f16
69 changed files with 1617 additions and 1549 deletions

View File

@@ -6,7 +6,7 @@ import logging
import os
import click
import hashlib
from datetime import datetime
from datetime import datetime, timedelta
from time import sleep
from xattr import listxattr, getxattr
from certidude import authority, mailer
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
class CertificateAuthorityResource(object):
def on_get(self, req, resp):
logger.info(u"Served CA certificate to %s", req.context.get("remote_addr"))
logger.info("Served CA certificate to %s", req.context.get("remote_addr"))
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" %
@@ -34,23 +34,43 @@ class SessionResource(object):
def on_get(self, req, resp):
def serialize_requests(g):
for common_name, path, buf, obj, server in g():
for common_name, path, buf, req, submitted, server in g():
try:
submission_address = getxattr(path, "user.request.address").decode("ascii") # TODO: move to authority.py
except IOError:
submission_address = None
try:
submission_hostname = getxattr(path, "user.request.hostname").decode("ascii") # TODO: move to authority.py
except IOError:
submission_hostname = None
yield dict(
submitted = submitted,
common_name = common_name,
server = server,
address = getxattr(path, "user.request.address"), # TODO: move to authority.py
address = submission_address,
hostname = submission_hostname if submission_hostname != submission_address else None,
md5sum = hashlib.md5(buf).hexdigest(),
sha1sum = hashlib.sha1(buf).hexdigest(),
sha256sum = hashlib.sha256(buf).hexdigest(),
sha512sum = hashlib.sha512(buf).hexdigest()
)
def serialize_revoked(g):
for common_name, path, buf, cert, signed, expired, revoked in g():
yield dict(
serial = "%x" % cert.serial_number,
common_name = common_name,
# TODO: key type, key length, key exponent, key modulo
signed = signed,
expired = expired,
revoked = revoked,
sha256sum = hashlib.sha256(buf).hexdigest())
def serialize_certificates(g):
for common_name, path, buf, obj, server in g():
for common_name, path, buf, cert, signed, expires in g():
# Extract certificate tags from filesystem
try:
tags = []
for tag in getxattr(path, "user.xdg.tags").split(","):
for tag in getxattr(path, "user.xdg.tags").decode("ascii").split(","):
if "=" in tag:
k, v = tag.split("=", 1)
else:
@@ -61,38 +81,47 @@ class SessionResource(object):
attributes = {}
for key in listxattr(path):
if key.startswith("user.machine."):
attributes[key[13:]] = getxattr(path, key)
if key.startswith(b"user.machine."):
attributes[key[13:]] = getxattr(path, key).decode("ascii")
# Extract lease information from filesystem
try:
last_seen = datetime.strptime(getxattr(path, "user.lease.last_seen"), "%Y-%m-%dT%H:%M:%S.%fZ")
last_seen = datetime.strptime(getxattr(path, "user.lease.last_seen").decode("ascii"), "%Y-%m-%dT%H:%M:%S.%fZ")
lease = dict(
inner_address = getxattr(path, "user.lease.inner_address"),
outer_address = getxattr(path, "user.lease.outer_address"),
inner_address = getxattr(path, "user.lease.inner_address").decode("ascii"),
outer_address = getxattr(path, "user.lease.outer_address").decode("ascii"),
last_seen = last_seen,
age = datetime.utcnow() - last_seen
)
except IOError: # No such attribute(s)
lease = None
try:
signer_username = getxattr(path, "user.signature.username").decode("ascii")
except IOError:
signer_username = None
yield dict(
serial_number = "%x" % obj.serial_number,
serial = "%x" % cert.serial_number,
common_name = common_name,
server = server,
# TODO: key type, key length, key exponent, key modulo
signed = obj["tbs_certificate"]["validity"]["not_before"].native,
expires = obj["tbs_certificate"]["validity"]["not_after"].native,
signed = signed,
expires = expires,
sha256sum = hashlib.sha256(buf).hexdigest(),
signer = signer_username,
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_value"] in ("extended_key_usage",)])
)
if req.context.get("user").is_admin():
logger.info(u"Logged in authority administrator %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")))
else:
logger.info(u"Logged in authority user %s from %s" % (req.context.get("user"), req.context.get("remote_addr")))
logger.info("Logged in authority user %s from %s" % (req.context.get("user"), req.context.get("remote_addr")))
return dict(
user = dict(
name=req.context.get("user").name,
@@ -107,7 +136,8 @@ class SessionResource(object):
offline = 600, # Seconds from last seen activity to consider lease offline, OpenVPN reneg-sec option
dead = 604800 # Seconds from last activity to consider lease dead, X509 chain broken or machine discarded
),
common_name = authority.certificate.subject.native["common_name"],
common_name = const.FQDN,
title = authority.certificate.subject.native["common_name"],
mailer = dict(
name = config.MAILER_NAME,
address = config.MAILER_ADDRESS
@@ -118,16 +148,17 @@ class SessionResource(object):
events = config.EVENT_SOURCE_SUBSCRIBE % config.EVENT_SOURCE_TOKEN,
requests=serialize_requests(authority.list_requests),
signed=serialize_certificates(authority.list_signed),
revoked=serialize_certificates(authority.list_revoked),
revoked=serialize_revoked(authority.list_revoked),
admin_users = User.objects.filter_admins(),
user_subnets = config.USER_SUBNETS,
autosign_subnets = config.AUTOSIGN_SUBNETS,
request_subnets = config.REQUEST_SUBNETS,
admin_subnets=config.ADMIN_SUBNETS,
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(
server_certificate_lifetime=config.SERVER_CERTIFICATE_LIFETIME,
client_certificate_lifetime=config.CLIENT_CERTIFICATE_LIFETIME,
revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME
revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME,
profiles = [dict(organizational_unit=ou, flags=f, lifetime=lt) for f, lt, ou in config.PROFILES.values()]
)
) if req.context.get("user").is_admin() else None,
features=dict(
@@ -155,22 +186,17 @@ class StaticResource(object):
if content_encoding:
resp.append_header("Content-Encoding", content_encoding)
resp.stream = open(path, "rb")
logger.debug(u"Serving '%s' from '%s'", req.path, path)
logger.debug("Serving '%s' from '%s'", req.path, path)
else:
resp.status = falcon.HTTP_404
resp.body = "File '%s' not found" % req.path
logger.info(u"File '%s' not found, path resolved to '%s'", req.path, path)
logger.info("File '%s' not found, path resolved to '%s'", req.path, path)
import ipaddress
class NormalizeMiddleware(object):
def process_request(self, req, resp, *args):
assert not req.get_param("unicode") or req.get_param("unicode") == u"", "Unicode sanity check failed"
req.context["remote_addr"] = ipaddress.ip_address(req.access_route[0].decode("utf-8"))
def process_response(self, req, resp, resource=None):
# wtf falcon?!
if isinstance(resp.location, unicode):
resp.location = resp.location.encode("ascii")
req.context["remote_addr"] = ipaddress.ip_address(req.access_route[0])
def certidude_app(log_handlers=[]):
from certidude import config
@@ -194,7 +220,7 @@ def certidude_app(log_handlers=[]):
app.add_route("/api/request/", RequestListResource())
app.add_route("/api/", SessionResource())
if config.BUNDLE_FORMAT and config.USER_ENROLLMENT_ALLOWED:
if config.USER_ENROLLMENT_ALLOWED: # TODO: add token enable/disable flag for config
app.add_route("/api/token/", TokenResource())
# Extended attributes for scripting etc.
@@ -224,7 +250,6 @@ def certidude_app(log_handlers=[]):
from .scep import SCEPResource
app.add_route("/api/scep/", SCEPResource())
# Add sink for serving static files
app.add_sink(StaticResource(os.path.join(__file__, "..", "..", "static")))

View File

@@ -36,8 +36,9 @@ class AttributeResource(object):
@csrf_protection
@whitelist_subject # TODO: sign instead
def on_post(self, req, resp, cn):
namespace = ("user.%s." % self.namespace).encode("ascii")
try:
path, buf, cert = authority.get_signed(cn)
path, buf, cert, signed, expires = authority.get_signed(cn)
except IOError:
raise falcon.HTTPNotFound()
else:
@@ -50,7 +51,7 @@ class AttributeResource(object):
setxattr(path, identifier, value.encode("utf-8"))
valid.add(identifier)
for key in listxattr(path):
if not key.startswith("user.%s." % self.namespace):
if not key.startswith(namespace):
continue
if key not in valid:
removexattr(path, key)

View File

@@ -5,7 +5,7 @@ import logging
import xattr
from datetime import datetime
from certidude import config, authority, push
from certidude.auth import login_required, authorize_admin
from certidude.auth import login_required, authorize_admin, authorize_server
from certidude.decorators import serialize
logger = logging.getLogger(__name__)
@@ -18,9 +18,9 @@ class LeaseDetailResource(object):
@authorize_admin
def on_get(self, req, resp, cn):
try:
path, buf, cert = authority.get_signed(cn)
path, buf, cert, signed, expires = authority.get_signed(cn)
return dict(
last_seen = xattr.getxattr(path, "user.lease.last_seen"),
last_seen = xattr.getxattr(path, "user.lease.last_seen").decode("ascii"),
inner_address = xattr.getxattr(path, "user.lease.inner_address").decode("ascii"),
outer_address = xattr.getxattr(path, "user.lease.outer_address").decode("ascii")
)
@@ -29,10 +29,10 @@ class LeaseDetailResource(object):
class LeaseResource(object):
@authorize_server
def on_post(self, req, resp):
# TODO: verify signature
common_name = req.get_param("client", required=True)
path, buf, cert = authority.get_signed(common_name) # TODO: catch exceptions
path, buf, cert, signed, expires = authority.get_signed(common_name) # TODO: catch exceptions
if req.get_param("serial") and cert.serial_number != req.get_param_as_int("serial"): # OCSP-ish solution for OpenVPN, not exposed for StrongSwan
raise falcon.HTTPForbidden("Forbidden", "Invalid serial number supplied")

View File

@@ -22,7 +22,7 @@ class OCSPResource(object):
else:
raise falcon.HTTPMethodNotAllowed()
fh = open(config.AUTHORITY_CERTIFICATE_PATH)
fh = open(config.AUTHORITY_CERTIFICATE_PATH, "rb") # TODO: import from authority
server_certificate = asymmetric.load_certificate(fh.read())
fh.close()
@@ -35,7 +35,7 @@ class OCSPResource(object):
if ext["extn_id"].native == "nonce":
response_extensions.append(
ocsp.ResponseDataExtension({
'extn_id': u"nonce",
'extn_id': "nonce",
'critical': False,
'extn_value': ext["extn_value"]
})
@@ -51,18 +51,19 @@ class OCSPResource(object):
link_target = os.readlink(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % serial))
assert link_target.startswith("../")
assert link_target.endswith(".pem")
path, buf, cert = authority.get_signed(link_target[3:-4])
path, buf, cert, signed, expires = authority.get_signed(link_target[3:-4])
if serial != cert.serial_number:
raise EnvironmentError("integrity check failed")
logger.error("Certificate store integrity check failed, %s refers to certificate with serial %x" % (link_target, cert.serial_number))
raise EnvironmentError("Integrity check failed")
status = ocsp.CertStatus(name='good', value=None)
except EnvironmentError:
try:
path, buf, cert, revoked = authority.get_revoked(serial)
path, buf, cert, signed, expires, revoked = authority.get_revoked(serial)
status = ocsp.CertStatus(
name='revoked',
value={
'revocation_time': revoked,
'revocation_reason': u"key_compromise",
'revocation_reason': "key_compromise",
})
except EnvironmentError:
status = ocsp.CertStatus(name="unknown", value=None)
@@ -70,7 +71,7 @@ class OCSPResource(object):
responses.append({
'cert_id': {
'hash_algorithm': {
'algorithm': u"sha1"
'algorithm': "sha1"
},
'issuer_name_hash': server_certificate.asn1.subject.sha1,
'issuer_key_hash': server_certificate.public_key.asn1.sha1,
@@ -89,13 +90,13 @@ class OCSPResource(object):
})
resp.body = ocsp.OCSPResponse({
'response_status': u"successful",
'response_status': "successful",
'response_bytes': {
'response_type': u"basic_ocsp_response",
'response_type': "basic_ocsp_response",
'response': {
'tbs_response_data': response_data,
'certs': [server_certificate.asn1],
'signature_algorithm': {'algorithm': u"sha1_rsa"},
'signature_algorithm': {'algorithm': "sha1_rsa"},
'signature': asymmetric.rsa_pkcs1v15_sign(
authority.private_key,
response_data.dump(),

View File

@@ -11,7 +11,7 @@ from asn1crypto.csr import CertificationRequest
from base64 import b64decode
from certidude import config, authority, push, errors
from certidude.auth import login_required, login_optional, authorize_admin
from certidude.decorators import serialize, csrf_protection
from certidude.decorators import csrf_protection, MyEncoder
from certidude.firewall import whitelist_subnets, whitelist_content_types
from datetime import datetime
from oscrypto import asymmetric
@@ -36,7 +36,7 @@ class RequestListResource(object):
Validate and parse certificate signing request, the RESTful way
"""
reasons = []
body = req.stream.read(req.content_length).encode("ascii")
body = req.stream.read(req.content_length)
header, _, der_bytes = pem.unarmor(body)
csr = CertificationRequest.load(der_bytes)
@@ -56,7 +56,7 @@ class RequestListResource(object):
# Automatic enroll with Kerberos machine cerdentials
resp.set_header("Content-Type", "application/x-pem-file")
cert, resp.body = authority._sign(csr, body, overwrite=True)
logger.info(u"Automatically enrolled Kerberos authenticated machine %s from %s",
logger.info("Automatically enrolled Kerberos authenticated machine %s from %s",
machine, req.context.get("remote_addr"))
return
else:
@@ -66,7 +66,7 @@ class RequestListResource(object):
Attempt to renew certificate using currently valid key pair
"""
try:
path, buf, cert = authority.get_signed(common_name)
path, buf, cert, signed, expires = authority.get_signed(common_name)
except EnvironmentError:
pass # No currently valid certificate for this common name
else:
@@ -85,8 +85,8 @@ class RequestListResource(object):
try:
renewal_signature = b64decode(renewal_header)
except TypeError, ValueError:
logger.error(u"Renewal failed, bad signature supplied for %s", common_name)
except (TypeError, ValueError):
logger.error("Renewal failed, bad signature supplied for %s", common_name)
reasons.append("Renewal failed, bad signature supplied")
else:
try:
@@ -94,20 +94,20 @@ class RequestListResource(object):
asymmetric.load_certificate(cert),
renewal_signature, buf + body, "sha512")
except SignatureError:
logger.error(u"Renewal failed, invalid signature supplied for %s", common_name)
logger.error("Renewal failed, invalid signature supplied for %s", common_name)
reasons.append("Renewal failed, invalid signature supplied")
else:
# At this point renewal signature was valid but we need to perform some extra checks
if datetime.utcnow() > expires:
logger.error(u"Renewal failed, current certificate for %s has expired", common_name)
logger.error("Renewal failed, current certificate for %s has expired", common_name)
reasons.append("Renewal failed, current certificate expired")
elif not config.CERTIFICATE_RENEWAL_ALLOWED:
logger.error(u"Renewal requested for %s, but not allowed by authority settings", common_name)
logger.error("Renewal requested for %s, but not allowed by authority settings", common_name)
reasons.append("Renewal requested, but not allowed by authority settings")
else:
resp.set_header("Content-Type", "application/x-x509-user-cert")
_, resp.body = authority._sign(csr, body, overwrite=True)
logger.info(u"Renewed certificate for %s", common_name)
logger.info("Renewed certificate for %s", common_name)
return
@@ -122,10 +122,10 @@ class RequestListResource(object):
try:
resp.set_header("Content-Type", "application/x-pem-file")
_, resp.body = authority._sign(csr, body)
logger.info(u"Autosigned %s as %s is whitelisted", common_name, req.context.get("remote_addr"))
logger.info("Autosigned %s as %s is whitelisted", common_name, req.context.get("remote_addr"))
return
except EnvironmentError:
logger.info(u"Autosign for %s from %s failed, signed certificate already exists",
logger.info("Autosign for %s from %s failed, signed certificate already exists",
common_name, req.context.get("remote_addr"))
reasons.append("Autosign failed, signed certificate already exists")
break
@@ -143,7 +143,7 @@ class RequestListResource(object):
# We should still redirect client to long poll URL below
except errors.DuplicateCommonNameError:
# TODO: Certificate renewal
logger.warning(u"Rejected signing request with overlapping common name from %s",
logger.warning("Rejected signing request with overlapping common name from %s",
req.context.get("remote_addr"))
raise falcon.HTTPConflict(
"CSR with such CN already exists",
@@ -152,14 +152,14 @@ class RequestListResource(object):
push.publish("request-submitted", common_name)
# Wait the certificate to be signed if waiting is requested
logger.info(u"Stored signing request %s from %s", common_name, req.context.get("remote_addr"))
logger.info("Stored signing request %s from %s", common_name, req.context.get("remote_addr"))
if req.get_param("wait"):
# Redirect to nginx pub/sub
url = config.LONG_POLL_SUBSCRIBE % hashlib.sha256(body).hexdigest()
click.echo("Redirecting to: %s" % url)
resp.status = falcon.HTTP_SEE_OTHER
resp.set_header("Location", url.encode("ascii"))
logger.debug(u"Redirecting signing request from %s to %s", req.context.get("remote_addr"), url)
resp.set_header("Location", url)
logger.debug("Redirecting signing request from %s to %s", req.context.get("remote_addr"), url)
else:
# Request was accepted, but not processed
resp.status = falcon.HTTP_202
@@ -173,14 +173,14 @@ class RequestDetailResource(object):
"""
try:
path, buf, _ = authority.get_request(cn)
path, buf, _, submitted = authority.get_request(cn)
except errors.RequestDoesNotExist:
logger.warning(u"Failed to serve non-existant request %s to %s",
logger.warning("Failed to serve non-existant request %s to %s",
cn, req.context.get("remote_addr"))
raise falcon.HTTPNotFound()
resp.set_header("Content-Type", "application/pkcs10")
logger.debug(u"Signing request %s was downloaded by %s",
logger.debug("Signing request %s was downloaded by %s",
cn, req.context.get("remote_addr"))
preferred_type = req.client_prefers(("application/json", "application/x-pem-file"))
@@ -195,13 +195,14 @@ class RequestDetailResource(object):
resp.set_header("Content-Type", "application/json")
resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % cn))
resp.body = json.dumps(dict(
submitted = submitted,
common_name = cn,
server = authority.server_flags(cn),
address = getxattr(path, "user.request.address"), # TODO: move to authority.py
address = getxattr(path, "user.request.address").decode("ascii"), # TODO: move to authority.py
md5sum = hashlib.md5(buf).hexdigest(),
sha1sum = hashlib.sha1(buf).hexdigest(),
sha256sum = hashlib.sha256(buf).hexdigest(),
sha512sum = hashlib.sha512(buf).hexdigest()))
sha512sum = hashlib.sha512(buf).hexdigest()), cls=MyEncoder)
else:
raise falcon.HTTPUnsupportedMediaType(
"Client did not accept application/json or application/x-pem-file")
@@ -214,13 +215,16 @@ class RequestDetailResource(object):
"""
Sign a certificate signing request
"""
cert, buf = authority.sign(cn, overwrite=True)
# Mailing and long poll publishing implemented in the function above
try:
cert, buf = authority.sign(cn, ou=req.get_param("ou"), overwrite=True, signer=req.context.get("user").name)
# Mailing and long poll publishing implemented in the function above
except EnvironmentError: # no such CSR
raise falcon.HTTPNotFound()
resp.body = "Certificate successfully signed"
resp.status = falcon.HTTP_201
resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn)
logger.info(u"Signing request %s signed by %s from %s", cn,
logger.info("Signing request %s signed by %s from %s", cn,
req.context.get("user"), req.context.get("remote_addr"))
@csrf_protection
@@ -232,6 +236,6 @@ class RequestDetailResource(object):
# Logging implemented in the function above
except errors.RequestDoesNotExist as e:
resp.body = "No certificate signing request for %s found" % cn
logger.warning(u"User %s failed to delete signing request %s from %s, reason: %s",
logger.warning("User %s failed to delete signing request %s from %s, reason: %s",
req.context["user"], cn, req.context.get("remote_addr"), e)
raise falcon.HTTPNotFound()

View File

@@ -18,25 +18,25 @@ class RevocationListResource(object):
resp.set_header("Content-Type", "application/x-pkcs7-crl")
resp.append_header(
"Content-Disposition",
("attachment; filename=%s.crl" % const.HOSTNAME).encode("ascii"))
("attachment; filename=%s.crl" % const.HOSTNAME))
# Convert PEM to DER
logger.debug(u"Serving revocation list (DER) to %s", req.context.get("remote_addr"))
logger.debug("Serving revocation list (DER) to %s", req.context.get("remote_addr"))
resp.body = export_crl(pem=False)
elif req.client_accepts("application/x-pem-file"):
if req.get_param_as_bool("wait"):
url = config.LONG_POLL_SUBSCRIBE % "crl"
resp.status = falcon.HTTP_SEE_OTHER
resp.set_header("Location", url.encode("ascii"))
logger.debug(u"Redirecting to CRL request to %s", url)
resp.set_header("Location", url)
logger.debug("Redirecting to CRL request to %s", url)
resp.body = "Redirecting to %s" % url
else:
resp.set_header("Content-Type", "application/x-pem-file")
resp.append_header(
"Content-Disposition",
("attachment; filename=%s-crl.pem" % const.HOSTNAME).encode("ascii"))
logger.debug(u"Serving revocation list (PEM) to %s", req.context.get("remote_addr"))
("attachment; filename=%s-crl.pem" % const.HOSTNAME))
logger.debug("Serving revocation list (PEM) to %s", req.context.get("remote_addr"))
resp.body = export_crl()
else:
logger.debug(u"Client %s asked revocation list in unsupported format" % req.context.get("remote_addr"))
logger.debug("Client %s asked revocation list in unsupported format" % req.context.get("remote_addr"))
raise falcon.HTTPUnsupportedMediaType(
"Client did not accept application/x-pkcs7-crl or application/x-pem-file")

View File

@@ -15,12 +15,12 @@ from oscrypto.errors import SignatureError
class SetOfPrintableString(SetOf):
_child_spec = PrintableString
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.2'] = u"message_type"
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.3'] = u"pki_status"
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.4'] = u"fail_info"
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.5'] = u"sender_nonce"
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.6'] = u"recipient_nonce"
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.7'] = u"trans_id"
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.2'] = "message_type"
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.3'] = "pki_status"
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.4'] = "fail_info"
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.5'] = "sender_nonce"
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.6'] = "recipient_nonce"
cms.CMSAttributeType._map['2.16.840.1.113733.1.9.7'] = "trans_id"
cms.CMSAttribute._oid_specs['message_type'] = SetOfPrintableString
cms.CMSAttribute._oid_specs['pki_status'] = SetOfPrintableString
@@ -41,25 +41,20 @@ class SCEPResource(object):
def on_get(self, req, resp):
operation = req.get_param("operation")
if operation.lower() == "getcacert":
resp.stream = keys.parse_certificate(authority.certificate_buf).dump()
resp.body = keys.parse_certificate(authority.certificate_buf).dump()
resp.append_header("Content-Type", "application/x-x509-ca-cert")
return
# Parse CA certificate
fh = open(config.AUTHORITY_CERTIFICATE_PATH)
server_certificate = asymmetric.load_certificate(fh.read())
fh.close()
# If we bump into exceptions later
encrypted_container = b""
attr_list = [
cms.CMSAttribute({
'type': u"message_type",
'values': [u"3"]
'type': "message_type",
'values': ["3"]
}),
cms.CMSAttribute({
'type': u"pki_status",
'values': [u"2"] # rejected
'type': "pki_status",
'values': ["2"] # rejected
})
]
@@ -98,7 +93,7 @@ class SCEPResource(object):
assert message_digest
msg = signer["signed_attrs"].dump(force=True)
assert msg[0] == b"\xa0", repr(msg[0])
assert msg[0] == 160
# Verify signature
try:
@@ -139,9 +134,9 @@ class SCEPResource(object):
signed_certificate = asymmetric.load_certificate(buf)
content = signed_certificate.asn1.dump()
except SCEPError, e:
except SCEPError as e:
attr_list.append(cms.CMSAttribute({
'type': u"fail_info",
'type': "fail_info",
'values': ["%d" % e.code]
}))
else:
@@ -151,17 +146,17 @@ class SCEPResource(object):
##################################
degenerate = cms.ContentInfo({
'content_type': u"signed_data",
'content_type': "signed_data",
'content': cms.SignedData({
'version': u"v1",
'version': "v1",
'certificates': [signed_certificate.asn1],
'digest_algorithms': [cms.DigestAlgorithm({
'algorithm': u"md5"
'algorithm': "md5"
})],
'encap_content_info': {
'content_type': u"data",
'content_type': "data",
'content': cms.ContentInfo({
'content_type': u"signed_data",
'content_type': "signed_data",
'content': None
}).dump()
},
@@ -180,7 +175,7 @@ class SCEPResource(object):
ri = cms.RecipientInfo({
'ktri': cms.KeyTransRecipientInfo({
'version': u"v0",
'version': "v0",
'rid': cms.RecipientIdentifier({
'issuer_and_serial_number': cms.IssuerAndSerialNumber({
'issuer': current_certificate.chosen["tbs_certificate"]["issuer"],
@@ -188,7 +183,7 @@ class SCEPResource(object):
}),
}),
'key_encryption_algorithm': {
'algorithm': u"rsa"
'algorithm': "rsa"
},
'encrypted_key': asymmetric.rsa_pkcs1v15_encrypt(
asymmetric.load_certificate(current_certificate.chosen.dump()), key)
@@ -196,14 +191,14 @@ class SCEPResource(object):
})
encrypted_container = cms.ContentInfo({
'content_type': u"enveloped_data",
'content_type': "enveloped_data",
'content': cms.EnvelopedData({
'version': u"v1",
'version': "v1",
'recipient_infos': [ri],
'encrypted_content_info': {
'content_type': u"data",
'content_type': "data",
'content_encryption_algorithm': {
'algorithm': u"des",
'algorithm': "des",
'parameters': iv
},
'encrypted_content': encrypted_content
@@ -213,16 +208,16 @@ class SCEPResource(object):
attr_list = [
cms.CMSAttribute({
'type': u"message_digest",
'type': "message_digest",
'values': [hashlib.sha1(encrypted_container).digest()]
}),
cms.CMSAttribute({
'type': u"message_type",
'values': [u"3"]
'type': "message_type",
'values': ["3"]
}),
cms.CMSAttribute({
'type': u"pki_status",
'values': [u"0"] # ok
'type': "pki_status",
'values': ["0"] # ok
})
]
finally:
@@ -233,26 +228,26 @@ class SCEPResource(object):
attrs = cms.CMSAttributes(attr_list + [
cms.CMSAttribute({
'type': u"recipient_nonce",
'type': "recipient_nonce",
'values': [sender_nonce]
}),
cms.CMSAttribute({
'type': u"trans_id",
'type': "trans_id",
'values': [transaction_id]
})
])
signer = cms.SignerInfo({
"signed_attrs": attrs,
'version': u"v1",
'version': "v1",
'sid': cms.SignerIdentifier({
'issuer_and_serial_number': cms.IssuerAndSerialNumber({
'issuer': server_certificate.asn1["tbs_certificate"]["issuer"],
'serial_number': server_certificate.asn1["tbs_certificate"]["serial_number"],
'issuer': authority.certificate.issuer,
'serial_number': authority.certificate.serial_number,
}),
}),
'digest_algorithm': algos.DigestAlgorithm({'algorithm': u"sha1"}),
'signature_algorithm': algos.SignedDigestAlgorithm({'algorithm': u"rsassa_pkcs1v15"}),
'digest_algorithm': algos.DigestAlgorithm({'algorithm': "sha1"}),
'signature_algorithm': algos.SignedDigestAlgorithm({'algorithm': "rsassa_pkcs1v15"}),
'signature': asymmetric.rsa_pkcs1v15_sign(
authority.private_key,
b"\x31" + attrs.dump()[1:],
@@ -262,15 +257,15 @@ class SCEPResource(object):
resp.append_header("Content-Type", "application/x-pki-message")
resp.body = cms.ContentInfo({
'content_type': u"signed_data",
'content_type': "signed_data",
'content': cms.SignedData({
'version': u"v1",
'certificates': [x509.Certificate.load(server_certificate.asn1.dump())], # wat
'version': "v1",
'certificates': [authority.certificate],
'digest_algorithms': [cms.DigestAlgorithm({
'algorithm': u"sha1"
'algorithm': "sha1"
})],
'encap_content_info': {
'content_type': u"data",
'content_type': "data",
'content': encrypted_container
},
'signer_infos': [signer]

View File

@@ -22,7 +22,7 @@ class ScriptResource():
k, v = tag.split("=", 1)
named_tags[k] = v
else:
other_tags.append(v)
other_tags.append(tag)
except AttributeError: # No tags
pass
@@ -34,5 +34,5 @@ class ScriptResource():
other_tags=other_tags,
named_tags=named_tags,
attributes=attribs.get("user").get("machine"))
logger.info(u"Served script %s for %s at %s" % (script, cn, req.context["remote_addr"]))
logger.info("Served script %s for %s at %s" % (script, cn, req.context["remote_addr"]))
# TODO: Assert time is within reasonable range

View File

@@ -6,6 +6,7 @@ import hashlib
from certidude import authority
from certidude.auth import login_required, authorize_admin
from certidude.decorators import csrf_protection
from xattr import getxattr
logger = logging.getLogger(__name__)
@@ -14,9 +15,9 @@ class SignedCertificateDetailResource(object):
preferred_type = req.client_prefers(("application/json", "application/x-pem-file"))
try:
path, buf, cert = authority.get_signed(cn)
path, buf, cert, signed, expires = authority.get_signed(cn)
except EnvironmentError:
logger.warning(u"Failed to serve non-existant certificate %s to %s",
logger.warning("Failed to serve non-existant certificate %s to %s",
cn, req.context.get("remote_addr"))
raise falcon.HTTPNotFound()
@@ -24,21 +25,26 @@ class SignedCertificateDetailResource(object):
resp.set_header("Content-Type", "application/x-pem-file")
resp.set_header("Content-Disposition", ("attachment; filename=%s.pem" % cn))
resp.body = buf
logger.debug(u"Served certificate %s to %s as application/x-pem-file",
logger.debug("Served certificate %s to %s as application/x-pem-file",
cn, req.context.get("remote_addr"))
elif preferred_type == "application/json":
resp.set_header("Content-Type", "application/json")
resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % cn))
try:
signer_username = getxattr(path, "user.signature.username").decode("ascii")
except IOError:
signer_username = None
resp.body = json.dumps(dict(
common_name = cn,
signer = signer_username,
serial_number = "%x" % cert.serial_number,
signed = cert["tbs_certificate"]["validity"]["not_before"].native.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
expires = cert["tbs_certificate"]["validity"]["not_after"].native.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
sha256sum = hashlib.sha256(buf).hexdigest()))
logger.debug(u"Served certificate %s to %s as application/json",
logger.debug("Served certificate %s to %s as application/json",
cn, req.context.get("remote_addr"))
else:
logger.debug(u"Client did not accept application/json or application/x-pem-file")
logger.debug("Client did not accept application/json or application/x-pem-file")
raise falcon.HTTPUnsupportedMediaType(
"Client did not accept application/json or application/x-pem-file")
@@ -46,7 +52,7 @@ class SignedCertificateDetailResource(object):
@login_required
@authorize_admin
def on_delete(self, req, resp, cn):
logger.info(u"Revoked certificate %s by %s from %s",
logger.info("Revoked certificate %s by %s from %s",
cn, req.context.get("user"), req.context.get("remote_addr"))
authority.revoke(cn)

View File

@@ -12,10 +12,10 @@ class TagResource(object):
@login_required
@authorize_admin
def on_get(self, req, resp, cn):
path, buf, cert = authority.get_signed(cn)
path, buf, cert, signed, expires = authority.get_signed(cn)
tags = []
try:
for tag in getxattr(path, "user.xdg.tags").split(","):
for tag in getxattr(path, "user.xdg.tags").decode("utf-8").split(","):
if "=" in tag:
k, v = tag.split("=", 1)
else:
@@ -30,7 +30,7 @@ class TagResource(object):
@login_required
@authorize_admin
def on_post(self, req, resp, cn):
path, buf, cert = authority.get_signed(cn)
path, buf, cert, signed, expires = authority.get_signed(cn)
key, value = req.get_param("key", required=True), req.get_param("value", required=True)
try:
tags = set(getxattr(path, "user.xdg.tags").decode("utf-8").split(","))
@@ -41,7 +41,7 @@ class TagResource(object):
else:
tags.add("%s=%s" % (key,value))
setxattr(path, "user.xdg.tags", ",".join(tags).encode("utf-8"))
logger.debug(u"Tag %s=%s set for %s" % (key, value, cn))
logger.debug("Tag %s=%s set for %s" % (key, value, cn))
push.publish("tag-update", cn)
@@ -50,7 +50,7 @@ class TagDetailResource(object):
@login_required
@authorize_admin
def on_put(self, req, resp, cn, tag):
path, buf, cert = authority.get_signed(cn)
path, buf, cert, signed, expires = authority.get_signed(cn)
value = req.get_param("value", required=True)
try:
tags = set(getxattr(path, "user.xdg.tags").decode("utf-8").split(","))
@@ -65,19 +65,19 @@ class TagDetailResource(object):
else:
tags.add(value)
setxattr(path, "user.xdg.tags", ",".join(tags).encode("utf-8"))
logger.debug(u"Tag %s set to %s for %s" % (tag, value, cn))
logger.debug("Tag %s set to %s for %s" % (tag, value, cn))
push.publish("tag-update", cn)
@csrf_protection
@login_required
@authorize_admin
def on_delete(self, req, resp, cn, tag):
path, buf, cert = authority.get_signed(cn)
tags = set(getxattr(path, "user.xdg.tags").split(","))
path, buf, cert, signed, expires = authority.get_signed(cn)
tags = set(getxattr(path, "user.xdg.tags").decode("utf-8").split(","))
tags.remove(tag)
if not tags:
removexattr(path, "user.xdg.tags")
else:
setxattr(path, "user.xdg.tags", ",".join(tags))
logger.debug(u"Tag %s removed for %s" % (tag, cn))
logger.debug("Tag %s removed for %s" % (tag, cn))
push.publish("tag-update", cn)

View File

@@ -4,27 +4,20 @@ import logging
import hashlib
import random
import string
from asn1crypto import pem
from asn1crypto.csr import CertificationRequest
from datetime import datetime
from time import time
from certidude import mailer
from certidude.decorators import serialize
from certidude.user import User
from certidude import config, authority
from certidude.auth import login_required, authorize_admin
logger = logging.getLogger(__name__)
KEYWORDS = (
(u"Android", u"android"),
(u"iPhone", u"iphone"),
(u"iPad", u"ipad"),
(u"Ubuntu", u"ubuntu"),
(u"Fedora", u"fedora"),
(u"Linux", u"linux"),
(u"Macintosh", u"mac"),
)
class TokenResource(object):
def on_get(self, req, resp):
def on_put(self, req, resp):
# Consume token
now = time()
timestamp = req.get_param_as_int("t", required=True)
@@ -32,8 +25,8 @@ class TokenResource(object):
user = User.objects.get(username)
csum = hashlib.sha256()
csum.update(config.TOKEN_SECRET)
csum.update(username)
csum.update(str(timestamp))
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):
@@ -44,46 +37,46 @@ class TokenResource(object):
raise falcon.HTTPForbidden("Forbidden", "Token expired")
# At this point consider token to be legitimate
common_name = username
if config.USER_MULTIPLE_CERTIFICATES:
for key, value in KEYWORDS:
if key in req.user_agent:
device_identifier = value
break
else:
device_identifier = u"unknown-device"
common_name = u"%s@%s-%s" % (common_name, device_identifier, \
hashlib.sha256(req.user_agent).hexdigest()[:8])
logger.info(u"Signing bundle %s for %s", common_name, req.context.get("user"))
if config.BUNDLE_FORMAT == "p12":
resp.set_header("Content-Type", "application/x-pkcs12")
resp.set_header("Content-Disposition", "attachment; filename=%s.p12" % common_name.encode("ascii"))
resp.body, cert = authority.generate_pkcs12_bundle(common_name,
owner=req.context.get("user"))
elif config.BUNDLE_FORMAT == "ovpn":
resp.set_header("Content-Type", "application/x-openvpn")
resp.set_header("Content-Disposition", "attachment; filename=%s.ovpn" % common_name.encode("ascii"))
resp.body, cert = authority.generate_ovpn_bundle(common_name,
owner=req.context.get("user"))
else:
raise ValueError("Unknown bundle format %s" % config.BUNDLE_FORMAT)
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 = authority._sign(csr, body)
resp.set_header("Content-Type", "application/x-pem-file")
logger.info("Autosigned %s as proven by token ownership", common_name)
except FileExistsError:
logger.info("Won't autosign duplicate %s", common_name)
raise falcon.HTTPConflict(
"Certificate with such common name (CN) already exists",
"Will not overwrite existing certificate signing request, explicitly delete existing one and try again")
@serialize
@login_required
@authorize_admin
def on_post(self, req, resp):
# Generate token
issuer = req.context.get("user")
username = req.get_param("user", required=True)
user = User.objects.get(username)
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)
csum.update(str(timestamp))
args = "u=%s&t=%d&c=%s" % (username, timestamp, csum.hexdigest())
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)
@@ -93,7 +86,11 @@ class TokenResource(object):
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)
resp.body = args
return {
"token": args,
"url": url,
}