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 @@
+
+
+
+
+
+
+ REPORTS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+