diff --git a/api/desecapi/migrations/0013_identities.py b/api/desecapi/migrations/0013_identities.py new file mode 100644 index 000000000..69e9ff580 --- /dev/null +++ b/api/desecapi/migrations/0013_identities.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.5 on 2021-01-30 15:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('desecapi', '0012_rrset_label_length'), + ] + + operations = [ + migrations.CreateModel( + name='TLSIdentity', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=24)), + ('created', models.DateTimeField(auto_now_add=True)), + ('certificate', models.TextField()), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='identities', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/api/desecapi/migrations/0014_auto_20210131_1300.py b/api/desecapi/migrations/0014_auto_20210131_1300.py new file mode 100644 index 000000000..bed8b7926 --- /dev/null +++ b/api/desecapi/migrations/0014_auto_20210131_1300.py @@ -0,0 +1,43 @@ +# Generated by Django 3.1.5 on 2021-01-31 13:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('desecapi', '0013_identities'), + ] + + operations = [ + migrations.AddField( + model_name='tlsidentity', + name='port', + field=models.IntegerField(default=443), + ), + migrations.AddField( + model_name='tlsidentity', + name='protocol', + field=models.TextField(choices=[('tcp', 'Tcp'), ('udp', 'Udp'), ('sctp', 'Sctp')], default='tcp'), + ), + migrations.AddField( + model_name='tlsidentity', + name='scheduled_removal', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='tlsidentity', + name='tlsa_certificate_usage', + field=models.IntegerField(choices=[(0, 'Ca Constraint'), (1, 'Service Certificate Constraint'), (2, 'Trust Anchor Assertion'), (3, 'Domain Issued Certificate')], default=3), + ), + migrations.AddField( + model_name='tlsidentity', + name='tlsa_matching_type', + field=models.IntegerField(choices=[(0, 'No Hash Used'), (1, 'Sha256'), (2, 'Sha512')], default=1), + ), + migrations.AddField( + model_name='tlsidentity', + name='tlsa_selector', + field=models.IntegerField(choices=[(0, 'Full Certificate'), (1, 'Subject Public Key Info')], default=1), + ), + ] diff --git a/api/desecapi/models.py b/api/desecapi/models.py index 9635fb4f7..7c062660a 100644 --- a/api/desecapi/models.py +++ b/api/desecapi/models.py @@ -12,10 +12,12 @@ from datetime import timedelta from functools import cached_property from hashlib import sha256 +from typing import Set import dns import psl_dns import rest_framework.authtoken.models +from cryptography import x509, hazmat from django.conf import settings from django.contrib.auth.hashers import make_password from django.contrib.auth.models import BaseUserManager, AbstractBaseUser @@ -939,3 +941,132 @@ def verify(self, solution: str): and age <= settings.CAPTCHA_VALIDITY_PERIOD # not expired ) + + +class Identity(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=24) + created = models.DateTimeField(auto_now_add=True) + owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name='identities') + + class Meta: + abstract = True + + +class TLSIdentity(Identity): + + class CertificateUsage(models.Choices): + CA_CONSTRAINT = 0 + SERVICE_CERTIFICATE_CONSTRAINT = 1 + TRUST_ANCHOR_ASSERTION = 2 + DOMAIN_ISSUED_CERTIFICATE = 3 + + class Selector(models.Choices): + FULL_CERTIFICATE = 0 + SUBJECT_PUBLIC_KEY_INFO = 1 + + class MatchingType(models.Choices): + NO_HASH_USED = 0 + SHA256 = 1 + SHA512 = 2 + + class Protocol(models.TextChoices): + TCP = 'tcp' + UDP = 'udp' + SCTP = 'sctp' + + certificate = models.TextField() + + tlsa_selector = models.IntegerField(choices=Selector.choices, default=Selector.SUBJECT_PUBLIC_KEY_INFO) + tlsa_matching_type = models.IntegerField(choices=MatchingType.choices, default=MatchingType.SHA256) + tlsa_certificate_usage = models.IntegerField(choices=CertificateUsage.choices, + default=CertificateUsage.DOMAIN_ISSUED_CERTIFICATE) + + port = models.IntegerField(default=443) + protocol = models.TextField(choices=Protocol.choices, default=Protocol.TCP) + + scheduled_removal = models.DateTimeField(null=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if 'not_valid_after' not in kwargs: + self.scheduled_removal = self.not_valid_after + + @property + def tlsa_record(self) -> str: + # choose hash function + if self.tlsa_matching_type == 1: + hash_function = hazmat.primitives.hashes.SHA256() + elif self.tlsa_matching_type == 2: + hash_function = hazmat.primitives.hashes.SHA512() + else: + raise NotImplementedError + + # choose data to hash + if self.tlsa_selector == 0: + to_be_hashed = self._cert.public_key().public_bytes( + hazmat.primitives.serialization.Encoding.DER, + hazmat.primitives.serialization.PublicFormat.SubjectPublicKeyInfo + ) + else: + raise NotImplementedError + + # compute the hash + h = hazmat.primitives.hashes.Hash(hash_function) + h.update(to_be_hashed) + hash = h.finalize.hex() + + # create TLSA record + return f"{self.tlsa_certificate_usage:n} {self.tlsa_selector:n} {self.tlsa_matching_type:n} {hash}" + + @property + def _cert(self) -> x509.Certificate: + return x509.load_pem_x509_certificate(self.certificate.encode()) + + @property + def fingerprint(self) -> str: + return self._cert.fingerprint(hazmat.primitives.hashes.SHA256()).hex() + + @property + def subject_names(self) -> Set[str]: + subject_names = { + x.value for x in + self._cert.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME) + } + + try: + subject_alternative_names = { + x for x in + self._cert.extensions.get_extension_for_oid( + x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(x509.DNSName) + } + except x509.extensions.ExtensionNotFound: + subject_alternative_names = set() + + return subject_names | subject_alternative_names + + @property + def domains_subnames(self): + domains_subnames = [] + for name in self.subject_names: + # filter names for valid domain names + try: + validate_domain_name[1](name) + except ValidationError: + continue + + # find user-owned parent domain + domain = 'example.dedyn.io' + subname = '*' + + # return subname, domain pair + domains_subnames.append((subname, domain)) + return domains_subnames + + @property + def not_valid_before(self): + return self._cert.not_valid_before + + @property + def not_valid_after(self): + return self._cert.not_valid_after diff --git a/api/desecapi/serializers.py b/api/desecapi/serializers.py index 1cccb73f5..2ddc38176 100644 --- a/api/desecapi/serializers.py +++ b/api/desecapi/serializers.py @@ -831,3 +831,11 @@ class AuthenticatedRenewDomainBasicUserActionSerializer(AuthenticatedDomainBasic class Meta(AuthenticatedDomainBasicUserActionSerializer.Meta): model = models.AuthenticatedRenewDomainBasicUserAction + + +class TLSIdentitySerializer(serializers.ModelSerializer): + + class Meta: + model = models.TLSIdentity + fields = ('id', 'name', 'certificate', 'created', 'tlsa_record', 'fingerprint', 'not_valid_before', 'not_valid_after', 'subject_names') + read_only_fields = ('id', 'created', 'tlsa_record', 'fingerprint', 'not_valid_before', 'not_valid_after', 'subject_names') diff --git a/api/desecapi/tests/test_identities.py b/api/desecapi/tests/test_identities.py new file mode 100644 index 000000000..b084910b3 --- /dev/null +++ b/api/desecapi/tests/test_identities.py @@ -0,0 +1,5 @@ +from desecapi.tests.base import DesecTestCase + + +class TLSAIdentityTest(DesecTestCase): + pass diff --git a/api/desecapi/urls/version_1.py b/api/desecapi/urls/version_1.py index e487e801e..618d32e84 100644 --- a/api/desecapi/urls/version_1.py +++ b/api/desecapi/urls/version_1.py @@ -6,6 +6,9 @@ tokens_router = SimpleRouter() tokens_router.register(r'', views.TokenViewSet, basename='token') +identities_router = SimpleRouter() +identities_router.register(r'tls', views.TLSIdentityViewSet, basename='identities-tls') + auth_urls = [ # User management path('', views.AccountCreateView.as_view(), name='register'), @@ -56,6 +59,9 @@ # CAPTCHA path('captcha/', views.CaptchaView.as_view(), name='captcha'), + + # Identities management + path('identities/', include(identities_router.urls)), ] app_name = 'desecapi' diff --git a/api/desecapi/views.py b/api/desecapi/views.py index 074cb5c1a..f74239d94 100644 --- a/api/desecapi/views.py +++ b/api/desecapi/views.py @@ -746,3 +746,21 @@ def finalize(self): class CaptchaView(generics.CreateAPIView): serializer_class = serializers.CaptchaSerializer throttle_scope = 'account_management_passive' + + +class IdentityViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet): + permission_classes = (IsAuthenticated, IsOwner,) + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + +class TLSIdentityViewSet(IdentityViewSet): + serializer_class = serializers.TLSIdentitySerializer + + @property + def throttle_scope(self): + return 'dns_api_read' if self.request.method in SAFE_METHODS else 'dns_api_write' + + def get_queryset(self): + return self.request.user.identities.all() # TODO filter for TLS diff --git a/webapp/src/App.vue b/webapp/src/App.vue index 2f6db2d34..d9fa1ae8c 100644 --- a/webapp/src/App.vue +++ b/webapp/src/App.vue @@ -235,6 +235,10 @@ export default { 'name': 'tokens', 'text': 'Token Management', }, + 'dane': { + 'name': 'dane', + 'text': 'DANE Management', + } }, tabmenumore: { 'change-email': { diff --git a/webapp/src/components/Field/MultilineText.vue b/webapp/src/components/Field/MultilineText.vue new file mode 100644 index 000000000..3efbe32cc --- /dev/null +++ b/webapp/src/components/Field/MultilineText.vue @@ -0,0 +1,78 @@ + + + + + \ No newline at end of file diff --git a/webapp/src/router/index.js b/webapp/src/router/index.js index 907072f52..80803b3fb 100644 --- a/webapp/src/router/index.js +++ b/webapp/src/router/index.js @@ -123,6 +123,12 @@ const routes = [ component: () => import(/* webpackChunkName: "gui" */ '../views/Domain/CrudDomain.vue'), meta: {guest: false}, }, + { + path: '/dane', + name: 'dane', + component: () => import(/* webpackChunkName: "gui" */ '../views/DaneHome.vue'), + meta: {guest: false}, + }, ] const router = new VueRouter({ diff --git a/webapp/src/views/CrudList.vue b/webapp/src/views/CrudList.vue index e377f1d73..54b71cd1c 100644 --- a/webapp/src/views/CrudList.vue +++ b/webapp/src/views/CrudList.vue @@ -338,6 +338,7 @@ import Record from '@/components/Field/Record'; import RecordList from '@/components/Field/RecordList'; import Switchbox from '@/components/Field/Switchbox'; import TTL from '@/components/Field/TTL'; +import MultilineText from "@/components/Field/MultilineText"; const filter = function (obj, predicate) { const result = {}; @@ -366,6 +367,7 @@ export default { Record, RecordList, TTL, + MultilineText, }, data() { return { createDialog: false, diff --git a/webapp/src/views/DaneHome.vue b/webapp/src/views/DaneHome.vue new file mode 100644 index 000000000..8408db896 --- /dev/null +++ b/webapp/src/views/DaneHome.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/webapp/src/views/TLSIdentityList.vue b/webapp/src/views/TLSIdentityList.vue new file mode 100644 index 000000000..f15e86d68 --- /dev/null +++ b/webapp/src/views/TLSIdentityList.vue @@ -0,0 +1,160 @@ +