diff --git a/passbook/admin/templates/administration/base.html b/passbook/admin/templates/administration/base.html index 2be718d76..db483f1b9 100644 --- a/passbook/admin/templates/administration/base.html +++ b/passbook/admin/templates/administration/base.html @@ -64,6 +64,12 @@ {% trans 'Policies' %} +
  • + + {% trans 'Certificates' %} + +
  • diff --git a/passbook/admin/templates/administration/certificatekeypair/list.html b/passbook/admin/templates/administration/certificatekeypair/list.html new file mode 100644 index 000000000..bfc62f86e --- /dev/null +++ b/passbook/admin/templates/administration/certificatekeypair/list.html @@ -0,0 +1,69 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load utils %} + +{% block content %} +
    +
    +

    + + {% trans 'Certificate-Key Pairs' %} +

    +

    {% trans "Import certificates of external providers or create certificates to sign requests with." %}

    +
    +
    +
    +
    +
    + + {% include 'partials/pagination.html' %} +
    + + + + + + + + + + + + {% for kp in object_list %} + + + + + + + {% endfor %} + +
    {% trans 'Name' %}{% trans 'Private Key available' %}{% trans 'Fingerprint' %}{% trans 'Provider Type' %}
    +
    +
    {{ kp.name }}
    +
    +
    + + {% if kp.key_data is not None %} + {% trans 'Yes' %} + {% else %} + {% trans 'No' %} + {% endif %} + + + + {{ kp.fingerprint }} + + + {% trans 'Edit' %} + {% trans 'Delete' %} +
    +
    + {% include 'partials/pagination.html' %} +
    +
    +
    +{% endblock %} diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py index 51279ecad..7806471d7 100644 --- a/passbook/admin/urls.py +++ b/passbook/admin/urls.py @@ -4,6 +4,7 @@ from django.urls import path from passbook.admin.views import ( applications, audit, + certificate_key_pair, debug, factors, groups, @@ -148,6 +149,27 @@ urlpatterns = [ path( "group//delete/", groups.GroupDeleteView.as_view(), name="group-delete" ), + # Certificate-Key Pairs + path( + "crypto/certificates/", + certificate_key_pair.CertificateKeyPairListView.as_view(), + name="certificate_key_pair", + ), + path( + "crypto/certificates/create/", + certificate_key_pair.CertificateKeyPairCreateView.as_view(), + name="certificatekeypair-create", + ), + path( + "crypto/certificates//update/", + certificate_key_pair.CertificateKeyPairUpdateView.as_view(), + name="certificatekeypair-update", + ), + path( + "crypto/certificates//delete/", + certificate_key_pair.CertificateKeyPairDeleteView.as_view(), + name="certificatekeypair-delete", + ), # Audit Log path("audit/", audit.EventListView.as_view(), name="audit-log"), # Groups diff --git a/passbook/admin/views/certificate_key_pair.py b/passbook/admin/views/certificate_key_pair.py new file mode 100644 index 000000000..716434b84 --- /dev/null +++ b/passbook/admin/views/certificate_key_pair.py @@ -0,0 +1,77 @@ +"""passbook CertificateKeyPair administration""" +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import ugettext as _ +from django.views.generic import DeleteView, ListView, UpdateView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from passbook.crypto.forms import CertificateKeyPairForm +from passbook.crypto.models import CertificateKeyPair +from passbook.lib.views import CreateAssignPermView + + +class CertificateKeyPairListView(LoginRequiredMixin, PermissionListMixin, ListView): + """Show list of all keypairs""" + + model = CertificateKeyPair + permission_required = "passbook_crypto.view_certificatekeypair" + ordering = "name" + paginate_by = 40 + template_name = "administration/certificatekeypair/list.html" + + +class CertificateKeyPairCreateView( + SuccessMessageMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new CertificateKeyPair""" + + model = CertificateKeyPair + form_class = CertificateKeyPairForm + permission_required = "passbook_crypto.add_certificatekeypair" + + template_name = "generic/create.html" + success_url = reverse_lazy("passbook_admin:certificate_key_pair") + success_message = _("Successfully created CertificateKeyPair") + + def get_context_data(self, **kwargs): + kwargs["type"] = "Certificate-Key Pair" + return super().get_context_data(**kwargs) + + +class CertificateKeyPairUpdateView( + SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView +): + """Update certificatekeypair""" + + model = CertificateKeyPair + form_class = CertificateKeyPairForm + permission_required = "passbook_crypto.change_certificatekeypair" + + template_name = "generic/update.html" + success_url = reverse_lazy("passbook_admin:certificate_key_pair") + success_message = _("Successfully updated Certificate-Key Pair") + + +class CertificateKeyPairDeleteView( + SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, DeleteView +): + """Delete certificatekeypair""" + + model = CertificateKeyPair + permission_required = "passbook_crypto.delete_certificatekeypair" + + template_name = "generic/delete.html" + success_url = reverse_lazy("passbook_admin:certificate_key_pair") + success_message = _("Successfully deleted Certificate-Key Pair") + + def delete(self, request, *args, **kwargs): + messages.success(self.request, self.success_message) + return super().delete(request, *args, **kwargs) diff --git a/passbook/crypto/__init__.py b/passbook/crypto/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/crypto/admin.py b/passbook/crypto/admin.py new file mode 100644 index 000000000..5179b0d07 --- /dev/null +++ b/passbook/crypto/admin.py @@ -0,0 +1,5 @@ +"""passbook crypto model admin""" + +from passbook.lib.admin import admin_autoregister + +admin_autoregister("passbook_crypto") diff --git a/passbook/crypto/apps.py b/passbook/crypto/apps.py new file mode 100644 index 000000000..fa41d0dc3 --- /dev/null +++ b/passbook/crypto/apps.py @@ -0,0 +1,10 @@ +"""passbook crypto app config""" +from django.apps import AppConfig + + +class PassbookCryptoConfig(AppConfig): + """passbook crypto app config""" + + name = "passbook.crypto" + label = "passbook_crypto" + verbose_name = "passbook Crypto" diff --git a/passbook/crypto/builder.py b/passbook/crypto/builder.py new file mode 100644 index 000000000..67545f5bb --- /dev/null +++ b/passbook/crypto/builder.py @@ -0,0 +1,82 @@ +"""Create self-signed certificates""" +import datetime +import uuid + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + + +class CertificateBuilder: + """Build self-signed certificates""" + + __public_key = None + __private_key = None + __builder = None + __certificate = None + + def __init__(self): + self.__public_key = None + self.__private_key = None + self.__builder = None + self.__certificate = None + + def build(self): + """Build self-signed certificate""" + one_day = datetime.timedelta(1, 0, 0) + self.__private_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + self.__public_key = self.__private_key.public_key() + self.__builder = ( + x509.CertificateBuilder() + .subject_name( + x509.Name( + [ + x509.NameAttribute( + NameOID.COMMON_NAME, u"passbook Self-signed Certificate", + ), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"passbook"), + x509.NameAttribute( + NameOID.ORGANIZATIONAL_UNIT_NAME, u"Self-signed" + ), + ] + ) + ) + .issuer_name( + x509.Name( + [ + x509.NameAttribute( + NameOID.COMMON_NAME, u"passbook Self-signed Certificate", + ), + ] + ) + ) + .not_valid_before(datetime.datetime.today() - one_day) + .not_valid_after(datetime.datetime.today() + datetime.timedelta(days=365)) + .serial_number(int(uuid.uuid4())) + .public_key(self.__public_key) + ) + self.__certificate = self.__builder.sign( + private_key=self.__private_key, + algorithm=hashes.SHA256(), + backend=default_backend(), + ) + + @property + def private_key(self): + """Return private key in PEM format""" + return self.__private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + @property + def certificate(self): + """Return certificate in PEM format""" + return self.__certificate.public_bytes( + encoding=serialization.Encoding.PEM, + ).decode("utf-8") diff --git a/passbook/crypto/forms.py b/passbook/crypto/forms.py new file mode 100644 index 000000000..cbfb5de23 --- /dev/null +++ b/passbook/crypto/forms.py @@ -0,0 +1,27 @@ +"""passbook Crypto forms""" +from django import forms +from django.utils.translation import gettext_lazy as _ + +from passbook.crypto.models import CertificateKeyPair + + +class CertificateKeyPairForm(forms.ModelForm): + """CertificateKeyPair Form""" + + class Meta: + + model = CertificateKeyPair + fields = [ + "name", + "certificate_data", + "key_data", + ] + widgets = { + "name": forms.TextInput(), + "certificate_data": forms.Textarea(attrs={"class": "monospaced"}), + "key_data": forms.Textarea(attrs={"class": "monospaced"}), + } + labels = { + "certificate_data": _("Certificate"), + "key_data": _("Private Key"), + } diff --git a/passbook/crypto/migrations/0001_initial.py b/passbook/crypto/migrations/0001_initial.py new file mode 100644 index 000000000..9e749f148 --- /dev/null +++ b/passbook/crypto/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 3.0.3 on 2020-03-03 21:45 + +import uuid + +from django.db import migrations, models + + +def create_self_signed(apps, schema_editor): + CertificateKeyPair = apps.get_model("passbook_crypto", "CertificateKeyPair") + db_alias = schema_editor.connection.alias + from passbook.crypto.builder import CertificateBuilder + + builder = CertificateBuilder() + builder.build() + CertificateKeyPair.objects.using(db_alias).create( + name="passbook Self-signed Certificate", + certificate_data=builder.certificate, + key_data=builder.private_key, + ) + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="CertificateKeyPair", + fields=[ + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.TextField()), + ("certificate_data", models.TextField()), + ("key_data", models.TextField(blank=True, default="")), + ], + options={ + "verbose_name": "Certificate-Key Pair", + "verbose_name_plural": "Certificate-Key Pairs", + }, + ), + migrations.RunPython(create_self_signed), + migrations.AlterField( + model_name="certificatekeypair", + name="certificate_data", + field=models.TextField(help_text="PEM-encoded Certificate data"), + ), + migrations.AlterField( + model_name="certificatekeypair", + name="key_data", + field=models.TextField( + blank=True, + default="", + help_text="Optional Private Key. If this is set, you can use this keypair for encryption.", + ), + ), + ] diff --git a/passbook/crypto/migrations/__init__.py b/passbook/crypto/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/crypto/models.py b/passbook/crypto/models.py new file mode 100644 index 000000000..80bd7f29b --- /dev/null +++ b/passbook/crypto/models.py @@ -0,0 +1,50 @@ +"""passbook crypto models""" +from binascii import hexlify +from typing import Optional + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.x509 import Certificate, load_pem_x509_certificate +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from passbook.lib.models import CreatedUpdatedModel, UUIDModel + + +class CertificateKeyPair(UUIDModel, CreatedUpdatedModel): + """CertificateKeyPair that can be used for signing or encrypting if `key_data` + is set, otherwise it can be used to verify remote data.""" + + name = models.TextField() + certificate_data = models.TextField(help_text=_("PEM-encoded Certificate data")) + key_data = models.TextField( + help_text=_( + "Optional Private Key. If this is set, you can use this keypair for encryption." + ), + blank=True, + default="", + ) + + _cert: Optional[Certificate] = None + + @property + def certificate(self) -> Certificate: + """Get python cryptography Certificate instance""" + if not self._cert: + self._cert = load_pem_x509_certificate( + self.certificate_data.encode("utf-8"), default_backend() + ) + return self._cert + + @property + def fingerprint(self) -> str: + """Get SHA256 Fingerprint of certificate_data""" + return hexlify(self.certificate.fingerprint(hashes.SHA256())).decode("utf-8") + + def __str__(self) -> str: + return f"Certificate-Key Pair {self.name} {self.fingerprint}" + + class Meta: + + verbose_name = _("Certificate-Key Pair") + verbose_name_plural = _("Certificate-Key Pairs") diff --git a/passbook/root/settings.py b/passbook/root/settings.py index 503d8da13..b60197ab7 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -85,6 +85,7 @@ INSTALLED_APPS = [ "passbook.api.apps.PassbookAPIConfig", "passbook.lib.apps.PassbookLibConfig", "passbook.audit.apps.PassbookAuditConfig", + "passbook.crypto.apps.PassbookCryptoConfig", "passbook.recovery.apps.PassbookRecoveryConfig", "passbook.sources.saml.apps.PassbookSourceSAMLConfig", "passbook.sources.ldap.apps.PassbookSourceLDAPConfig",