Skip to content

Commit 49eab1d

Browse files
committed
feat(api): allow CSK import on domain creation
1 parent 8524f05 commit 49eab1d

7 files changed

Lines changed: 250 additions & 10 deletions

File tree

api/desecapi/dnssec.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import base64
2+
import re
3+
4+
import dns.dnssec
5+
from cryptography.hazmat.primitives.asymmetric import ec
6+
7+
8+
def parse_csk_private_key(private_key: str) -> dict:
9+
if not private_key or not private_key.strip():
10+
raise ValueError("Missing private key material")
11+
12+
algorithm = None
13+
private_b64 = None
14+
for line in private_key.strip().splitlines():
15+
line = line.strip()
16+
if not line:
17+
continue
18+
if line.lower().startswith("algorithm:"):
19+
match = re.search(r"\b(\d+)\b", line)
20+
if match:
21+
algorithm = int(match.group(1))
22+
elif line.lower().startswith("privatekey:"):
23+
private_b64 = line.split(":", 1)[1].strip()
24+
25+
if algorithm is None:
26+
raise ValueError("Missing algorithm in private key")
27+
if private_b64 is None:
28+
raise ValueError("Missing PrivateKey in private key")
29+
30+
if algorithm != 13:
31+
raise ValueError("Unsupported algorithm")
32+
33+
try:
34+
private_bytes = base64.b64decode(private_b64, validate=True)
35+
except Exception as exc:
36+
raise ValueError("Invalid base64 private key") from exc
37+
38+
if len(private_bytes) > 32:
39+
raise ValueError("Invalid private key length")
40+
if len(private_bytes) < 32:
41+
private_bytes = private_bytes.rjust(32, b"\x00")
42+
43+
private_value = int.from_bytes(private_bytes, "big")
44+
if private_value == 0:
45+
raise ValueError("Invalid private key value")
46+
47+
private_key_obj = ec.derive_private_key(private_value, ec.SECP256R1())
48+
dnskey = dns.dnssec.make_dnskey(
49+
private_key_obj.public_key(), algorithm=13, flags=257, protocol=3
50+
).to_text()
51+
52+
return {
53+
"algorithm": algorithm,
54+
"dnskey": dnskey,
55+
}

api/desecapi/knot.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import dns.query
1111
import dns.rcode
1212
import dns.rdtypes.ANY.DNSKEY
13+
import dns.rdata
1314
import dns.rdatatype
1415
import dns.tsig
1516
import dns.tsigkeyring
@@ -183,6 +184,26 @@ def ensure_default_ns(name):
183184
_send_update_with_retry(update)
184185

185186

187+
def import_csk_key(name, *, dnskey, private_key=None):
188+
if not wait_for_zone(name, attempts=60, interval_seconds=0.5):
189+
raise KnotException(f"Knot zone {name} not ready for updates")
190+
update = _new_update(name)
191+
apex = name.rstrip(".") + "."
192+
update.add(apex, settings.DEFAULT_NS_TTL, "DNSKEY", dnskey)
193+
try:
194+
key_rdata = dns.rdata.from_text(
195+
dns.rdataclass.IN, dns.rdatatype.DNSKEY, dnskey
196+
)
197+
cds_records = [
198+
dns.dnssec.make_ds(dns.name.from_text(name), key_rdata, algo).to_text()
199+
for algo in (2, 4)
200+
]
201+
update.replace(apex, 0, "CDS", *cds_records)
202+
except Exception:
203+
pass
204+
_send_update_with_retry(update)
205+
206+
186207
def wait_for_zone(name, *, attempts=20, interval_seconds=0.5) -> bool:
187208
query = dns.message.make_query(name, dns.rdatatype.SOA)
188209
query_timeout = min(settings.NSLORD_KNOT_TIMEOUT, 1.0)

api/desecapi/pdns.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,24 @@ def get_keys(domain):
166166
]
167167

168168

169+
def list_cryptokeys(domain_name):
170+
return _pdns_get(NSLORD, "/zones/%s/cryptokeys" % pdns_id(domain_name)).json()
171+
172+
173+
def set_cryptokey_active(domain_name, key_id, active):
174+
_pdns_put(
175+
NSLORD,
176+
"/zones/%s/cryptokeys/%s" % (pdns_id(domain_name), key_id),
177+
data={"active": bool(active)},
178+
)
179+
180+
181+
def delete_cryptokey(domain_name, key_id):
182+
_pdns_delete(
183+
NSLORD, "/zones/%s/cryptokeys/%s" % (pdns_id(domain_name), key_id)
184+
)
185+
186+
169187
def get_zone(domain):
170188
"""
171189
Retrieves a dict representation of the zone from pdns
@@ -262,6 +280,37 @@ def create_zone_master(name, master_host="nslord"):
262280
)
263281

264282

283+
def import_csk_key(name, *, dnskey, private_key):
284+
response = _pdns_post(
285+
NSLORD,
286+
"/zones/%s/cryptokeys" % pdns_id(name),
287+
{
288+
"keytype": "csk",
289+
"active": True,
290+
"published": True,
291+
"content": private_key,
292+
},
293+
)
294+
cryptokey = response.json()
295+
imported_id = cryptokey.get("id")
296+
keys = list_cryptokeys(name)
297+
if imported_id is None:
298+
for key in keys:
299+
if key.get("dnskey") == dnskey:
300+
imported_id = key.get("id")
301+
break
302+
if imported_id is None:
303+
return cryptokey
304+
for key in keys:
305+
if key.get("id") == imported_id:
306+
if not key.get("active", False):
307+
set_cryptokey_active(name, imported_id, True)
308+
continue
309+
delete_cryptokey(name, key["id"])
310+
rectify_zone(name)
311+
return cryptokey
312+
313+
265314
def delete_zone(name, server):
266315
_pdns_delete(server, "/zones/" + pdns_id(name))
267316

@@ -282,6 +331,10 @@ def axfr_to_master(zone):
282331
_pdns_put(NSMASTER, "/zones/%s/axfr-retrieve" % pdns_id(zone))
283332

284333

334+
def rectify_zone(name):
335+
_pdns_put(NSLORD, "/zones/%s/rectify" % pdns_id(name))
336+
337+
285338
def construct_catalog_rrset(
286339
zone=None, delete=False, subname=None, qtype="PTR", rdata=None
287340
):

api/desecapi/pdns_change_tracker.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def pch_do(self):
7474
def __init__(self):
7575
self._domain_additions = set()
7676
self._domain_deletions = set()
77+
self._domain_create_payload = {}
7778
self._rr_set_additions = {}
7879
self._rr_set_modifications = {}
7980
self._rr_set_deletions = {}
@@ -119,6 +120,7 @@ def __enter__(self):
119120
)
120121
self._domain_additions = set()
121122
self._domain_deletions = set()
123+
self._domain_create_payload = {}
122124
self._rr_set_additions = {}
123125
self._rr_set_modifications = {}
124126
self._rr_set_deletions = {}
@@ -227,7 +229,7 @@ def _nslord_for_domain(self, domain_name):
227229
return nslord or Domain.NSLord.PDNS
228230

229231
@abstractmethod
230-
def _create_domain_change(self, domain_name, nslord):
232+
def _create_domain_change(self, domain_name, nslord, create_payload):
231233
raise NotImplementedError()
232234

233235
@abstractmethod
@@ -258,7 +260,10 @@ def _compute_changes(self):
258260
for domain_name in self._rr_set_additions.keys() | self._domain_additions:
259261
nslord = self._nslord_for_domain(domain_name)
260262
if domain_name in self._domain_additions:
261-
changes.append(self._create_domain_change(domain_name, nslord))
263+
create_payload = self._domain_create_payload.get(domain_name)
264+
changes.append(
265+
self._create_domain_change(domain_name, nslord, create_payload)
266+
)
262267

263268
additions = self._rr_set_additions.get(domain_name, set())
264269
modifications = self._rr_set_modifications.get(domain_name, set())
@@ -356,6 +361,9 @@ def _domain_updated(self, domain: Domain, created=False, deleted=False):
356361
deletions.remove(name)
357362
else:
358363
additions.add(name)
364+
create_payload = getattr(domain, "_csk_private_key_data", None)
365+
if create_payload:
366+
self._domain_create_payload[name] = create_payload
359367
elif deleted:
360368
if name in additions:
361369
additions.remove(name)
@@ -430,12 +438,22 @@ def pdns_do(self):
430438
self.nslord_do()
431439

432440
class CreateDomain(PDNSChange):
441+
def __init__(self, domain_name, create_payload=None):
442+
super().__init__(domain_name)
443+
self._create_payload = create_payload or {}
444+
433445
@property
434446
def axfr_required(self):
435447
return True
436448

437449
def nslord_do(self):
438450
pdns.create_zone_lord(self.domain_name)
451+
if self._create_payload:
452+
pdns.import_csk_key(
453+
self.domain_name,
454+
dnskey=self._create_payload["dnskey"],
455+
private_key=self._create_payload["private_key"],
456+
)
439457
pdns.create_zone_master(self.domain_name)
440458
pdns.update_catalog(self.domain_name)
441459

@@ -534,8 +552,8 @@ def __str__(self):
534552
)
535553
)
536554

537-
def _create_domain_change(self, domain_name, nslord):
538-
return PDNSChangeTracker.CreateDomain(domain_name)
555+
def _create_domain_change(self, domain_name, nslord, create_payload):
556+
return PDNSChangeTracker.CreateDomain(domain_name, create_payload)
539557

540558
def _delete_domain_change(self, domain_name, nslord):
541559
return PDNSChangeTracker.DeleteDomain(domain_name)
@@ -553,13 +571,23 @@ class KnotChange(BaseChangeTracker.Change):
553571
pass
554572

555573
class CreateDomain(KnotChange):
574+
def __init__(self, domain_name, create_payload=None):
575+
super().__init__(domain_name)
576+
self._create_payload = create_payload or {}
577+
556578
@property
557579
def axfr_required(self):
558580
return True
559581

560582
def nslord_do(self):
561583
knot.create_zone(self.domain_name)
562584
knot.ensure_default_ns(self.domain_name)
585+
if self._create_payload:
586+
knot.import_csk_key(
587+
self.domain_name,
588+
dnskey=self._create_payload["dnskey"],
589+
private_key=self._create_payload["private_key"],
590+
)
563591
pdns.create_zone_master(
564592
self.domain_name, master_host=settings.NSLORD_KNOT_HOST
565593
)
@@ -626,8 +654,8 @@ def __str__(self):
626654
)
627655
)
628656

629-
def _create_domain_change(self, domain_name, nslord):
630-
return KnotChangeTracker.CreateDomain(domain_name)
657+
def _create_domain_change(self, domain_name, nslord, create_payload):
658+
return KnotChangeTracker.CreateDomain(domain_name, create_payload)
631659

632660
def _delete_domain_change(self, domain_name, nslord):
633661
return KnotChangeTracker.DeleteDomain(domain_name)
@@ -646,8 +674,8 @@ def _backend(self, nslord):
646674
return KnotChangeTracker
647675
return PDNSChangeTracker
648676

649-
def _create_domain_change(self, domain_name, nslord):
650-
return self._backend(nslord).CreateDomain(domain_name)
677+
def _create_domain_change(self, domain_name, nslord, create_payload):
678+
return self._backend(nslord).CreateDomain(domain_name, create_payload)
651679

652680
def _delete_domain_change(self, domain_name, nslord):
653681
return self._backend(nslord).DeleteDomain(domain_name)

api/desecapi/serializers/domains.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.conf import settings
44
from rest_framework import serializers
55

6+
from desecapi import dnssec
67
from desecapi.models import Domain, RR_SET_TYPES_AUTOMATIC
78
from desecapi.validators import ReadOnlyOnUpdateValidator
89

@@ -15,6 +16,7 @@ class DomainSerializer(serializers.ModelSerializer):
1516
"name_unavailable": "This domain name conflicts with an existing domain, or is disallowed by policy.",
1617
}
1718
zonefile = serializers.CharField(write_only=True, required=False, allow_blank=True)
19+
csk_private_key = serializers.CharField(write_only=True, required=False)
1820
nslord = serializers.ChoiceField(
1921
choices=Domain.NSLord.choices, required=False, write_only=True
2022
)
@@ -29,6 +31,7 @@ class Meta:
2931
"minimum_ttl",
3032
"touched",
3133
"zonefile",
34+
"csk_private_key",
3235
"nslord",
3336
)
3437
read_only_fields = (
@@ -42,6 +45,7 @@ class Meta:
4245
def __init__(self, *args, include_keys=False, **kwargs):
4346
self.include_keys = include_keys
4447
self.import_zone = None
48+
self._csk_private_key_data = None
4549
super().__init__(*args, **kwargs)
4650

4751
def get_fields(self):
@@ -100,11 +104,32 @@ def parse_zonefile(self, domain_name: str, zonefile: str):
100104
def validate(self, attrs):
101105
if attrs.get("zonefile") is not None:
102106
self.parse_zonefile(attrs.get("name"), attrs.pop("zonefile"))
107+
if attrs.get("csk_private_key") is not None:
108+
private_key = attrs.get("csk_private_key")
109+
if not private_key.strip():
110+
raise serializers.ValidationError(
111+
{"csk_private_key": ["Missing private key material."]}
112+
)
113+
try:
114+
parsed = dnssec.parse_csk_private_key(private_key)
115+
except ValueError as exc:
116+
raise serializers.ValidationError(
117+
{"csk_private_key": [str(exc)]}
118+
) from exc
119+
self._csk_private_key_data = {
120+
"private_key": private_key,
121+
"dnskey": parsed["dnskey"],
122+
"algorithm": parsed["algorithm"],
123+
}
103124
return super().validate(attrs)
104125

105126
def create(self, validated_data):
127+
validated_data.pop("csk_private_key", None)
106128
# save domain
107-
domain: Domain = super().create(validated_data)
129+
domain = Domain(**validated_data)
130+
if self._csk_private_key_data is not None:
131+
domain._csk_private_key_data = self._csk_private_key_data
132+
domain.save()
108133

109134
# save RRsets if zonefile was given
110135
nodes = getattr(self.import_zone, "nodes", None)

test/e2e2/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,14 +241,18 @@ def login(self, email: str, password: str) -> requests.Response:
241241
def domain_list(self) -> requests.Response:
242242
return self.get("/domains/").json()
243243

244-
def domain_create(self, name, zonefile=None, nslord=None) -> requests.Response:
244+
def domain_create(
245+
self, name, zonefile=None, nslord=None, csk_private_key=None
246+
) -> requests.Response:
245247
if name in self.domains:
246248
raise ValueError
247249
data = {"name": name}
248250
if zonefile is not None:
249251
data['zonefile'] = zonefile
250252
if nslord is not None:
251253
data['nslord'] = nslord
254+
if csk_private_key is not None:
255+
data['csk_private_key'] = csk_private_key
252256
response = self.post("/domains/", data=data)
253257
self.domains[name] = response.json()
254258
if nslord is not None:

0 commit comments

Comments
 (0)