Skip to content

Commit

Permalink
work in progress
Browse files Browse the repository at this point in the history
  • Loading branch information
nils-wisiol committed Feb 1, 2021
1 parent ec712e6 commit f09f0bc
Show file tree
Hide file tree
Showing 13 changed files with 546 additions and 0 deletions.
29 changes: 29 additions & 0 deletions api/desecapi/migrations/0013_identities.py
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
43 changes: 43 additions & 0 deletions api/desecapi/migrations/0014_auto_20210131_1300.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
131 changes: 131 additions & 0 deletions api/desecapi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions api/desecapi/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
5 changes: 5 additions & 0 deletions api/desecapi/tests/test_identities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from desecapi.tests.base import DesecTestCase


class TLSAIdentityTest(DesecTestCase):
pass
6 changes: 6 additions & 0 deletions api/desecapi/urls/version_1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -56,6 +59,9 @@

# CAPTCHA
path('captcha/', views.CaptchaView.as_view(), name='captcha'),

# Identities management
path('identities/', include(identities_router.urls)),
]

app_name = 'desecapi'
Expand Down
18 changes: 18 additions & 0 deletions api/desecapi/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions webapp/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ export default {
'name': 'tokens',
'text': 'Token Management',
},
'dane': {
'name': 'dane',
'text': 'DANE Management',
}
},
tabmenumore: {
'change-email': {
Expand Down
78 changes: 78 additions & 0 deletions webapp/src/components/Field/MultilineText.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<template>
<v-textarea
:label="label"
:disabled="disabled || readonly"
:error-messages="errorMessages"
:value="value"
:type="type || ''"
:placeholder="required ? '' : '(optional)'"
:hint="hint"
persistent-hint
:required="required"
:rules="[v => !required || !!v || 'Required.']"
@input="changed('input', $event)"
@input.native="$emit('dirty', $event)"
@keyup="changed('keyup', $event)"
/>
</template>

<script>
export default {
name: 'MultilineText',
props: {
disabled: {
type: Boolean,
required: false,
},
errorMessages: {
type: [String, Array],
default: () => [],
},
hint: {
type: String,
default: '',
},
label: {
type: String,
required: false,
},
readonly: {
type: Boolean,
required: false,
},
required: {
type: Boolean,
default: false,
},
value: {
type: [String, Number],
required: false,
},
type: {
type: String,
required: false,
},
},
methods: {
changed(event, e) {
this.$emit(event, e);
this.$emit('dirty');
},
},
};
</script>

<style>
/* Removes dropdown icon from read-only select */
.v-application--is-ltr .v-text-field.v-input--is-disabled .v-input__append-inner {
display: none;
}
/* remove underline from disabled text fields so they look like regular text */
:not(v-select).theme--light.v-text-field.v-input--is-disabled .v-input__slot::before {
content: none;
}
/* display disabled text fields in normal color */
.theme--light.v-input--is-disabled input {
color: rgba(0, 0, 0, 0.87);
}
</style>
6 changes: 6 additions & 0 deletions webapp/src/router/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions webapp/src/views/CrudList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -366,6 +367,7 @@ export default {
Record,
RecordList,
TTL,
MultilineText,
},
data() { return {
createDialog: false,
Expand Down
Loading

0 comments on commit f09f0bc

Please sign in to comment.