diff --git a/.gitignore b/.gitignore
index 67ca749..d80a151 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,10 +61,6 @@ node_modules/
# diff
*.diff
-# Ignore autogenerated files
-certidude/static/js/nunjucks*
-certidude/static/js/templates.js
-
# Ignore patch
*.orig
*.rej
diff --git a/.travis.yml b/.travis.yml
index 821fa8e..8e34d83 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,16 +2,16 @@ sudo: required
language: python
dist: trusty
python:
- - "2.7"
+ - "3.4"
after_success:
- codecov
virtualenv:
system_site_packages: true
install:
- sudo mkdir -p /etc/systemd/system # Until Travis is stuck with 14.04
- - sudo pip install -r requirements.txt
- - sudo pip install codecov pytest-cov requests-kerberos
- - sudo pip install -e .
+ - sudo pip3 install -r requirements.txt
+ - sudo pip3 install codecov pytest-cov requests-kerberos
+ - sudo pip3 install -e .
script:
- sudo find /home/ -type d -exec chmod 755 {} \; # Allow certidude serve to read templates
- sudo chmod 777 . # Allow forked processes to write .coverage files
@@ -27,4 +27,3 @@ addons:
apt:
packages:
- software-properties-common
- - python-configparser
diff --git a/README.rst b/README.rst
index cf08bdd..24d18f5 100644
--- a/README.rst
+++ b/README.rst
@@ -68,10 +68,11 @@ Common:
* Server-side events support via `nchan `_.
* E-mail notifications about pending, signed, revoked, renewed and overwritten certificates.
* Built using compilation-free `oscrypto `_ library.
+* Object tagging, attach metadata to certificates using extended filesystem attributes.
Virtual private networking:
-* Send OpenVPN profile URL tokens via e-mail, for simplified VPN adoption on Android, iOS, Windows, Mac OS X and Ubuntu.
+* Send VPN profile URL tokens via e-mail, for simplified VPN adoption on Android, iOS, Windows, Mac OS X and Ubuntu.
* OpenVPN gateway and roadwarrior integration, check out ``certidude setup openvpn server`` and ``certidude setup openvpn client``.
* StrongSwan gateway and roadwarrior integration, check out ``certidude setup strongswan server`` and ``certidude setup strongswan client``.
* NetworkManager integration for Ubuntu and Fedora, check out ``certidude setup openvpn networkmanager`` and ``certidude setup strongswan networkmanager``.
@@ -82,12 +83,6 @@ HTTPS:
* HTTPS server setup with client verification, check out ``certidude setup nginx``
-TODO
-----
-
-* Use `pki.js `_ for generating keypair in the browser when claiming a token.
-
-
Install
-------
@@ -98,13 +93,12 @@ System dependencies for Ubuntu 16.04:
.. code:: bash
- apt install -y
- python-click python-configparser \
- python-humanize \
- python-ipaddress python-jinja2 python-ldap python-markdown \
- python-mimeparse python-mysql.connector python-openssl python-pip \
- python-pyasn1 python-pysqlite2 python-requests \
- python-setproctitle python-xattr
+ apt install -y \
+ python3-click \
+ python3-jinja2 python3-markdown \
+ python3-pip \
+ python3-mysql.connector python3-requests \
+ python3-pyxattr
System dependencies for Fedora 25+:
@@ -153,6 +147,13 @@ and start the services:
systemctl restart certidude
+Certidude will submit e-mail notifications to locally running MTA.
+Install Postfix and configure it as Satellite system:
+
+.. code:: bash
+
+ apt install postfix
+
Setting up PAM authentication
-----------------------------
@@ -171,7 +172,7 @@ Python modules:
.. code:: bash
- pip install simplepam
+ pip3 install simplepam
The default configuration generated by ``certidude setup`` should make use of the
PAM.
@@ -247,17 +248,7 @@ Setting up services
Set up services as usual (OpenVPN, Strongswan, etc), when setting up certificates
generate signing request with TLS server flag set.
-Paste signing request into the Certidude web interface and hit the submit button.
-
-Since signing requests with custom flags are not allowed to be signed
-from the interface due to security concerns, sign the certificate at Certidude command line:
-
-.. code:: bash
-
- certidude sign gateway.example.com
-
-Download signed certificate from the web interface or ``wget`` it into the service machine.
-Fetch also CA certificate and finish configuring the service.
+See Certidude admin interface how to submit CSR-s and retrieve signed certificates.
Setting up clients
@@ -319,35 +310,19 @@ Install dependencies as shown above and additionally:
.. code:: bash
- pip install -r requirements.txt
+ pip3 install -r requirements.txt
-To generate templates:
+To install the package from the source tree:
.. code:: bash
- apt install npm nodejs
- sudo ln -s nodejs /usr/bin/node # Fix 'env node' on Ubuntu 14.04
- npm install -g nunjucks@2.5.2
- nunjucks-precompile --include "\\.html$" --include "\\.svg$" certidude/static/ > certidude/static/js/templates.js
- cp /usr/local/lib/node_modules/nunjucks/browser/*.js certidude/static/js/
-
-To run from source tree:
-
-.. code:: bash
-
- PYTHONPATH=. KRB5CCNAME=/run/certidude/krb5cc KRB5_KTNAME=/etc/certidude/server.keytab LANG=C.UTF-8 python misc/certidude
-
-To install the package from the source:
-
-.. code:: bash
-
- pip install -e .
+ pip3 install -e .
To run tests and measure code coverage grab a clean VM or container:
.. code:: bash
- pip install codecov pytest-cov
+ pip3 install codecov pytest-cov
rm .coverage*
TRAVIS=1 coverage run --parallel-mode --source certidude -m py.test tests
coverage combine
@@ -357,7 +332,7 @@ To uninstall:
.. code:: bash
- pip uninstall certidude
+ pip3 uninstall certidude
Certificate attributes
diff --git a/certidude/api/__init__.py b/certidude/api/__init__.py
index f6488c8..9e1fc83 100644
--- a/certidude/api/__init__.py
+++ b/certidude/api/__init__.py
@@ -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")))
diff --git a/certidude/api/attrib.py b/certidude/api/attrib.py
index 2277026..28c0b50 100644
--- a/certidude/api/attrib.py
+++ b/certidude/api/attrib.py
@@ -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)
diff --git a/certidude/api/lease.py b/certidude/api/lease.py
index 1bd6237..ef07e86 100644
--- a/certidude/api/lease.py
+++ b/certidude/api/lease.py
@@ -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")
diff --git a/certidude/api/ocsp.py b/certidude/api/ocsp.py
index e88476c..09fa5b5 100644
--- a/certidude/api/ocsp.py
+++ b/certidude/api/ocsp.py
@@ -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(),
diff --git a/certidude/api/request.py b/certidude/api/request.py
index 97d6ceb..da374da 100644
--- a/certidude/api/request.py
+++ b/certidude/api/request.py
@@ -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()
diff --git a/certidude/api/revoked.py b/certidude/api/revoked.py
index ee18dd0..5851848 100644
--- a/certidude/api/revoked.py
+++ b/certidude/api/revoked.py
@@ -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")
diff --git a/certidude/api/scep.py b/certidude/api/scep.py
index cf92f58..3f960a5 100644
--- a/certidude/api/scep.py
+++ b/certidude/api/scep.py
@@ -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]
diff --git a/certidude/api/script.py b/certidude/api/script.py
index 3751881..889519b 100644
--- a/certidude/api/script.py
+++ b/certidude/api/script.py
@@ -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
diff --git a/certidude/api/signed.py b/certidude/api/signed.py
index 69859b9..29fc8bb 100644
--- a/certidude/api/signed.py
+++ b/certidude/api/signed.py
@@ -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)
diff --git a/certidude/api/tag.py b/certidude/api/tag.py
index 6c4a154..a670588 100644
--- a/certidude/api/tag.py
+++ b/certidude/api/tag.py
@@ -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)
diff --git a/certidude/api/token.py b/certidude/api/token.py
index d9a4fa1..27e2d33 100644
--- a/certidude/api/token.py
+++ b/certidude/api/token.py
@@ -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,
+ }
diff --git a/certidude/auth.py b/certidude/auth.py
index 8c22f4b..7d74a8d 100644
--- a/certidude/auth.py
+++ b/certidude/auth.py
@@ -1,4 +1,5 @@
+import binascii
import click
import gssapi
import falcon
@@ -23,7 +24,7 @@ def authenticate(optional=False):
req.context["user"] = None
return func(resource, req, resp, *args, **kwargs)
- logger.debug(u"No Kerberos ticket offered while attempting to access %s from %s",
+ 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?",
@@ -44,12 +45,15 @@ def authenticate(optional=False):
try:
context.step(b64decode(token))
- except TypeError: # base64 errors
+ 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.")
- username, domain = str(context.initiator_name).split("@")
+ try:
+ username, domain = 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 domain.lower() != const.DOMAIN.lower():
raise falcon.HTTPForbidden("Forbidden",
@@ -64,7 +68,7 @@ def authenticate(optional=False):
# Attempt to look up real user
req.context["user"] = User.objects.get(username)
- logger.debug(u"Succesfully authenticated user %s for %s from %s",
+ 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)
@@ -89,24 +93,24 @@ def authenticate(optional=False):
from base64 import b64decode
basic, token = req.auth.split(" ", 1)
- user, passwd = b64decode(token).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)
+ 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(u"LDAP server demands encryption, use ldaps:// instead of ldaps://")
+ logger.critical("LDAP server demands encryption, use ldaps:// instead of ldaps://")
raise
except ldap.SERVER_DOWN:
- logger.critical(u"Failed to connect LDAP server at %s, are you sure LDAP server's CA certificate has been copied to this machine?",
+ 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(u"LDAP bind authentication failed for user %s from %s",
+ 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,
@@ -134,11 +138,11 @@ def authenticate(optional=False):
raise falcon.HTTPBadRequest("Bad request", "Bad header: %s" % req.auth)
basic, token = req.auth.split(" ", 1)
- user, passwd = b64decode(token).split(":", 1)
+ user, passwd = b64decode(token).decode("ascii").split(":", 1)
import simplepam
if not simplepam.authenticate(user, passwd, "sshd"):
- logger.critical(u"Basic authentication failed for user %s from %s, "
+ 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",))
@@ -175,6 +179,27 @@ def authorize_admin(func):
if req.context.get("user").is_admin():
req.context["admin_authorized"] = True
return func(resource, req, resp, *args, **kwargs)
- logger.info(u"User '%s' not authorized to access administrative API", req.context.get("user").name)
+ 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:
+ 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 22d2983..3e0dc06 100644
--- a/certidude/authority.py
+++ b/certidude/authority.py
@@ -5,6 +5,7 @@ import re
import requests
import hashlib
import socket
+import sys
from oscrypto import asymmetric
from asn1crypto import pem, x509
from asn1crypto.csr import CertificationRequest
@@ -28,25 +29,53 @@ RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z
# Cache CA certificate
-with open(config.AUTHORITY_CERTIFICATE_PATH) as fh:
+with open(config.AUTHORITY_CERTIFICATE_PATH, "rb") as fh:
certificate_buf = fh.read()
header, _, certificate_der_bytes = pem.unarmor(certificate_buf)
certificate = x509.Certificate.load(certificate_der_bytes)
public_key = asymmetric.load_public_key(certificate["tbs_certificate"]["subject_public_key_info"])
-with open(config.AUTHORITY_PRIVATE_KEY_PATH) as fh:
+with open(config.AUTHORITY_PRIVATE_KEY_PATH, "rb") as fh:
key_buf = fh.read()
header, _, key_der_bytes = pem.unarmor(key_buf)
private_key = asymmetric.load_private_key(key_der_bytes)
+def self_enroll():
+ from certidude import const
+ common_name = const.FQDN
+ directory = os.path.join("/var/lib/certidude", const.FQDN)
+ # Sign certificate used for HTTPS
+ public_key, private_key = asymmetric.generate_pair('rsa', bit_size=2048)
+ with open(os.path.join(directory, "self_key.pem"), 'wb') as fh:
+ fh.write(asymmetric.dump_private_key(private_key, None))
+ builder = CSRBuilder({"common_name": common_name}, public_key)
+ request = builder.build(private_key)
+ with open(os.path.join(directory, "requests", common_name + ".pem"), "wb") as fh:
+ fh.write(pem_armor_csr(request))
+ pid = os.fork()
+ if not pid:
+ from certidude import authority
+ from certidude.common import drop_privileges
+ drop_privileges()
+ authority.sign(common_name, skip_push=True, overwrite=True)
+ sys.exit(0)
+ else:
+ os.waitpid(pid, 0)
+ if os.path.exists("/etc/systemd"):
+ os.system("systemctl reload nginx")
+ else:
+ os.system("service nginx reload")
+
+
def get_request(common_name):
if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name %s" % repr(common_name))
path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
try:
- with open(path) as fh:
+ with open(path, "rb") as fh:
buf = fh.read()
header, _, der_bytes = pem.unarmor(buf)
- return path, buf, CertificationRequest.load(der_bytes)
+ return path, buf, CertificationRequest.load(der_bytes), \
+ datetime.utcfromtimestamp(os.stat(path).st_ctime)
except EnvironmentError:
raise errors.RequestDoesNotExist("Certificate signing request file %s does not exist" % path)
@@ -54,24 +83,33 @@ def get_signed(common_name):
if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name %s" % repr(common_name))
path = os.path.join(config.SIGNED_DIR, common_name + ".pem")
- with open(path) as fh:
+ with open(path, "rb") as fh:
buf = fh.read()
header, _, der_bytes = pem.unarmor(buf)
- return path, buf, x509.Certificate.load(der_bytes)
+ cert = x509.Certificate.load(der_bytes)
+ return path, buf, cert, \
+ cert["tbs_certificate"]["validity"]["not_before"].native.replace(tzinfo=None), \
+ cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None)
def get_revoked(serial):
+ if isinstance(serial, str):
+ serial = int(serial, 16)
path = os.path.join(config.REVOKED_DIR, "%x.pem" % serial)
- with open(path) as fh:
+ with open(path, "rb") as fh:
buf = fh.read()
header, _, der_bytes = pem.unarmor(buf)
- return path, buf, x509.Certificate.load(der_bytes), \
+ cert = x509.Certificate.load(der_bytes)
+ return path, buf, cert, \
+ cert["tbs_certificate"]["validity"]["not_before"].native.replace(tzinfo=None), \
+ cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None), \
datetime.utcfromtimestamp(os.stat(path).st_ctime)
def get_attributes(cn, namespace=None):
- path, buf, cert = get_signed(cn)
+ path, buf, cert, signed, expires = get_signed(cn)
attribs = dict()
for key in listxattr(path):
+ key = key.decode("ascii")
if not key.startswith("user."):
continue
if namespace and not key.startswith("user.%s." % namespace):
@@ -84,7 +122,7 @@ def get_attributes(cn, namespace=None):
if component not in current:
current[component] = dict()
current = current[component]
- current[key] = value
+ current[key] = value.decode("utf-8")
return path, buf, cert, attribs
@@ -113,12 +151,12 @@ def store_request(buf, overwrite=False, address="", user=""):
# If there is cert, check if it's the same
if os.path.exists(request_path) and not overwrite:
- if open(request_path).read() == buf:
+ if open(request_path, "rb").read() == buf:
raise errors.RequestExists("Request already exists")
else:
raise errors.DuplicateCommonNameError("Another request with same common name already exists")
else:
- with open(request_path + ".part", "w") as fh:
+ with open(request_path + ".part", "wb") as fh:
fh.write(buf)
os.rename(request_path + ".part", request_path)
@@ -128,6 +166,12 @@ def store_request(buf, overwrite=False, address="", user=""):
common_name=common_name)
setxattr(request_path, "user.request.address", address)
setxattr(request_path, "user.request.user", user)
+ try:
+ hostname, aliaslist, ipaddrlist = socket.gethostbyaddr(address)
+ except (socket.herror, OSError): # Failed to resolve hostname or resolved to multiple
+ pass
+ else:
+ setxattr(request_path, "user.request.hostname", hostname)
return request_path, csr, common_name
@@ -135,10 +179,12 @@ def revoke(common_name):
"""
Revoke valid certificate
"""
- signed_path, buf, cert = get_signed(common_name)
+ signed_path, buf, cert, signed, expires = get_signed(common_name)
revoked_path = os.path.join(config.REVOKED_DIR, "%x.pem" % cert.serial_number)
- os.rename(signed_path, revoked_path)
+
os.unlink(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial_number))
+ os.rename(signed_path, revoked_path)
+
push.publish("certificate-revoked", common_name)
@@ -172,30 +218,37 @@ def list_requests(directory=config.REQUESTS_DIR):
for filename in os.listdir(directory):
if filename.endswith(".pem"):
common_name = filename[:-4]
- path, buf, req = get_request(common_name)
- yield common_name, path, buf, req, server_flags(common_name),
+ path, buf, req, submitted = get_request(common_name)
+ yield common_name, path, buf, req, submitted, "." in common_name
def _list_certificates(directory):
for filename in os.listdir(directory):
if filename.endswith(".pem"):
- common_name = filename[:-4]
path = os.path.join(directory, filename)
- with open(path) as fh:
+ with open(path, "rb") as fh:
buf = fh.read()
header, _, der_bytes = pem.unarmor(buf)
cert = x509.Certificate.load(der_bytes)
server = False
for extension in cert["tbs_certificate"]["extensions"]:
- if extension["extn_id"].native == u"extended_key_usage":
- if u"server_auth" in extension["extn_value"].native:
+ if extension["extn_id"].native == "extended_key_usage":
+ if "server_auth" in extension["extn_value"].native:
server = True
- yield common_name, path, buf, cert, server
+ yield cert.subject.native["common_name"], path, buf, cert, server
-def list_signed():
- return _list_certificates(config.SIGNED_DIR)
+def list_signed(directory=config.SIGNED_DIR):
+ for filename in os.listdir(directory):
+ if filename.endswith(".pem"):
+ common_name = filename[:-4]
+ path, buf, cert, signed, expires = get_signed(common_name)
+ yield common_name, path, buf, cert, signed, expires
-def list_revoked():
- return _list_certificates(config.REVOKED_DIR)
+def list_revoked(directory=config.REVOKED_DIR):
+ for filename in os.listdir(directory):
+ if filename.endswith(".pem"):
+ common_name = filename[:-4]
+ path, buf, cert, signed, expired, revoked = get_revoked(common_name)
+ yield cert.subject.native["common_name"], path, buf, cert, signed, expired, revoked
def list_server_names():
return [cn for cn, path, buf, cert, server in list_signed() if server]
@@ -218,7 +271,7 @@ def export_crl(pem=True):
builder.add_certificate(
int(filename[:-4], 16),
datetime.utcfromtimestamp(s.st_ctime),
- u"key_compromise")
+ "key_compromise")
certificate_list = builder.build(private_key)
if pem:
@@ -231,7 +284,7 @@ def delete_request(common_name):
if not re.match(RE_HOSTNAME, common_name):
raise ValueError("Invalid common name")
- path, buf, csr = get_request(common_name)
+ path, buf, csr, submitted = get_request(common_name)
os.unlink(path)
# Publish event at CA channel
@@ -242,28 +295,28 @@ def delete_request(common_name):
config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest(),
headers={"User-Agent": "Certidude API"})
-def sign(common_name, overwrite=False):
+def sign(common_name, skip_notify=False, skip_push=False, overwrite=False, ou=None, signer=None):
"""
Sign certificate signing request by it's common name
"""
req_path = os.path.join(config.REQUESTS_DIR, common_name + ".pem")
- with open(req_path) as fh:
+ with open(req_path, "rb") as fh:
csr_buf = fh.read()
header, _, der_bytes = pem.unarmor(csr_buf)
csr = CertificationRequest.load(der_bytes)
# Sign with function below
- cert, buf = _sign(csr, csr_buf, overwrite)
+ cert, buf = _sign(csr, csr_buf, skip_notify, skip_push, overwrite, ou, signer)
os.unlink(req_path)
return cert, buf
-def _sign(csr, buf, overwrite=False):
+def _sign(csr, buf, skip_notify=False, skip_push=False, overwrite=False, ou=None, signer=None):
# TODO: CRLDistributionPoints, OCSP URL, Certificate URL
- assert buf.startswith("-----BEGIN CERTIFICATE REQUEST-----\n")
+ assert buf.startswith(b"-----BEGIN CERTIFICATE REQUEST-----")
assert isinstance(csr, CertificationRequest)
csr_pubkey = asymmetric.load_public_key(csr["certification_request_info"]["subject_pk_info"])
common_name = csr["certification_request_info"]["subject"].native["common_name"]
@@ -279,7 +332,7 @@ def _sign(csr, buf, overwrite=False):
# Move existing certificate if necessary
if os.path.exists(cert_path):
- with open(cert_path) as fh:
+ with open(cert_path, "rb") as fh:
prev_buf = fh.read()
header, _, der_bytes = pem.unarmor(prev_buf)
prev = x509.Certificate.load(der_bytes)
@@ -298,10 +351,13 @@ def _sign(csr, buf, overwrite=False):
attachments += [(prev_buf, "application/x-pem-file", "deprecated.crt" if renew else "overwritten.crt")]
overwritten = True
else:
- raise EnvironmentError("Will not overwrite existing certificate")
+ raise FileExistsError("Will not overwrite existing certificate")
# Sign via signer process
- builder = CertificateBuilder({u'common_name': common_name }, csr_pubkey)
+ dn = {u'common_name': common_name }
+ if ou:
+ dn["organizational_unit"] = ou
+ builder = CertificateBuilder(dn, csr_pubkey)
builder.serial_number = random.randint(
0x1000000000000000000000000000000000000000,
0xffffffffffffffffffffffffffffffffffffffff)
@@ -313,14 +369,14 @@ def _sign(csr, buf, overwrite=False):
else config.CLIENT_CERTIFICATE_LIFETIME)
builder.issuer = certificate
builder.ca = False
- builder.key_usage = set([u"digital_signature", u"key_encipherment"])
+ builder.key_usage = set(["digital_signature", "key_encipherment"])
# OpenVPN uses CN while StrongSwan uses SAN
if server_flags(common_name):
builder.subject_alt_domains = [common_name]
- builder.extended_key_usage = set([u"server_auth", u"1.3.6.1.5.5.8.2.2", u"client_auth"])
+ builder.extended_key_usage = set(["server_auth", "1.3.6.1.5.5.8.2.2", "client_auth"])
else:
- builder.extended_key_usage = set([u"client_auth"])
+ builder.extended_key_usage = set(["client_auth"])
end_entity_cert = builder.build(private_key)
end_entity_cert_buf = asymmetric.dump_certificate(end_entity_cert)
@@ -339,20 +395,26 @@ def _sign(csr, buf, overwrite=False):
# Copy filesystem attributes to newly signed certificate
if revoked_path:
for key in listxattr(revoked_path):
- if not key.startswith("user."):
+ if not key.startswith(b"user."):
continue
setxattr(cert_path, key, getxattr(revoked_path, key))
- # Send mail
- if renew: # Same keypair
- mailer.send("certificate-renewed.md", **locals())
- else: # New keypair
- mailer.send("certificate-signed.md", **locals())
+ # Attach signer username
+ if signer:
+ setxattr(cert_path, "user.signature.username", signer)
- url = config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest()
- click.echo("Publishing certificate at %s ..." % url)
- requests.post(url, data=end_entity_cert_buf,
- headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"})
+ if not skip_notify:
+ # Send mail
+ if renew: # Same keypair
+ mailer.send("certificate-renewed.md", **locals())
+ else: # New keypair
+ mailer.send("certificate-signed.md", **locals())
- push.publish("request-signed", common_name)
+ if not skip_push:
+ url = config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest()
+ click.echo("Publishing certificate at %s ..." % url)
+ requests.post(url, data=end_entity_cert_buf,
+ headers={"User-Agent": "Certidude API", "Content-Type": "application/x-x509-user-cert"})
+
+ push.publish("request-signed", common_name)
return end_entity_cert, end_entity_cert_buf
diff --git a/certidude/cli.py b/certidude/cli.py
index fed49ef..21fa9e3 100755
--- a/certidude/cli.py
+++ b/certidude/cli.py
@@ -12,13 +12,19 @@ import socket
import string
import subprocess
import sys
-from asn1crypto.util import timezone
+from asn1crypto import pem, x509
from base64 import b64encode
+from certbuilder import CertificateBuilder, pem_armor_certificate
+from certidude import const
+from csrbuilder import CSRBuilder, pem_armor_csr
from configparser import ConfigParser, NoOptionError, NoSectionError
-from certidude.common import ip_address, ip_network, apt, rpm, pip, drop_privileges, selinux_fixup
+from certidude.common import apt, rpm, pip, drop_privileges, selinux_fixup
from datetime import datetime, timedelta
+from glob import glob
+from ipaddress import ip_network
+from oscrypto import asymmetric
from time import sleep
-import const
+
logger = logging.getLogger(__name__)
@@ -33,7 +39,7 @@ def fqdn_required(func):
def wrapped(**args):
common_name = args.get("common_name")
if "." in common_name:
- logger.info(u"Using fully qualified hostname %s" % common_name)
+ logger.info("Using fully qualified hostname %s" % common_name)
else:
raise ValueError("Fully qualified hostname not specified as common name, make sure hostname -f works")
return func(**args)
@@ -43,7 +49,6 @@ def setup_client(prefix="client_", dh=False):
# Create section in /etc/certidude/client.conf
def wrapper(func):
def wrapped(**arguments):
- from certidude import const
common_name = arguments.get("common_name")
authority = arguments.get("authority")
b = os.path.join(const.STORAGE_PATH, authority)
@@ -71,7 +76,7 @@ def setup_client(prefix="client_", dh=False):
client_config.set(authority, "certificate path", os.path.join(b, prefix + "cert.pem"))
client_config.set(authority, "authority path", os.path.join(b, "ca_cert.pem"))
client_config.set(authority, "revocations path", os.path.join(b, "ca_crl.pem"))
- with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh:
+ with open(const.CLIENT_CONFIG_PATH + ".part", 'w') as fh:
client_config.write(fh)
os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH)
click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH))
@@ -84,27 +89,39 @@ def setup_client(prefix="client_", dh=False):
return wrapper
-@click.command("request", help="Run processes for requesting certificates and configuring services")
+@click.command("enroll", help="Run processes for requesting certificates and configuring services")
@click.option("-k", "--kerberos", default=False, is_flag=True, help="Offer system keytab for auth")
@click.option("-r", "--renew", default=False, is_flag=True, help="Renew now")
@click.option("-f", "--fork", default=False, is_flag=True, help="Fork to background")
+@click.option("-s", "--skip-self", default=False, is_flag=True, help="Skip self enroll")
@click.option("-nw", "--no-wait", default=False, is_flag=True, help="Return immideately if server doesn't autosign")
-def certidude_request(fork, renew, no_wait, kerberos):
- rpm("openssl") or \
- apt("openssl python-jinja2")
- pip("jinja2 oscrypto csrbuilder asn1crypto")
+def certidude_enroll(fork, renew, no_wait, kerberos, skip_self):
+ if not skip_self and os.path.exists(const.CONFIG_PATH):
+ click.echo("Self-enrolling authority's web interface certificate")
+ from certidude import authority
+ authority.self_enroll()
- import requests
from jinja2 import Environment, PackageLoader
- from oscrypto import asymmetric
- from asn1crypto import crl, pem
- from csrbuilder import CSRBuilder, pem_armor_csr
-
+ context = globals()
+ context.update(locals())
env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=True)
+ if not os.path.exists("/etc/systemd/system/certidude-enroll.timer"):
+ click.echo("Creating systemd timer...")
+ with open("/etc/systemd/system/certidude-enroll.timer", "w") as fh:
+ fh.write(env.get_template("client/certidude.timer").render(context))
+ if not os.path.exists("/etc/systemd/system/certidude-enroll.service"):
+ click.echo("Creating systemd service...")
+ with open("/etc/systemd/system/certidude-enroll.service", "w") as fh:
+ fh.write(env.get_template("client/certidude.service").render(context))
+ os.system("systemctl daemon-reload")
+ os.system("systemctl enable certidude-enroll.timer")
+ os.system("systemctl start certidude-enroll.timer")
if not os.path.exists(const.CLIENT_CONFIG_PATH):
- click.echo("No %s!" % const.CLIENT_CONFIG_PATH)
- return 1
+ click.echo("Client not configured, so not going to enroll")
+ return
+
+ import requests
clients = ConfigParser()
clients.readfp(open(const.CLIENT_CONFIG_PATH))
@@ -118,20 +135,6 @@ def certidude_request(fork, renew, no_wait, kerberos):
click.echo("Creating: %s" % const.RUN_DIR)
os.makedirs(const.RUN_DIR)
- context = globals()
- context.update(locals())
-
- # TODO: Create per-authority timers
- if not os.path.exists("/etc/systemd/system/certidude.timer"):
- click.echo("Creating systemd timer...")
- with open("/etc/systemd/system/certidude.timer", "w") as fh:
- fh.write(env.get_template("client/certidude.timer").render(context))
- if not os.path.exists("/etc/systemd/system/certidude.service"):
- click.echo("Creating systemd service...")
- with open("/etc/systemd/system/certidude.service", "w") as fh:
- fh.write(env.get_template("client/certidude.service").render(context))
-
-
for authority_name in clients.sections():
# TODO: Create directories automatically
@@ -192,7 +195,7 @@ def certidude_request(fork, renew, no_wait, kerberos):
try:
authority_path = clients.get(authority_name, "authority path")
except NoOptionError:
- authority_path = "/var/lib/certidude/%s/ca_crt.pem" % authority_name
+ authority_path = "/var/lib/certidude/%s/ca_cert.pem" % authority_name
finally:
if os.path.exists(authority_path):
click.echo("Found authority certificate in: %s" % authority_path)
@@ -203,12 +206,13 @@ def certidude_request(fork, renew, no_wait, kerberos):
try:
r = requests.get(authority_url,
headers={"Accept": "application/x-x509-ca-cert,application/x-pem-file"})
- asymmetric.load_certificate(r.content)
- except:
+ header, _, certificate_der_bytes = pem.unarmor(r.content)
+ cert = x509.Certificate.load(certificate_der_bytes)
+ except: # TODO: catch correct exceptions
raise
# raise ValueError("Failed to parse PEM: %s" % r.text)
authority_partial = authority_path + ".part"
- with open(authority_partial, "w") as oh:
+ with open(authority_partial, "wb") as oh:
oh.write(r.content)
click.echo("Writing authority certificate to: %s" % authority_path)
selinux_fixup(authority_partial)
@@ -284,7 +288,7 @@ def certidude_request(fork, renew, no_wait, kerberos):
key_partial = key_path + ".part"
request_partial = request_path + ".part"
public_key, private_key = asymmetric.generate_pair('rsa', bit_size=2048)
- builder = CSRBuilder({u"common_name": common_name}, public_key)
+ builder = CSRBuilder({"common_name": common_name}, public_key)
request = builder.build(private_key)
with open(key_partial, 'wb') as f:
f.write(asymmetric.dump_private_key(private_key, None))
@@ -294,6 +298,7 @@ def certidude_request(fork, renew, no_wait, kerberos):
selinux_fixup(request_partial)
os.rename(key_partial, key_path)
os.rename(request_partial, request_path)
+ # else: check that CSR has correct CN
##############################################
### Submit CSR and save signed certificate ###
@@ -385,10 +390,15 @@ def certidude_request(fork, renew, no_wait, kerberos):
submission.raise_for_status()
try:
- cert = asymmetric.load_certificate(submission.content)
+ header, _, certificate_der_bytes = pem.unarmor(submission.content)
+ cert = x509.Certificate.load(certificate_der_bytes)
except: # TODO: catch correct exceptions
raise ValueError("Failed to parse PEM: %s" % submission.text)
+ assert cert.subject.native["common_name"] == common_name, \
+ "Expected certificate with common name %s, but got %s instead" % \
+ (common_name, cert.subject.native["common_name"])
+
os.umask(0o022)
certificate_partial = certificate_path + ".part"
with open(certificate_partial, "w") as fh:
@@ -412,6 +422,8 @@ def certidude_request(fork, renew, no_wait, kerberos):
fh.write(ch.read())
click.echo("Writing bundle to: %s" % bundle_path)
os.rename(bundle_partial, bundle_path)
+ else:
+ click.echo("Certificate found at %s and no renewal requested" % certificate_path)
##################################
@@ -471,6 +483,13 @@ def certidude_request(fork, renew, no_wait, kerberos):
"%s/ipsec.conf" % const.STRONGSWAN_PREFIX)
break
+ # Tune AppArmor profile, TODO: retain contents
+ if os.path.exists("/etc/apparmor.d/local"):
+ with open("/etc/apparmor.d/local/usr.lib.ipsec.charon", "w") as fh:
+ fh.write(key_path + " r,\n")
+ fh.write(authority_path + " r,\n")
+ fh.write(certificate_path + " r,\n")
+
# Attempt to reload config or start if it's not running
if os.path.exists("/usr/sbin/strongswan"): # wtf fedora
if os.system("strongswan update"):
@@ -608,7 +627,7 @@ def certidude_setup_openvpn_server(authority, common_name, config, subnet, route
service_config.set(endpoint, "authority", authority)
service_config.set(endpoint, "service", "init/openvpn")
- with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh:
+ with open(const.SERVICES_CONFIG_PATH + ".part", 'w') as fh:
service_config.write(fh)
os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH)
click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH))
@@ -635,7 +654,7 @@ def certidude_setup_openvpn_server(authority, common_name, config, subnet, route
click.echo("Generated %s" % config.name)
click.echo("Inspect generated files and issue following to request certificate:")
click.echo()
- click.echo(" certidude request")
+ click.echo(" certidude enroll")
@click.command("nginx", help="Set up nginx as HTTPS server")
@@ -714,7 +733,7 @@ def certidude_setup_openvpn_client(authority, remote, common_name, config, proto
service_config.set(endpoint, "authority", authority)
service_config.set(endpoint, "service", "init/openvpn")
service_config.set(endpoint, "remote", remote)
- with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh:
+ with open(const.SERVICES_CONFIG_PATH + ".part", 'w') as fh:
service_config.write(fh)
os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH)
click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH))
@@ -740,13 +759,13 @@ def certidude_setup_openvpn_client(authority, remote, common_name, config, proto
click.echo("Generated %s" % config.name)
click.echo("Inspect generated files and issue following to request certificate:")
click.echo()
- click.echo(" certidude request")
+ click.echo(" certidude enroll")
@click.command("server", help="Set up strongSwan server")
@click.argument("authority")
@click.option("--common-name", "-cn", default=const.FQDN, help="Common name, %s by default" % const.FQDN)
-@click.option("--subnet", "-sn", default=u"192.168.33.0/24", type=ip_network, help="IPsec virtual subnet, 192.168.33.0/24 by default")
+@click.option("--subnet", "-sn", default="192.168.33.0/24", type=ip_network, help="IPsec virtual subnet, 192.168.33.0/24 by default")
@click.option("--route", "-r", type=ip_network, multiple=True, help="Subnets to advertise via this connection, multiple allowed")
@fqdn_required
@setup_client(prefix="server_")
@@ -754,7 +773,6 @@ def certidude_setup_strongswan_server(authority, common_name, subnet, route, **p
# Install dependencies
apt("strongswan")
rpm("strongswan")
- pip("ipsecparse")
# Create corresponding section in /etc/certidude/services.conf
endpoint = "IPsec gateway for %s" % authority
@@ -767,7 +785,7 @@ def certidude_setup_strongswan_server(authority, common_name, subnet, route, **p
service_config.add_section(endpoint)
service_config.set(endpoint, "authority", authority)
service_config.set(endpoint, "service", "init/strongswan")
- with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh:
+ with open(const.SERVICES_CONFIG_PATH + ".part", 'w') as fh:
service_config.write(fh)
os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH)
click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH))
@@ -789,9 +807,6 @@ def certidude_setup_strongswan_server(authority, common_name, subnet, route, **p
fh.write(ipsec_conf.dumps())
with open("%s/ipsec.secrets" % const.STRONGSWAN_PREFIX, "a") as fh:
fh.write(": RSA %s\n" % paths.get("key_path"))
- if os.path.exists("/etc/apparmor.d/local"):
- with open("/etc/apparmor.d/local/usr.lib.ipsec.charon", "w") as fh:
- fh.write(os.path.join(const.STORAGE_PATH, "**") + " r,\n") # TODO: dedup!
click.echo()
click.echo("If you're running Ubuntu make sure you're not affected by #1505222")
@@ -806,7 +821,6 @@ def certidude_setup_strongswan_server(authority, common_name, subnet, route, **p
def certidude_setup_strongswan_client(authority, remote, common_name, **paths):
# Install dependencies
apt("strongswan") or rpm("strongswan")
- pip("ipsecparse")
# Create corresponding section in /etc/certidude/services.conf
endpoint = "IPsec connection to %s" % remote
@@ -820,7 +834,7 @@ def certidude_setup_strongswan_client(authority, remote, common_name, **paths):
service_config.set(endpoint, "authority", authority)
service_config.set(endpoint, "service", "init/strongswan")
service_config.set(endpoint, "remote", remote)
- with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh:
+ with open(const.SERVICES_CONFIG_PATH + ".part", 'w') as fh:
service_config.write(fh)
os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH)
click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH))
@@ -852,7 +866,7 @@ def certidude_setup_strongswan_client(authority, remote, common_name, **paths):
fh.write(os.path.join(const.STORAGE_PATH, "**") + " r,\n")
click.echo("Generated section %s in %s" % (authority, const.CLIENT_CONFIG_PATH))
- click.echo("Run 'certidude request' to request certificates and to enable services")
+ click.echo("Run 'certidude enroll' to request certificates and to enable services")
@click.command("networkmanager", help="Set up strongSwan client via NetworkManager")
@@ -877,7 +891,7 @@ def certidude_setup_strongswan_networkmanager(authority, remote, common_name, **
service_config.set(endpoint, "authority", authority)
service_config.set(endpoint, "remote", remote)
service_config.set(endpoint, "service", "network-manager/strongswan")
- with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh:
+ with open(const.SERVICES_CONFIG_PATH + ".part", 'w') as fh:
service_config.write(fh)
os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH)
click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH))
@@ -916,41 +930,55 @@ def certidude_setup_openvpn_networkmanager(authority, remote, common_name, **pat
default="/etc/nginx/sites-available/certidude.conf",
type=click.File(mode="w", atomic=True, lazy=True),
help="nginx site config for serving Certidude, /etc/nginx/sites-available/certidude by default")
-@click.option("--common-name", "-cn", default=const.FQDN, help="Common name, fully qualified hostname by default")
+@click.option("--common-name", "-cn", default=const.FQDN, help="Common name of the server, %s by default" % const.FQDN)
+@click.option("--title", "-t", default="Certidude at %s" % const.FQDN, help="Common name of the certificate authority, 'Certidude at %s' by default" % const.FQDN)
@click.option("--country", "-c", default=None, help="Country, none by default")
@click.option("--state", "-s", default=None, help="State or country, none by default")
@click.option("--locality", "-l", default=None, help="City or locality, none by default")
@click.option("--authority-lifetime", default=20*365, help="Authority certificate lifetime in days, 20 years by default")
@click.option("--organization", "-o", default=None, help="Company or organization name")
-@click.option("--organizational-unit", "-ou", default=None)
+@click.option("--organizational-unit", "-o", default=None)
@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)
@fqdn_required
-def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, state, locality, organization, organizational_unit, common_name, directory, authority_lifetime, push_server, outbox, server_flags):
- # Install only rarely changing stuff from OS package management
- apt("cython python-dev python-mimeparse python-markdown python-xattr python-jinja2 python-cffi python-ldap software-properties-common libsasl2-modules-gssapi-mit")
- pip("gssapi falcon humanize ipaddress simplepam humanize requests")
- click.echo("Software dependencies installed")
-
- if not os.path.exists("/usr/lib/nginx/modules/ngx_nchan_module.so"):
- os.system("add-apt-repository -y ppa:nginx/stable")
- os.system("apt-get update")
- os.system("apt-get install -y libnginx-mod-nchan")
- if not os.path.exists("/usr/sbin/nginx"):
- os.system("apt-get install -y nginx")
+def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, state, locality, organization, organizational_unit, common_name, directory, authority_lifetime, push_server, outbox, server_flags, title):
+ assert os.getuid() == 0 and os.getgid() == 0, "Authority can be set up only by root"
import pwd
- from oscrypto import asymmetric
- from certbuilder import CertificateBuilder, pem_armor_certificate
from jinja2 import Environment, PackageLoader
env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=True)
- # Generate secret for tokens
- token_secret = ''.join(random.choice(string.letters + string.digits + '!@#$%^&*()') for i in range(50))
+ click.echo("Installing packages...")
+ os.system("apt-get install -qq -y cython3 python3-dev python3-mimeparse \
+ python3-markdown python3-pyxattr python3-jinja2 python3-cffi \
+ software-properties-common libsasl2-modules-gssapi-mit npm nodejs \
+ libkrb5-dev libldap2-dev libsasl2-dev")
+ os.system("pip3 install -q --upgrade gssapi falcon humanize ipaddress simplepam")
+ os.system("pip3 install -q --pre --upgrade python-ldap")
- template_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates")
+ if not os.path.exists("/usr/lib/nginx/modules/ngx_nchan_module.so"):
+ click.echo("Enabling nginx PPA")
+ os.system("add-apt-repository -y ppa:nginx/stable")
+ os.system("apt-get update -q")
+ os.system("apt-get install -y -q libnginx-mod-nchan")
+ else:
+ click.echo("PPA for nginx already enabled")
+
+ if not os.path.exists("/usr/sbin/nginx"):
+ click.echo("Installing nginx from PPA")
+ os.system("apt-get install -y -q nginx")
+ else:
+ click.echo("Web server nginx already installed")
+
+ if not os.path.exists("/usr/bin/node"):
+ os.symlink("/usr/bin/nodejs", "/usr/bin/node")
+
+ # Generate secret for tokens
+ token_secret = ''.join(random.choice(string.ascii_letters + string.digits + '!@#$%^&*()') for i in range(50))
+
+ template_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates", "profile")
click.echo("Using templates from %s" % template_path)
if not directory:
@@ -964,8 +992,9 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country,
click.echo("Setting revocation list URL to %s" % revoked_url)
# Expand variables
+ assets_dir = os.path.join(directory, "assets")
ca_key = os.path.join(directory, "ca_key.pem")
- ca_crt = os.path.join(directory, "ca_crt.pem")
+ ca_cert = os.path.join(directory, "ca_cert.pem")
sqlite_path = os.path.join(directory, "meta", "db.sqlite")
try:
@@ -986,6 +1015,7 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country,
click.echo(" chown %s %s" % (username, kerberos_keytab))
click.echo()
+
if os.path.exists("/etc/krb5.keytab") and os.path.exists("/etc/samba/smb.conf"):
# Fetch Kerberos ticket for system account
cp = ConfigParser()
@@ -1015,8 +1045,6 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country,
click.echo("Symlinked %s -> /etc/nginx/sites-enabled/" % nginx_config.name)
if os.path.exists("/etc/nginx/sites-enabled/default"):
os.unlink("/etc/nginx/sites-enabled/default")
- os.system("service nginx restart")
-
if os.path.exists("/etc/systemd"):
if os.path.exists("/etc/systemd/system/certidude.service"):
click.echo("File /etc/systemd/system/certidude.service already exists, remove to regenerate")
@@ -1027,100 +1055,145 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country,
else:
click.echo("Not systemd based OS, don't know how to set up initscripts")
- _, _, uid, gid, gecos, root, shell = pwd.getpwnam("certidude")
- os.setgid(gid)
+ assert os.getuid() == 0 and os.getgid() == 0
+ bootstrap_pid = os.fork()
+ if not bootstrap_pid:
+ # Create bundle directories
+ bundle_js = os.path.join(assets_dir, "js", "bundle.js")
+ bundle_css = os.path.join(assets_dir, "css", "bundle.css")
+ for path in bundle_js, bundle_css:
+ subdir = os.path.dirname(path)
+ if not os.path.exists(subdir):
+ os.makedirs(subdir)
- if not os.path.exists(const.CONFIG_DIR):
- click.echo("Creating %s" % const.CONFIG_DIR)
- os.makedirs(const.CONFIG_DIR)
+ # Install JavaScript pacakges
+ 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")
- if os.path.exists(const.CONFIG_PATH):
- click.echo("Configuration file %s already exists, remove to regenerate" % const.CONFIG_PATH)
- else:
- os.umask(0o137)
- push_token = "".join([random.choice(string.ascii_letters + string.digits) for j in range(0,32)])
- with open(const.CONFIG_PATH, "w") as fh:
- fh.write(env.get_template("server/server.conf").render(vars()))
- click.echo("Generated %s" % const.CONFIG_PATH)
+ # Compile nunjucks templates
+ cmd = 'nunjucks-precompile --include ".html$" --include ".svg" %s > %s.part' % (static_path, bundle_js)
+ click.echo("Compiling templates: %s" % cmd)
+ os.system(cmd)
- # Create directories with 770 permissions
- os.umask(0o027)
- if not os.path.exists(directory):
- os.makedirs(directory)
+ # Assemble bundle.js
+ click.echo("Assembling %s" % bundle_js)
+ with open(bundle_js + ".part", "a") as fh:
+ for pkg in "qrcode-svg/dist/qrcode.min.js", "jquery/dist/jquery.min.js", "timeago/*.js", "nunjucks/browser/nunjucks-slim.min.js", "tether/dist/js/*.min.js", "bootstrap/dist/js/*.min.js":
+ for j in glob(os.path.join("/usr/local/lib/node_modules", pkg)):
+ click.echo("- Merging: %s" % j)
+ with open(j) as ih:
+ fh.write(ih.read())
- # Create subdirectories with 770 permissions
- os.umask(0o007)
- for subdir in ("signed", "signed/by-serial", "requests", "revoked", "expired", "meta"):
- path = os.path.join(directory, subdir)
- if not os.path.exists(path):
- click.echo("Creating directory %s" % path)
- os.mkdir(path)
+ # Assemble bundle.css
+ click.echo("Assembling %s" % bundle_css)
+ with open(bundle_css + ".part", "w") as fh:
+ for pkg in "tether/dist/css/*.min.css", "bootstrap/dist/css/*.min.*css", "font-awesome/css/font-awesome.min.css":
+ for j in glob(os.path.join("/usr/local/lib/node_modules", pkg)):
+ click.echo("- Merging: %s" % j)
+ with open(j) as ih:
+ fh.write(ih.read())
+
+ os.rename(bundle_css + ".part", bundle_css)
+ os.rename(bundle_js + ".part", bundle_js)
+
+ # Copy fonts
+ click.echo("Copying fonts...")
+ os.system("rsync -avq /usr/local/lib/node_modules/font-awesome/fonts/ %s/fonts/" % assets_dir)
+
+ assert os.getuid() == 0 and os.getgid() == 0
+ _, _, uid, gid, gecos, root, shell = pwd.getpwnam("certidude")
+ os.setgid(gid)
+
+ # Generate Certidude server config
+ if not os.path.exists(const.CONFIG_DIR):
+ click.echo("Creating %s" % const.CONFIG_DIR)
+ os.makedirs(const.CONFIG_DIR)
+ if os.path.exists(const.CONFIG_PATH):
+ click.echo("Configuration file %s already exists, remove to regenerate" % const.CONFIG_PATH)
else:
- click.echo("Directory already exists %s" % path)
+ os.umask(0o137)
+ push_token = "".join([random.choice(string.ascii_letters + string.digits) for j in range(0,32)])
+ with open(const.CONFIG_PATH, "w") as fh:
+ fh.write(env.get_template("server/server.conf").render(vars()))
+ click.echo("Generated %s" % const.CONFIG_PATH)
- # Create SQLite database file with correct permissions
- if not os.path.exists(sqlite_path):
- os.umask(0o117)
- with open(sqlite_path, "wb") as fh:
- pass
+ # Create directory with 755 permissions
+ os.umask(0o022)
+ if not os.path.exists(directory):
+ os.makedirs(directory)
- # Generate and sign CA key
- if not os.path.exists(ca_key):
- click.echo("Generating %d-bit RSA key for CA ..." % const.KEY_SIZE)
+ # Create subdirectories with 770 permissions
+ os.umask(0o007)
+ for subdir in ("signed", "signed/by-serial", "requests", "revoked", "expired", "meta"):
+ path = os.path.join(directory, subdir)
+ if not os.path.exists(path):
+ click.echo("Creating directory %s" % path)
+ os.mkdir(path)
+ else:
+ click.echo("Directory already exists %s" % path)
- public_key, private_key = asymmetric.generate_pair('rsa', bit_size=const.KEY_SIZE)
+ # Create SQLite database file with correct permissions
+ if not os.path.exists(sqlite_path):
+ os.umask(0o117)
+ with open(sqlite_path, "wb") as fh:
+ pass
- names = (
- (u"country_name", country),
- (u"state_or_province_name", state),
- (u"locality_name", locality),
- (u"organization_name", organization),
- (u"common_name", common_name)
- )
+ # Generate and sign CA key
+ if not os.path.exists(ca_key):
+ click.echo("Generating %d-bit RSA key for CA ..." % const.KEY_SIZE)
- builder = CertificateBuilder(
- dict([(k,v) for (k,v) in names if v]),
- public_key
- )
- builder.self_signed = True
- builder.ca = True
- builder.subject_alt_domains = [common_name]
- builder.serial_number = random.randint(
- 0x100000000000000000000000000000000000000,
- 0xfffffffffffffffffffffffffffffffffffffff)
+ public_key, private_key = asymmetric.generate_pair('rsa', bit_size=const.KEY_SIZE)
- builder.begin_date = NOW - timedelta(minutes=5)
- builder.end_date = NOW + timedelta(days=authority_lifetime)
+ names = (
+ ("country_name", country),
+ ("state_or_province_name", state),
+ ("locality_name", locality),
+ ("organization_name", organization),
+ ("common_name", title)
+ )
- if server_flags:
- builder.key_usage = set(['digital_signature', 'key_encipherment', 'key_cert_sign', 'crl_sign'])
- builder.extended_key_usage = set(['server_auth', "1.3.6.1.5.5.8.2.2"])
+ builder = CertificateBuilder(
+ dict([(k,v) for (k,v) in names if v]),
+ public_key
+ )
+ builder.self_signed = True
+ builder.ca = True
+ builder.serial_number = random.randint(
+ 0x100000000000000000000000000000000000000,
+ 0xfffffffffffffffffffffffffffffffffffffff)
- certificate = builder.build(private_key)
+ builder.begin_date = NOW - timedelta(minutes=5)
+ builder.end_date = NOW + timedelta(days=authority_lifetime)
- # Set permission bits to 640
- os.umask(0o137)
- with open(ca_crt, 'wb') as f:
- f.write(pem_armor_certificate(certificate))
+ certificate = builder.build(private_key)
- # Set permission bits to 600
- os.umask(0o177)
- with open(ca_key, 'wb') as f:
- f.write(asymmetric.dump_private_key(private_key, None))
+ # Set permission bits to 640
+ os.umask(0o137)
+ with open(ca_cert, 'wb') as f:
+ f.write(pem_armor_certificate(certificate))
-
- click.echo("To enable e-mail notifications install Postfix as sattelite system and set mailer address in %s" % const.CONFIG_PATH)
- click.echo()
- click.echo("Use following commands to inspect the newly created files:")
- click.echo()
- click.echo(" openssl x509 -text -noout -in %s | less" % ca_crt)
- click.echo(" openssl rsa -check -in %s" % ca_key)
- click.echo(" openssl verify -CAfile %s %s" % (ca_crt, ca_crt))
- click.echo()
- click.echo("To enable and start the service:")
- click.echo()
- click.echo(" systemctl enable certidude")
- click.echo(" systemctl start certidude")
+ # Set permission bits to 600
+ os.umask(0o177)
+ with open(ca_key, 'wb') as f:
+ f.write(asymmetric.dump_private_key(private_key, None))
+ sys.exit(0) # stop this fork here
+ else:
+ os.waitpid(bootstrap_pid, 0)
+ from certidude import authority
+ authority.self_enroll()
+ assert os.getuid() == 0 and os.getgid() == 0, "Enroll contaminated environment"
+ click.echo("To enable e-mail notifications install Postfix as sattelite system and set mailer address in %s" % const.CONFIG_PATH)
+ click.echo()
+ click.echo("Use following commands to inspect the newly created files:")
+ click.echo()
+ 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
@click.command("users", help="List users")
@@ -1128,9 +1201,9 @@ def certidude_users():
from certidude.user import User
admins = set(User.objects.filter_admins())
for user in User.objects.all():
- print "%s;%s;%s;%s;%s" % (
+ click.echo("%s;%s;%s;%s;%s" % (
"admin" if user in admins else "user",
- user.name, user.given_name, user.surname, user.mail)
+ user.name, user.given_name, user.surname, user.mail))
@click.command("list", help="List certificates")
@@ -1161,7 +1234,7 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign
click.echo()
if not hide_requests:
- for common_name, path, buf, csr, server in authority.list_requests():
+ for common_name, path, buf, csr, submitted, server in authority.list_requests():
created = 0
if not verbose:
click.echo("s " + path)
@@ -1175,9 +1248,7 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign
if show_signed:
- for common_name, path, buf, cert, server in authority.list_signed():
- signed = cert["tbs_certificate"]["validity"]["not_before"].native.replace(tzinfo=None)
- expires = cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None)
+ for common_name, path, buf, cert, signed, expires in authority.list_signed():
if not verbose:
if signed < NOW and NOW < expires:
click.echo("v " + path)
@@ -1200,10 +1271,10 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign
click.echo("openssl x509 -in %s -text -noout" % path)
dump_common(common_name, path, cert)
for ext in cert["tbs_certificate"]["extensions"]:
- print " - %s: %s" % (ext["extn_id"].native, repr(ext["extn_value"].native))
+ click.echo(" - %s: %s" % (ext["extn_id"].native, repr(ext["extn_value"].native)))
if show_revoked:
- for common_name, path, buf, cert, server in authority.list_revoked():
+ for common_name, path, buf, cert, signed, expires, revoked in authority.list_revoked():
if not verbose:
click.echo("r " + path)
continue
@@ -1211,13 +1282,11 @@ def certidude_list(verbose, show_key_type, show_extensions, show_path, show_sign
click.echo(click.style(common_name, fg="blue") + " " + click.style("%x" % cert.serial_number, fg="white"))
click.echo("="*(len(common_name)+60))
- _, _, _, _, _, _, _, _, mtime, _ = os.stat(path)
- changed = datetime.fromtimestamp(mtime)
- click.echo("Status: " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-changed), click.style(", %s" % changed, fg="white")))
+ click.echo("Status: " + click.style("revoked", fg="red") + " %s%s" % (naturaltime(NOW-revoked), click.style(", %s" % revoked, fg="white")))
click.echo("openssl x509 -in %s -text -noout" % path)
dump_common(common_name, path, cert)
for ext in cert["tbs_certificate"]["extensions"]:
- print " - %s: %s" % (ext["extn_id"].native, repr(ext["extn_value"].native))
+ click.echo(" - %s: %s" % (ext["extn_id"].native, repr(ext["extn_value"].native)))
@click.command("sign", help="Sign certificate")
@@ -1237,17 +1306,22 @@ def certidude_revoke(common_name):
authority.revoke(common_name)
-@click.command("cron", help="Run from cron to manage Certidude server")
-def certidude_cron():
- import itertools
+@click.command("expire", help="Move expired certificates")
+def certidude_expire():
from certidude import authority, config
- for cn, path, buf, cert, server in itertools.chain(authority.list_signed(), authority.list_revoked()):
- expires = cert["tbs_certificate"]["validity"]["not_after"].native.replace(tzinfo=None)
- if expires < NOW:
+ threshold = datetime.utcnow() - timedelta(minutes=5) # Kerberos tolerance
+ for common_name, path, buf, cert, signed, expires in authority.list_signed():
+ if expires < threshold:
expired_path = os.path.join(config.EXPIRED_DIR, "%x.pem" % cert.serial_number)
- assert not os.path.exists(expired_path)
+ click.echo("Moving %s to %s" % (path, expired_path))
os.rename(path, expired_path)
- click.echo("Moved %s to %s" % (path, expired_path))
+ os.remove(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial_number))
+ for common_name, path, buf, cert, signed, expires, revoked in authority.list_revoked():
+ if expires < threshold:
+ expired_path = os.path.join(config.EXPIRED_DIR, "%x.pem" % cert.serial_number)
+ click.echo("Moving %s to %s" % (path, expired_path))
+ os.rename(path, expired_path)
+ # TODO: Send e-mail
@click.command("serve", help="Run server")
@@ -1268,7 +1342,7 @@ def certidude_serve(port, listen, fork):
from certidude import config
# Rebuild reverse mapping
- for cn, path, buf, cert, server in authority.list_signed():
+ for cn, path, buf, cert, signed, expires in authority.list_signed():
by_serial = os.path.join(config.SIGNED_BY_SERIAL_DIR, "%x.pem" % cert.serial_number)
if not os.path.exists(by_serial):
click.echo("Linking %s to ../%s.pem" % (by_serial, cn))
@@ -1278,7 +1352,7 @@ def certidude_serve(port, listen, fork):
if not os.path.exists(const.RUN_DIR):
click.echo("Creating: %s" % const.RUN_DIR)
os.makedirs(const.RUN_DIR)
- os.chmod(const.RUN_DIR, 0755)
+ os.chmod(const.RUN_DIR, 0o755)
# TODO: umask!
@@ -1339,14 +1413,14 @@ def certidude_serve(port, listen, fork):
def cleanup_handler(*args):
push.publish("server-stopped")
- logger.debug(u"Shutting down Certidude")
+ logger.debug("Shutting down Certidude")
sys.exit(0) # TODO: use another code, needs test refactor
import signal
signal.signal(signal.SIGTERM, cleanup_handler) # Handle SIGTERM from systemd
push.publish("server-started")
- logger.debug(u"Started Certidude at %s", const.FQDN)
+ logger.debug("Started Certidude at %s", const.FQDN)
drop_privileges()
try:
@@ -1434,12 +1508,12 @@ certidude_setup.add_command(certidude_setup_nginx)
certidude_setup.add_command(certidude_setup_yubikey)
entry_point.add_command(certidude_setup)
entry_point.add_command(certidude_serve)
-entry_point.add_command(certidude_request)
+entry_point.add_command(certidude_enroll)
entry_point.add_command(certidude_sign)
entry_point.add_command(certidude_revoke)
entry_point.add_command(certidude_list)
+entry_point.add_command(certidude_expire)
entry_point.add_command(certidude_users)
-entry_point.add_command(certidude_cron)
entry_point.add_command(certidude_test)
if __name__ == "__main__":
diff --git a/certidude/common.py b/certidude/common.py
index 1db73b9..bacb955 100644
--- a/certidude/common.py
+++ b/certidude/common.py
@@ -32,20 +32,12 @@ def drop_privileges():
("certidude", os.getuid(), os.getgid(), ", ".join([str(j) for j in os.getgroups()])))
os.umask(0o007)
-def ip_network(j):
- import ipaddress
- return ipaddress.ip_network(unicode(j))
-
-def ip_address(j):
- import ipaddress
- return ipaddress.ip_address(unicode(j))
-
def apt(packages):
"""
Install packages for Debian and Ubuntu
"""
if os.path.exists("/usr/bin/apt-get"):
- cmd = ["/usr/bin/apt-get", "install", "-yqq"] + packages.split(" ")
+ cmd = ["/usr/bin/apt-get", "install", "-yqq", "-o", "Dpkg::Options::=--force-confold"] + packages.split(" ")
click.echo("Running: %s" % " ".join(cmd))
subprocess.call(cmd)
return True
@@ -65,7 +57,7 @@ def rpm(packages):
def pip(packages):
- click.echo("Running: pip install %s" % packages)
+ click.echo("Running: pip3 install %s" % packages)
import pip
pip.main(['install'] + packages.split(" "))
return True
diff --git a/certidude/config.py b/certidude/config.py
index b891e7a..d9dab0f 100644
--- a/certidude/config.py
+++ b/certidude/config.py
@@ -5,7 +5,8 @@ import configparser
import ipaddress
import os
import string
-import const
+from certidude import const
+from collections import OrderedDict
from random import choice
# Options that are parsed from config file are fetched here
@@ -91,14 +92,14 @@ if "%s" not in LDAP_ADMIN_FILTER: raise ValueError("No placeholder %s for userna
TAG_TYPES = [j.split("/", 1) + [cp.get("tagging", j)] for j in cp.options("tagging")]
# Tokens
-BUNDLE_FORMAT = cp.get("token", "format")
-OPENVPN_PROFILE_TEMPLATE = cp.get("token", "openvpn profile template")
TOKEN_URL = cp.get("token", "url")
TOKEN_LIFETIME = cp.getint("token", "lifetime") * 60 # Convert minutes to seconds
-TOKEN_SECRET = cp.get("token", "secret")
+TOKEN_SECRET = cp.get("token", "secret").encode("ascii")
# TODO: Check if we don't have base or servers
# The API call for looking up scripts uses following directory as root
SCRIPT_DIR = os.path.join(os.path.dirname(__file__), "templates", "script")
SCRIPT_DEFAULT = "default.sh"
+
+PROFILES = OrderedDict([[i, [j.strip() for j in cp.get("profile", i).split(",")]] for i in cp.options("profile")])
diff --git a/certidude/decorators.py b/certidude/decorators.py
index c4ff04f..00a0826 100644
--- a/certidude/decorators.py
+++ b/certidude/decorators.py
@@ -5,7 +5,7 @@ import logging
import os
import types
from datetime import date, time, datetime, timedelta
-from urlparse import urlparse
+from urllib.parse import urlparse
logger = logging.getLogger("api")
@@ -33,7 +33,7 @@ def csrf_protection(func):
return func(self, req, resp, *args, **kwargs)
# Kaboom!
- logger.warning(u"Prevented clickbait from '%s' with user agent '%s'",
+ logger.warning("Prevented clickbait from '%s' with user agent '%s'",
referrer or "-", req.user_agent)
raise falcon.HTTPForbidden("Forbidden",
"No suitable UA or referrer provided, cross-site scripting disabled")
@@ -68,7 +68,7 @@ def serialize(func):
import falcon
def wrapped(instance, req, resp, **kwargs):
if not req.client_accepts("application/json"):
- logger.debug(u"Client did not accept application/json")
+ logger.debug("Client did not accept application/json")
raise falcon.HTTPUnsupportedMediaType(
"Client did not accept application/json")
resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate")
diff --git a/certidude/firewall.py b/certidude/firewall.py
index 0253f5f..47ac4a1 100644
--- a/certidude/firewall.py
+++ b/certidude/firewall.py
@@ -17,7 +17,7 @@ def whitelist_subnets(subnets):
if req.context.get("remote_addr") in subnet:
break
else:
- logger.info(u"Rejected access to administrative call %s by %s from %s, source address not whitelisted",
+ logger.info("Rejected access to administrative call %s by %s from %s, source address not whitelisted",
req.env["PATH_INFO"],
req.context.get("user", "unauthenticated user"),
req.context.get("remote_addr"))
@@ -46,7 +46,7 @@ def whitelist_subject(func):
from certidude import authority
from xattr import getxattr
try:
- path, buf, cert = authority.get_signed(cn)
+ path, buf, cert, signed, expires = authority.get_signed(cn)
except IOError:
raise falcon.HTTPNotFound()
else:
diff --git a/certidude/mailer.py b/certidude/mailer.py
index 997aaf3..c3026a6 100644
--- a/certidude/mailer.py
+++ b/certidude/mailer.py
@@ -8,11 +8,11 @@ from jinja2 import Environment, PackageLoader
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
-from urlparse import urlparse
+from urllib.parse import urlparse
env = Environment(loader=PackageLoader("certidude", "templates/mail"))
-def send(template, to=None, include_admins=True, attachments=(), **context):
+def send(template, to=None, secondary=None, include_admins=True, attachments=(), **context):
from certidude import authority, config
recipients = ()
@@ -20,6 +20,9 @@ def send(template, to=None, include_admins=True, attachments=(), **context):
recipients = tuple(User.objects.filter_admins())
if to:
recipients = (to,) + recipients
+ if secondary:
+ recipients = (secondary,) + recipients
+
click.echo("Sending e-mail %s to %s" % (template, recipients))
@@ -27,12 +30,12 @@ def send(template, to=None, include_admins=True, attachments=(), **context):
html = markdown(text)
msg = MIMEMultipart("alternative")
- msg["Subject"] = subject.encode("utf-8")
+ msg["Subject"] = subject
msg["From"] = "%s <%s>" % (config.MAILER_NAME, config.MAILER_ADDRESS)
- msg["To"] = ", ".join([unicode(j) for j in recipients]).encode("utf-8")
+ msg["To"] = ", ".join([str(j) for j in recipients])
- part1 = MIMEText(text.encode("utf-8"), "plain", "utf-8")
- part2 = MIMEText(html.encode("utf-8"), "html", "utf-8")
+ part1 = MIMEText(text, "plain", "utf-8")
+ part2 = MIMEText(html, "html", "utf-8")
msg.attach(part1)
msg.attach(part2)
@@ -46,4 +49,4 @@ def send(template, to=None, include_admins=True, attachments=(), **context):
if config.MAILER_ADDRESS:
click.echo("Sending to: %s" % msg["to"])
conn = smtplib.SMTP("localhost")
- conn.sendmail(config.MAILER_ADDRESS, [u.mail for u in recipients], msg.as_string())
+ conn.sendmail(config.MAILER_ADDRESS, [str(u) for u in recipients], msg.as_string())
diff --git a/certidude/push.py b/certidude/push.py
index dc98979..54806d7 100644
--- a/certidude/push.py
+++ b/certidude/push.py
@@ -13,7 +13,7 @@ def publish(event_type, event_data=''):
"""
assert event_type, "No event type specified"
- if not isinstance(event_data, basestring):
+ if not isinstance(event_data, str):
from certidude.decorators import MyEncoder
event_data = json.dumps(event_data, cls=MyEncoder)
diff --git a/certidude/relational.py b/certidude/relational.py
index e1e3b60..e152f4f 100644
--- a/certidude/relational.py
+++ b/certidude/relational.py
@@ -3,7 +3,7 @@
import click
import re
import os
-from urlparse import urlparse
+from urllib.parse import urlparse
SCRIPTS = {}
diff --git a/certidude/static/css/style.css b/certidude/static/css/style.css
index 54d31a9..cdb3f78 100644
--- a/certidude/static/css/style.css
+++ b/certidude/static/css/style.css
@@ -1,3 +1,19 @@
+
+.loader-container {
+ margin: 20% auto 0 auto;
+ text-align: center;
+}
+
+.loader {
+ border: 16px solid #f3f3f3; /* Light grey */
+ border-top: 16px solid #3498db; /* Blue */
+ border-radius: 50%;
+ width: 120px;
+ height: 120px;
+ animation: spin 2s linear infinite;
+ display: inline-block;
+}
+
@font-face {
font-family: 'PT Sans Narrow';
font-style: normal;
@@ -19,231 +35,55 @@
src: local('Gentium Basic'), local('GentiumBasic'), url('../fonts/gentium-basic.woff2') format('woff2');
}
-svg {
- position: relative;
- top: 0.5em;
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
}
-img {
- max-width: 100%;
- max-height: 100%;
+body, input {
}
-#pending_requests .notify {
- display: none;
-}
-
-#pending_requests .notify:only-child {
- display: block;
-}
-
-
-button, .button, input[type='search'], input[type='text'] {
- border: 1pt solid #ccc;
- border-radius: 6px;
-}
-
-button, .button {
- color: #000;
- float: right;
- background-color: #eee;
- margin: 2px;
- padding: 6px 12px;
- background-position: 6px;
- box-sizing: border-box;
-}
-
-input[type='search'], input[type='text'] {
- padding: 4px 4px 4px 36px;
- background-position: 6px;
- width: 100%;
-}
-
-button:disabled, .button:disabled {
- color: #888;
-}
-
-.monospace {
- font-family: 'Ubuntu Mono', courier, monospace;
-}
-
-footer {
- display: block;
- text-align: center;
-}
-
-a {
- text-decoration: none;
- color: #44c;
-}
-
-html,body {
- margin: 0;
- padding: 0 0 1em 0;
-}
-
-body {
- background: #fff;
-}
-
-.comment {
- color: #aaf;
-}
-
-table th, table td {
- border: 1px solid #ccc;
- padding: 2px;
-}
-
-h1, h2, th {
- font-family: 'Gentium Basic';
-}
-
-h1 {
- text-align: center;
- font-size: 22pt;
-}
-
-h2 {
- font-size: 18pt;
-}
-
-h2 svg {
- position: relative;
- top: 16px;
-}
-
-p, td, footer, li, button, input, select {
- font-family: 'PT Sans Narrow';
- font-size: 14pt;
+body, input {
}
pre {
+ font-family: 'Ubuntu Mono';
+ background: #333;
overflow: auto;
- border: 1px solid #000;
- background: #444;
- color: #fff;
- font-size: 12pt;
- padding: 4px;
- border-radius: 6px;
- margin: 0 0;
+ border: 1px solid #292929;
+ border-radius: 4px;
+ color: #ddd;
+ padding: 6px 10px;
+}
+
+pre code a {
+ color: #eef;
+}
+
+h1, h2 {
}
-.container {
- max-width: 960px;
- padding: 0 1em;
- margin: 0 auto;
+#view {
+ margin: 5em auto 5em auto;
+
}
-#signed ul,
-#requests ul,
-#log ul {
- list-style: none;
- margin: 1em 0;
- padding: 0;
+footer div {
+ text-align: center;
}
-#signed li,
-#requests li,
-#log li {
- margin: 4px 0;
- padding: 4px 0;
- clear: both;
- border-top: 1px dashed #ccc;
+svg {
+ position: relative;
}
-#menu {
- background-color: #444;
-}
-
-#menu li {
- color: #fff;
- border: none;
- display: inline;
- margin: 1mm 5mm 1mm 0;
- line-height: 200%;
+.badge {
cursor: pointer;
}
-.icon{
- background-size: 24px;
- background-position: 8px 5px;
- padding-left: 36px;
- background-repeat: no-repeat;
- display: block;
- vertical-align: text-bottom;
- text-decoration: none;
+.disabled {
+ pointer-events: none;
+ opacity: 0.4;
+ cursor: not-allowed;
}
-#log_entries li span.icon {
- background-size: 24px;
- padding-left: 42px;
- padding-top: 2px;
- padding-bottom: 2px;
-}
-
-.tags .tag,
-.attributes .attribute {
- display: inline;
- background-size: 24px;
- background-position: 0 4px;
- padding-top: 4px;
- padding-bottom: 4px;
- padding-right: 1em;
- line-height: 200%;
- cursor: pointer;
- white-space: nowrap;
-}
-
-.attribute {
- opacity: 0.25;
-}
-
-.attribute:hover {
- opacity: 1;
-}
-
-
-select {
- -webkit-appearance: none;
- -moz-appearance: none;
- appearance: none;
- background: none;
- border: none;
- box-sizing: border-box;
-
-}
-
-.icon.tag,
-.icon.attribute { background-image: url("../img/iconmonstr-tag-3.svg"); }
-
-.icon.critical { background-image: url("../img/iconmonstr-error-4.svg"); }
-.icon.error { background-image: url("../img/iconmonstr-error-4.svg"); }
-.icon.warning { background-image: url("../img/iconmonstr-warning-8.svg"); }
-.icon.info { background-image: url("../img/iconmonstr-info-8.svg"); }
-
-.icon.revoke { background-image: url("../img/iconmonstr-x-mark-8.svg"); }
-.icon.download { background-image: url("../img/iconmonstr-download-12.svg"); }
-.icon.sign { background-image: url("../img/iconmonstr-pen-14.svg"); }
-.icon.search { background-image: url("../img/iconmonstr-magnifier-4.svg"); }
-
-.icon.phone { background-image: url("../img/iconmonstr-mobile-phone-7.svg"); }
-.icon.location { background-image: url("../img/iconmonstr-compass-7.svg"); }
-.icon.room { background-image: url("../img/iconmonstr-home-7.svg"); }
-.icon.serial { background-image: url("../img/iconmonstr-barcode-4.svg"); }
-
-.icon.wireless { background-image: url("../img/iconmonstr-wireless-6.svg"); }
-.icon.password { background-image: url("../img/iconmonstr-lock-3.svg"); }
-
-.icon.upload { background-image: url("../img/iconmonstr-upload-17.svg"); }
-
-.icon.dist,
-.icon.kernel { background-image: url("../img/iconmonstr-linux-os-1.svg"); }
-.icon.if { background-image: url("../img/iconmonstr-sitemap-5.svg"); }
-.icon.cpu,
-.icon.mem,
-.icon.ram { background-image: url("../img/iconmonstr-cpu-1.svg"); }
-
-/* Make sure this is the last one */
-.icon.busy{background-image:url("https://software.opensuse.org/assets/ajax-loader-ea46060b6c9f42822a3d58d075c83ea2.gif");}
diff --git a/certidude/static/img/ajax-loader.gif b/certidude/static/img/ajax-loader.gif
deleted file mode 100644
index 564f6e9..0000000
Binary files a/certidude/static/img/ajax-loader.gif and /dev/null differ
diff --git a/certidude/static/img/iconmonstr-barcode-4.svg b/certidude/static/img/iconmonstr-barcode-4.svg
deleted file mode 100644
index af386ca..0000000
--- a/certidude/static/img/iconmonstr-barcode-4.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-calendar-6.svg b/certidude/static/img/iconmonstr-calendar-6.svg
deleted file mode 100644
index 9399caf..0000000
--- a/certidude/static/img/iconmonstr-calendar-6.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-certificate-15.svg b/certidude/static/img/iconmonstr-certificate-15.svg
deleted file mode 100644
index ef8a8bd..0000000
--- a/certidude/static/img/iconmonstr-certificate-15.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-compass-7.svg b/certidude/static/img/iconmonstr-compass-7.svg
deleted file mode 100644
index 7b2310b..0000000
--- a/certidude/static/img/iconmonstr-compass-7.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-cpu-1.svg b/certidude/static/img/iconmonstr-cpu-1.svg
deleted file mode 100644
index 545383b..0000000
--- a/certidude/static/img/iconmonstr-cpu-1.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-download-12.svg b/certidude/static/img/iconmonstr-download-12.svg
deleted file mode 100644
index e891a49..0000000
--- a/certidude/static/img/iconmonstr-download-12.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-email-2.svg b/certidude/static/img/iconmonstr-email-2.svg
deleted file mode 100644
index fd22489..0000000
--- a/certidude/static/img/iconmonstr-email-2.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-error-4.svg b/certidude/static/img/iconmonstr-error-4.svg
deleted file mode 100644
index b8cc627..0000000
--- a/certidude/static/img/iconmonstr-error-4.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-flag-3.svg b/certidude/static/img/iconmonstr-flag-3.svg
deleted file mode 100644
index 50e9dd0..0000000
--- a/certidude/static/img/iconmonstr-flag-3.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-home-7.svg b/certidude/static/img/iconmonstr-home-7.svg
deleted file mode 100644
index 0988235..0000000
--- a/certidude/static/img/iconmonstr-home-7.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-info-8.svg b/certidude/static/img/iconmonstr-info-8.svg
deleted file mode 100644
index c08c296..0000000
--- a/certidude/static/img/iconmonstr-info-8.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-key-3.svg b/certidude/static/img/iconmonstr-key-3.svg
deleted file mode 100644
index b1d4e78..0000000
--- a/certidude/static/img/iconmonstr-key-3.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-linux-os-1.svg b/certidude/static/img/iconmonstr-linux-os-1.svg
deleted file mode 100644
index 01e7883..0000000
--- a/certidude/static/img/iconmonstr-linux-os-1.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-magnifier-4.svg b/certidude/static/img/iconmonstr-magnifier-4.svg
deleted file mode 100644
index 6674706..0000000
--- a/certidude/static/img/iconmonstr-magnifier-4.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-mobile-phone-7.svg b/certidude/static/img/iconmonstr-mobile-phone-7.svg
deleted file mode 100644
index 22155df..0000000
--- a/certidude/static/img/iconmonstr-mobile-phone-7.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-pen-14.svg b/certidude/static/img/iconmonstr-pen-14.svg
deleted file mode 100644
index a0fc0b0..0000000
--- a/certidude/static/img/iconmonstr-pen-14.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-server-1.svg b/certidude/static/img/iconmonstr-server-1.svg
deleted file mode 100644
index ea8cf23..0000000
--- a/certidude/static/img/iconmonstr-server-1.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-sitemap-5.svg b/certidude/static/img/iconmonstr-sitemap-5.svg
deleted file mode 100644
index b13e1ba..0000000
--- a/certidude/static/img/iconmonstr-sitemap-5.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-tag-3.svg b/certidude/static/img/iconmonstr-tag-3.svg
deleted file mode 100644
index 94d80ed..0000000
--- a/certidude/static/img/iconmonstr-tag-3.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-upload-17.svg b/certidude/static/img/iconmonstr-upload-17.svg
deleted file mode 100644
index ad38fe6..0000000
--- a/certidude/static/img/iconmonstr-upload-17.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/certidude/static/img/iconmonstr-user-5.svg b/certidude/static/img/iconmonstr-user-5.svg
deleted file mode 100644
index 57a1ab2..0000000
--- a/certidude/static/img/iconmonstr-user-5.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-warning-8.svg b/certidude/static/img/iconmonstr-warning-8.svg
deleted file mode 100644
index 4883cf3..0000000
--- a/certidude/static/img/iconmonstr-warning-8.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/img/iconmonstr-x-mark-8.svg b/certidude/static/img/iconmonstr-x-mark-8.svg
deleted file mode 100644
index 8a69429..0000000
--- a/certidude/static/img/iconmonstr-x-mark-8.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/certidude/static/index.html b/certidude/static/index.html
index d0c5770..8826184 100644
--- a/certidude/static/index.html
+++ b/certidude/static/index.html
@@ -1,39 +1,65 @@
-
+
-
Certidude server
+
-
-
-
-
+
-
-
-