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/providers/saml/utils/cert.py b/passbook/crypto/builder.py similarity index 91% rename from passbook/providers/saml/utils/cert.py rename to passbook/crypto/builder.py index 9a0b6c56f..67545f5bb 100644 --- a/passbook/providers/saml/utils/cert.py +++ b/passbook/crypto/builder.py @@ -36,8 +36,7 @@ class CertificateBuilder: x509.Name( [ x509.NameAttribute( - NameOID.COMMON_NAME, - u"passbook Self-signed SAML Certificate", + NameOID.COMMON_NAME, u"passbook Self-signed Certificate", ), x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"passbook"), x509.NameAttribute( @@ -50,8 +49,7 @@ class CertificateBuilder: x509.Name( [ x509.NameAttribute( - NameOID.COMMON_NAME, - u"passbook Self-signed SAML Certificate", + NameOID.COMMON_NAME, u"passbook Self-signed Certificate", ), ] ) 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..e3ec5c607 --- /dev/null +++ b/passbook/crypto/models.py @@ -0,0 +1,64 @@ +"""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.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives.serialization import load_pem_private_key +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 + _key: Optional[RSAPrivateKey] = 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 private_key(self) -> Optional[RSAPrivateKey]: + """Get python cryptography PrivateKey instance""" + if not self._key: + self._key = load_pem_private_key( + str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])), + password=None, + backend=default_backend(), + ) + return self._key + + @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/providers/saml/api.py b/passbook/providers/saml/api.py index 1348c85ca..1f8d4d6ec 100644 --- a/passbook/providers/saml/api.py +++ b/passbook/providers/saml/api.py @@ -24,9 +24,7 @@ class SAMLProviderSerializer(ModelSerializer): "property_mappings", "digest_algorithm", "signature_algorithm", - "signing", - "signing_cert", - "signing_key", + "singing_kp", ] diff --git a/passbook/providers/saml/forms.py b/passbook/providers/saml/forms.py index 08dc95f89..e2178f1f6 100644 --- a/passbook/providers/saml/forms.py +++ b/passbook/providers/saml/forms.py @@ -9,7 +9,6 @@ from passbook.providers.saml.models import ( SAMLProvider, get_provider_choices, ) -from passbook.providers.saml.utils.cert import CertificateBuilder class SAMLProviderForm(forms.ModelForm): @@ -19,13 +18,6 @@ class SAMLProviderForm(forms.ModelForm): choices=get_provider_choices(), label="Processor" ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - builder = CertificateBuilder() - builder.build() - self.fields["signing_cert"].initial = builder.certificate - self.fields["signing_key"].initial = builder.private_key - class Meta: model = SAMLProvider @@ -41,9 +33,7 @@ class SAMLProviderForm(forms.ModelForm): "property_mappings", "digest_algorithm", "signature_algorithm", - "signing", - "signing_cert", - "signing_key", + "singing_kp", ] widgets = { "name": forms.TextInput(), diff --git a/passbook/providers/saml/migrations/0007_auto_20200303_2157.py b/passbook/providers/saml/migrations/0007_auto_20200303_2157.py new file mode 100644 index 000000000..d9a3400d1 --- /dev/null +++ b/passbook/providers/saml/migrations/0007_auto_20200303_2157.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.3 on 2020-03-03 21:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_crypto", "0001_initial"), + ("passbook_providers_saml", "0006_auto_20200217_2031"), + ] + + operations = [ + migrations.RemoveField(model_name="samlprovider", name="signing",), + migrations.RemoveField(model_name="samlprovider", name="signing_cert",), + migrations.RemoveField(model_name="samlprovider", name="signing_key",), + migrations.AddField( + model_name="samlprovider", + name="singing_kp", + field=models.ForeignKey( + default=None, + help_text="Singing is enabled upon selection of a Key Pair.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="passbook_crypto.CertificateKeyPair", + ), + ), + ] diff --git a/passbook/providers/saml/models.py b/passbook/providers/saml/models.py index 42e97328e..d215f1a9b 100644 --- a/passbook/providers/saml/models.py +++ b/passbook/providers/saml/models.py @@ -8,6 +8,7 @@ from django.utils.translation import ugettext_lazy as _ from structlog import get_logger from passbook.core.models import PropertyMapping, Provider +from passbook.crypto.models import CertificateKeyPair from passbook.lib.utils.reflection import class_to_path, path_to_class from passbook.lib.utils.template import render_to_string from passbook.providers.saml.processors.base import Processor @@ -74,9 +75,13 @@ class SAMLProvider(Provider): default="rsa-sha256", ) - signing = models.BooleanField(default=True) - signing_cert = models.TextField(verbose_name=_("Singing Certificate")) - signing_key = models.TextField() + singing_kp = models.ForeignKey( + CertificateKeyPair, + default=None, + null=True, + help_text=_("Singing is enabled upon selection of a Key Pair."), + on_delete=models.SET_NULL, + ) form = "passbook.providers.saml.forms.SAMLProviderForm" _processor = None diff --git a/passbook/providers/saml/utils/xml_signing.py b/passbook/providers/saml/utils/xml_signing.py index 496e48f8a..3afa2bdff 100644 --- a/passbook/providers/saml/utils/xml_signing.py +++ b/passbook/providers/saml/utils/xml_signing.py @@ -31,9 +31,12 @@ def sign_with_signxml(data: str, provider: "SAMLProvider", reference_uri=None) - digest_algorithm=provider.digest_algorithm, ) signed = signer.sign( - root, key=key, cert=[provider.signing_cert], reference_uri=reference_uri + root, + key=key, + cert=[provider.singing_kp.certificate_data], + reference_uri=reference_uri, ) - XMLVerifier().verify(signed, x509_cert=provider.signing_cert) + XMLVerifier().verify(signed, x509_cert=provider.singing_kp.certificate_data) return etree.tostring(signed).decode("utf-8") # nosec diff --git a/passbook/providers/saml/views.py b/passbook/providers/saml/views.py index 793cfeab9..3a3dc0665 100644 --- a/passbook/providers/saml/views.py +++ b/passbook/providers/saml/views.py @@ -274,9 +274,9 @@ class DescriptorDownloadView(AccessRequiredView): kwargs={"application": provider.application.slug}, ) ) - pubkey = strip_pem_header(provider.signing_cert.replace("\r", "")).replace( - "\n", "" - ) + pubkey = strip_pem_header( + provider.singing_kp.certificate_data.replace("\r", "") + ).replace("\n", "") subject_format = provider.processor.subject_format ctx = { "entity_id": entity_id, 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", diff --git a/passbook/sources/saml/api.py b/passbook/sources/saml/api.py index d89014d4a..53d741fef 100644 --- a/passbook/sources/saml/api.py +++ b/passbook/sources/saml/api.py @@ -17,7 +17,7 @@ class SAMLSourceSerializer(ModelSerializer): "idp_url", "idp_logout_url", "auto_logout", - "signing_cert", + "signing_kp", ] diff --git a/passbook/sources/saml/forms.py b/passbook/sources/saml/forms.py index ff6ed84ee..cfc3bf2c5 100644 --- a/passbook/sources/saml/forms.py +++ b/passbook/sources/saml/forms.py @@ -5,19 +5,12 @@ from django.contrib.admin.widgets import FilteredSelectMultiple from django.utils.translation import gettext as _ from passbook.admin.forms.source import SOURCE_FORM_FIELDS -from passbook.providers.saml.utils.cert import CertificateBuilder from passbook.sources.saml.models import SAMLSource class SAMLSourceForm(forms.ModelForm): """SAML Provider form""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - builder = CertificateBuilder() - builder.build() - self.fields["signing_cert"].initial = builder.certificate - class Meta: model = SAMLSource @@ -26,7 +19,7 @@ class SAMLSourceForm(forms.ModelForm): "idp_url", "idp_logout_url", "auto_logout", - "signing_cert", + "signing_kp", ] widgets = { "name": forms.TextInput(), diff --git a/passbook/sources/saml/migrations/0006_auto_20200303_2201.py b/passbook/sources/saml/migrations/0006_auto_20200303_2201.py new file mode 100644 index 000000000..7a152b2c3 --- /dev/null +++ b/passbook/sources/saml/migrations/0006_auto_20200303_2201.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.3 on 2020-03-03 22:01 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_crypto", "0001_initial"), + ("passbook_sources_saml", "0005_auto_20200220_1621"), + ] + + operations = [ + migrations.RemoveField(model_name="samlsource", name="signing_cert",), + migrations.AddField( + model_name="samlsource", + name="signing_kp", + field=models.ForeignKey( + default=None, + help_text="Certificate Key Pair of the IdP which Assertions are validated against.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="passbook_crypto.CertificateKeyPair", + ), + ), + ] diff --git a/passbook/sources/saml/models.py b/passbook/sources/saml/models.py index 4f8127bec..f98133ffc 100644 --- a/passbook/sources/saml/models.py +++ b/passbook/sources/saml/models.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from passbook.core.models import Source from passbook.core.types import UILoginButton +from passbook.crypto.models import CertificateKeyPair class SAMLSource(Source): @@ -22,7 +23,16 @@ class SAMLSource(Source): default=None, blank=True, null=True, verbose_name=_("IDP Logout URL") ) auto_logout = models.BooleanField(default=False) - signing_cert = models.TextField() + + signing_kp = models.ForeignKey( + CertificateKeyPair, + default=None, + null=True, + help_text=_( + "Certificate Key Pair of the IdP which Assertions are validated against." + ), + on_delete=models.SET_NULL, + ) form = "passbook.sources.saml.forms.SAMLSourceForm" diff --git a/passbook/sources/saml/processors/base.py b/passbook/sources/saml/processors/base.py index 775791adf..5568d9286 100644 --- a/passbook/sources/saml/processors/base.py +++ b/passbook/sources/saml/processors/base.py @@ -46,7 +46,7 @@ class Processor: def _verify_signed(self): """Verify SAML Response's Signature""" verifier = XMLVerifier() - verifier.verify(self._root_xml, x509_cert=self._source.signing_cert) + verifier.verify(self._root_xml, x509_cert=self._source.signing_kp.certificate) def _get_email(self) -> Optional[str]: """ diff --git a/passbook/sources/saml/views.py b/passbook/sources/saml/views.py index 9f6516afa..6dd38db93 100644 --- a/passbook/sources/saml/views.py +++ b/passbook/sources/saml/views.py @@ -101,9 +101,9 @@ class MetadataView(View): """Replies with the XML Metadata SPSSODescriptor.""" source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) issuer = get_issuer(request, source) - cert_stripped = strip_pem_header(source.signing_cert.replace("\r", "")).replace( - "\n", "" - ) + cert_stripped = strip_pem_header( + source.signing_kp.certificate_data.replace("\r", "") + ).replace("\n", "") return render_xml( request, "saml/sp/xml/sp_sso_descriptor.xml",