Skip to content

Commit dd8ac64

Browse files
committed
work in progress: feat(api,webapp): adds TLS identity manager for automatically generating TLSA records
1 parent fe2a89d commit dd8ac64

14 files changed

+787
-0
lines changed
+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Generated by Django 3.1.5 on 2021-01-30 15:24
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import uuid
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('desecapi', '0014_replication'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='TLSIdentity',
18+
fields=[
19+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
20+
('name', models.CharField(max_length=24)),
21+
('created', models.DateTimeField(auto_now_add=True)),
22+
('certificate', models.TextField()),
23+
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='identities', to=settings.AUTH_USER_MODEL)),
24+
],
25+
options={
26+
'abstract': False,
27+
},
28+
),
29+
]
+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 3.1.5 on 2021-01-31 13:00
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('desecapi', '0015_identities'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='tlsidentity',
15+
name='port',
16+
field=models.IntegerField(default=443),
17+
),
18+
migrations.AddField(
19+
model_name='tlsidentity',
20+
name='protocol',
21+
field=models.TextField(choices=[('tcp', 'Tcp'), ('udp', 'Udp'), ('sctp', 'Sctp')], default='tcp'),
22+
),
23+
migrations.AddField(
24+
model_name='tlsidentity',
25+
name='scheduled_removal',
26+
field=models.DateTimeField(null=True),
27+
),
28+
migrations.AddField(
29+
model_name='tlsidentity',
30+
name='tlsa_certificate_usage',
31+
field=models.IntegerField(choices=[(0, 'Ca Constraint'), (1, 'Service Certificate Constraint'), (2, 'Trust Anchor Assertion'), (3, 'Domain Issued Certificate')], default=3),
32+
),
33+
migrations.AddField(
34+
model_name='tlsidentity',
35+
name='tlsa_matching_type',
36+
field=models.IntegerField(choices=[(0, 'No Hash Used'), (1, 'Sha256'), (2, 'Sha512')], default=1),
37+
),
38+
migrations.AddField(
39+
model_name='tlsidentity',
40+
name='tlsa_selector',
41+
field=models.IntegerField(choices=[(0, 'Full Certificate'), (1, 'Subject Public Key Info')], default=1),
42+
),
43+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 3.1.6 on 2021-02-13 18:40
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('desecapi', '0016_identities'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='tlsidentity',
15+
name='default_ttl',
16+
field=models.PositiveIntegerField(default=300),
17+
),
18+
migrations.AlterField(
19+
model_name='tlsidentity',
20+
name='tlsa_certificate_usage',
21+
field=models.IntegerField(choices=[(0, 'Ca Constraint'), (1, 'Service Certificate Constraint'), (2, 'Trust Anchor Assertion'), (3, 'Domain Issued Certificate')], default=3),
22+
),
23+
migrations.AlterField(
24+
model_name='tlsidentity',
25+
name='tlsa_matching_type',
26+
field=models.IntegerField(choices=[(0, 'No Hash Used'), (1, 'Sha256'), (2, 'Sha512')], default=1),
27+
),
28+
migrations.AlterField(
29+
model_name='tlsidentity',
30+
name='tlsa_selector',
31+
field=models.IntegerField(choices=[(0, 'Full Certificate'), (1, 'Subject Public Key Info')], default=1),
32+
),
33+
]

api/desecapi/models.py

+208
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212
from datetime import timedelta
1313
from functools import cached_property
1414
from hashlib import sha256
15+
from typing import Set, List, Optional, Tuple, Dict
1516

1617
import dns
1718
import psl_dns
1819
import rest_framework.authtoken.models
20+
from cryptography import x509, hazmat
1921
from django.conf import settings
2022
from django.contrib.auth.hashers import make_password
2123
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser, BaseUserManager
@@ -946,3 +948,209 @@ def verify(self, solution: str):
946948
and
947949
age <= settings.CAPTCHA_VALIDITY_PERIOD # not expired
948950
)
951+
952+
953+
class Identity(models.Model):
954+
rr_type = None
955+
956+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
957+
name = models.CharField(max_length=24, default="")
958+
created = models.DateTimeField(auto_now_add=True)
959+
owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name='identities')
960+
default_ttl = models.PositiveIntegerField(default=300)
961+
962+
class Meta:
963+
abstract = True
964+
965+
def get_record_contents(self) -> List[str]:
966+
raise NotImplementedError
967+
968+
def save_rrs(self):
969+
raise NotImplementedError
970+
971+
def save(self, *args, **kwargs):
972+
self.save_rrs()
973+
return super().save(*args, **kwargs)
974+
975+
def delete_rrs(self):
976+
raise NotImplementedError
977+
978+
def delete(self, using=None, keep_parents=False):
979+
# TODO this will delete also RRs that may be covered by other identities
980+
self.delete_rrs()
981+
return super().delete(using, keep_parents)
982+
983+
def get_or_create_rr_set(self, domain: Domain, subname: str) -> RRset:
984+
try:
985+
return RRset.objects.get(domain=domain, subname=subname, type=self.rr_type)
986+
except RRset.DoesNotExist:
987+
# TODO save this RRset?
988+
return RRset(domain=domain, subname=subname, type=self.rr_type, ttl=self.default_ttl)
989+
990+
@staticmethod
991+
def get_or_create_rr(rrset: RRset, content: str) -> RR:
992+
try:
993+
return RR.objects.get(rrset=rrset, content=content)
994+
except RR.DoesNotExist:
995+
return RR(rrset=rrset, content=content)
996+
997+
998+
class TLSIdentity(Identity):
999+
rr_type = 'TLSA'
1000+
1001+
class CertificateUsage(models.IntegerChoices):
1002+
CA_CONSTRAINT = 0
1003+
SERVICE_CERTIFICATE_CONSTRAINT = 1
1004+
TRUST_ANCHOR_ASSERTION = 2
1005+
DOMAIN_ISSUED_CERTIFICATE = 3
1006+
1007+
class Selector(models.IntegerChoices):
1008+
FULL_CERTIFICATE = 0
1009+
SUBJECT_PUBLIC_KEY_INFO = 1
1010+
1011+
class MatchingType(models.IntegerChoices):
1012+
NO_HASH_USED = 0
1013+
SHA256 = 1
1014+
SHA512 = 2
1015+
1016+
class Protocol(models.TextChoices):
1017+
TCP = 'tcp'
1018+
UDP = 'udp'
1019+
SCTP = 'sctp'
1020+
1021+
certificate = models.TextField()
1022+
1023+
tlsa_selector = models.IntegerField(choices=Selector.choices, default=Selector.SUBJECT_PUBLIC_KEY_INFO)
1024+
tlsa_matching_type = models.IntegerField(choices=MatchingType.choices, default=MatchingType.SHA256)
1025+
tlsa_certificate_usage = models.IntegerField(choices=CertificateUsage.choices,
1026+
default=CertificateUsage.DOMAIN_ISSUED_CERTIFICATE)
1027+
1028+
port = models.IntegerField(default=443)
1029+
protocol = models.TextField(choices=Protocol.choices, default=Protocol.TCP)
1030+
1031+
scheduled_removal = models.DateTimeField(null=True)
1032+
1033+
def __init__(self, *args, **kwargs):
1034+
super().__init__(*args, **kwargs)
1035+
if 'not_valid_after' not in kwargs:
1036+
self.scheduled_removal = self.not_valid_after
1037+
1038+
def get_record_contents(self) -> List[str]:
1039+
# choose hash function
1040+
if self.tlsa_matching_type == self.MatchingType.SHA256:
1041+
hash_function = hazmat.primitives.hashes.SHA256()
1042+
elif self.tlsa_matching_type == self.MatchingType.SHA512:
1043+
hash_function = hazmat.primitives.hashes.SHA512()
1044+
else:
1045+
raise NotImplementedError
1046+
1047+
# choose data to hash
1048+
if self.tlsa_selector == self.Selector.SUBJECT_PUBLIC_KEY_INFO:
1049+
to_be_hashed = self._cert.public_key().public_bytes(
1050+
hazmat.primitives.serialization.Encoding.DER,
1051+
hazmat.primitives.serialization.PublicFormat.SubjectPublicKeyInfo
1052+
)
1053+
else:
1054+
raise NotImplementedError
1055+
1056+
# compute the hash
1057+
h = hazmat.primitives.hashes.Hash(hash_function)
1058+
h.update(to_be_hashed)
1059+
hash = h.finalize().hex()
1060+
1061+
# create TLSA record content
1062+
return [f"{self.tlsa_certificate_usage} {self.tlsa_selector} {self.tlsa_matching_type} {hash}"]
1063+
1064+
@property
1065+
def _cert(self) -> x509.Certificate:
1066+
return x509.load_pem_x509_certificate(self.certificate.encode())
1067+
1068+
@property
1069+
def fingerprint(self) -> str:
1070+
return self._cert.fingerprint(hazmat.primitives.hashes.SHA256()).hex()
1071+
1072+
@property
1073+
def subject_names(self) -> Set[str]:
1074+
subject_names = {
1075+
x.value for x in
1076+
self._cert.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)
1077+
}
1078+
1079+
try:
1080+
subject_alternative_names = {
1081+
x for x in
1082+
self._cert.extensions.get_extension_for_oid(
1083+
x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(x509.DNSName)
1084+
}
1085+
except x509.extensions.ExtensionNotFound:
1086+
subject_alternative_names = set()
1087+
1088+
return subject_names | subject_alternative_names
1089+
1090+
@staticmethod
1091+
def get_closest_ancestor(domain_name, owner: User) -> Optional[Domain]:
1092+
# TODO move to Domain?
1093+
labels = domain_name.split('.')
1094+
ancestor_names = ['.'.join(labels[i:]) for i in range(len(labels))]
1095+
for ancestor_name in ancestor_names: # TODO do this with one query
1096+
try:
1097+
return Domain.objects.get(name=ancestor_name, owner=owner)
1098+
except Domain.DoesNotExist:
1099+
continue
1100+
return None
1101+
1102+
def domains_subnames(self) -> Set[Tuple[Domain, str]]:
1103+
domains_subnames = set()
1104+
for name in self.subject_names:
1105+
# cut off any wildcard prefix
1106+
name = name.lstrip('*').lstrip('.')
1107+
1108+
# filter names for valid domain names
1109+
try:
1110+
validate_domain_name[1](name)
1111+
except ValidationError:
1112+
continue
1113+
1114+
# find user-owned parent domain
1115+
domain = self.get_closest_ancestor(name, self.owner)
1116+
if not domain:
1117+
continue
1118+
subname = name[:-len(domain.name)].rstrip('.')
1119+
1120+
# return subname, domain pair
1121+
domains_subnames.add((domain, f"_{self.port:n}._{self.protocol}.{subname}".rstrip('.')))
1122+
return domains_subnames
1123+
1124+
def get_rrsets(self) -> List[RRset]:
1125+
rrsets = []
1126+
for domain, subname in self.domains_subnames():
1127+
rrsets.append(self.get_or_create_rr_set(domain, subname))
1128+
return rrsets
1129+
1130+
def get_rrs(self) -> List[RR]:
1131+
rrs = []
1132+
for domain, subname in self.domains_subnames():
1133+
rrset = self.get_or_create_rr_set(domain, subname)
1134+
for content in self.get_record_contents():
1135+
rrs.append(self.get_or_create_rr(rrset=rrset, content=content))
1136+
return rrs
1137+
1138+
def save_rrs(self):
1139+
for rr in self.get_rrs():
1140+
rr.rrset.save()
1141+
rr.save()
1142+
1143+
def delete_rrs(self):
1144+
for domain, subname in self.domains_subnames():
1145+
rrset = self.get_or_create_rr_set(domain, subname)
1146+
rrset.records.filter(content__in=self.get_record_contents()).delete()
1147+
if not len(rrset.records.all()):
1148+
rrset.delete()
1149+
1150+
@property
1151+
def not_valid_before(self):
1152+
return self._cert.not_valid_before
1153+
1154+
@property
1155+
def not_valid_after(self):
1156+
return self._cert.not_valid_after

api/desecapi/serializers.py

+28
Original file line numberDiff line numberDiff line change
@@ -849,3 +849,31 @@ class AuthenticatedRenewDomainBasicUserActionSerializer(AuthenticatedDomainBasic
849849

850850
class Meta(AuthenticatedDomainBasicUserActionSerializer.Meta):
851851
model = models.AuthenticatedRenewDomainBasicUserAction
852+
853+
854+
class TLSIdentitySerializer(serializers.ModelSerializer):
855+
published_at = serializers.SerializerMethodField(read_only=True)
856+
857+
def get_published_at(self, tls_identity: models.TLSIdentity):
858+
return [
859+
f"{rrset.type}/{rrset.name}"
860+
for rrset in tls_identity.get_rrsets()
861+
]
862+
863+
class Meta:
864+
model = models.TLSIdentity
865+
fields = (
866+
'id', 'name', 'created',
867+
868+
'default_ttl',
869+
870+
'certificate',
871+
'tlsa_selector', 'tlsa_matching_type', 'tlsa_certificate_usage',
872+
873+
'port', 'protocol',
874+
875+
'fingerprint', 'not_valid_before', 'not_valid_after', 'subject_names',
876+
877+
'published_at',
878+
)
879+
read_only_fields = list(filter(lambda f: f not in ('name', 'certificate'), fields))

0 commit comments

Comments
 (0)