From e11e2c903207cb2ebbad521a308d03efd1ee41ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Wed, 15 Apr 2026 17:03:52 +0000 Subject: [PATCH] Add ZoneKey helpers for key operations in isctest.zone Introduce an abstract ZoneKey base class with two concrete implementations: - FileZoneKey wraps a dnssec-keygen-managed key file (kasp.Key). - PythonZoneKey holds a Python-native keypair for dnspython-based signing and key operations. Both share ZoneKey.into_ta() and ZoneKey.is_ksk(). The ZoneKey abstraction lets Zone.copy_dssets() and Zone.trust_anchors() handle pure-Python keys without callers needing to know how the key was made. Assisted-by: Claude:claude-opus-4-8 --- bin/tests/system/isctest/zone.py | 327 ++++++++++++++++++++++++++++--- 1 file changed, 297 insertions(+), 30 deletions(-) diff --git a/bin/tests/system/isctest/zone.py b/bin/tests/system/isctest/zone.py index 8db84f23f8..df65932541 100644 --- a/bin/tests/system/isctest/zone.py +++ b/bin/tests/system/isctest/zone.py @@ -9,11 +9,24 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable from pathlib import Path +from typing import TypeAlias import shutil +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa +from dns.rdtypes.dnskeybase import Flag + +import dns.dnssec import dns.name +import dns.rdataclass +import dns.rdatatype +import dns.rrset from .kasp import Key from .log import debug @@ -22,6 +35,253 @@ from .template import NS1, Nameserver, TemplateEngine, TrustAnchor from .vars.algorithms import Algorithm KEYDIR = "keys" +DNSKEY_TTL = 3600 + +PrivateKey: TypeAlias = ( + ec.EllipticCurvePrivateKey + | ed25519.Ed25519PrivateKey + | ed448.Ed448PrivateKey + | rsa.RSAPrivateKey +) + + +class ZoneKey(ABC): + """ + Abstract base for a DNSSEC key attached to a Zone. + + Two concrete implementations exist: + FileZoneKey — wraps a dnssec-keygen-managed key file pair (kasp.Key). + PythonZoneKey — holds a Python-native (private_key, dnskey_rdata) pair + required for dnspython-based operations and signing. + + The interface covers the zone-infrastructure subset: trust anchor + generation and DS propagation to parent zones. + """ + + @property + @abstractmethod + def dnskey(self) -> dns.rrset.RRset: + """The DNSKEY RRset for this key (single-record, with TTL).""" + + def is_ksk(self) -> bool: + return bool(self.dnskey[0].flags & int(Flag.SEP)) + + @abstractmethod + def write_dsset( + self, + target_dir: Path | str, + dsdigest: dns.dnssec.DSDigest = dns.dnssec.DSDigest.SHA256, + ) -> None: + """ + Write or copy dsset-{zone}. into target_dir. + + For FileZoneKey: copies the dsset file produced by dnssec-signzone. + For PythonZoneKey: derives the DS from the in-memory key and writes it. + """ + + def into_ta( + self, + ta_type: str = "static-ds", + dsdigest: dns.dnssec.DSDigest = dns.dnssec.DSDigest.SHA256, + ) -> TrustAnchor: + """ + Build a named.conf trust-anchor stanza from this key. + + ta_type must be one of: static-ds, initial-ds, static-key, initial-key. + Implemented once here; both subclasses inherit it via self.dnskey. + """ + dnskey = self.dnskey + if ta_type in ["static-ds", "initial-ds"]: + ds = dns.dnssec.make_ds(dnskey.name, dnskey[0], dsdigest) + parts = str(ds).split() + contents = " ".join(parts[:3]) + f' "{parts[3]}"' + elif ta_type in ["static-key", "initial-key"]: + parts = str(dnskey).split() + contents = " ".join(parts[4:7]) + f' "{"".join(parts[7:])}"' + else: + raise ValueError(f"invalid trust anchor type: {ta_type!r}") + return TrustAnchor(str(dnskey.name), ta_type, contents) + + +class FileZoneKey(ZoneKey): + """ + A ZoneKey backed by dnssec-keygen-managed key files. + + Constructed by FileZoneKey.generate(); callers normally do not + instantiate this directly. The underlying kasp.Key is accessible via + .key for working with timing metadata, state files, etc. + """ + + def __init__(self, key: Key, zone: Zone) -> None: + self.key = key + self.zone = zone + + @property + def dnskey(self) -> dns.rrset.RRset: + return self.key.dnskey + + def write_dsset( + self, + target_dir: Path | str, + dsdigest: dns.dnssec.DSDigest = dns.dnssec.DSDigest.SHA256, + ) -> None: + """ + Copy the dnssec-signzone-produced dsset-{zone}. into target_dir. + + dsdigest is accepted for interface compatibility but ignored: the dsset + file is produced by dnssec-signzone (SHA-256). This copy overwrites any + existing dsset file, so a zone must not mix FileZoneKey and + PythonZoneKey KSKs (PythonZoneKey appends to the same file); + Zone.copy_dssets enforces this. + """ + src = Path(self.zone.ns.name) / f"dsset-{self.zone.name}." + shutil.copy(src, Path(target_dir)) + debug(f"{self.zone.name}: dsset copied to {target_dir}") + + @staticmethod + def generate( + zone: Zone, + params: str = "", + alg: Algorithm | None = None, + ) -> FileZoneKey: + """ + Generate a DNSSEC key via dnssec-keygen for zone and return it. + + Runs dnssec-keygen in zone.ns.name/keys/, stores the key there, and + returns the resulting FileZoneKey. Pass params="-f KSK" to generate a + Key Signing Key; omit it (or pass "") for a Zone Signing Key. + """ + debug(f"{zone.name}: generating key using dnssec-keygen") + keydir = Path(zone.ns.name) / KEYDIR + keydir.mkdir(exist_ok=True) + if alg is None: + alg = Algorithm.default() + keygen = EnvCmd( + "KEYGEN", f"-q -a {alg.number} -b {alg.bits} -K {KEYDIR} -L {DNSKEY_TTL}" + ) + key_name = keygen(f"{params} {zone.name}", cwd=zone.ns.name).out.strip() + return FileZoneKey(Key(key_name, keydir=keydir), zone=zone) + + +class PythonZoneKey(ZoneKey): + """ + A ZoneKey holding a Python-native keypair. + + Construct via PythonZoneKey.generate() to create fresh key + material, or instantiate directly when you already have a private key and + dnskey rdata (e.g. when loading a saved PEM). + + Attach to a Zone via zone.keys = [key] so that Zone.copy_dssets() can + generate the dsset-* file for the parent zone and Zone.trust_anchors() can + produce the correct trust anchor stanzas. + + Zone.sign() raises TypeError if self.keys contains a PythonZoneKey, + because dnssec-signzone cannot use in-memory keys. Sign the zone + with dns.dnssec.sign_zone() directly instead. + + The raw private key object is available as .private_key for callers that + need it (e.g. to write a PEM file for a custom authoritative server). + Use write_private_key_pem() as a convenience for the common case. + """ + + def __init__( + self, + zone: Zone, + private_key, + dnskey_rdata, + ttl: int = DNSKEY_TTL, + ) -> None: + self.zone = zone + self.private_key = private_key + self._dnskey_rdata = dnskey_rdata + self.ttl = ttl + + @property + def dnskey(self) -> dns.rrset.RRset: + rrset = dns.rrset.RRset( + self.zone.dname, dns.rdataclass.IN, dns.rdatatype.DNSKEY + ) + rrset.update_ttl(self.ttl) + rrset.add(self._dnskey_rdata) + return rrset + + def write_dsset( + self, + target_dir: Path | str, + dsdigest: dns.dnssec.DSDigest = dns.dnssec.DSDigest.SHA256, + ) -> None: + target = Path(target_dir) + target.mkdir(parents=True, exist_ok=True) + ds = dns.dnssec.make_ds(self.zone.dname, self._dnskey_rdata, dsdigest) + text = ( + f"{self.zone.name}. {self.ttl} IN DS" + f" {ds.key_tag} {ds.algorithm} {ds.digest_type}" + f" {ds.digest.hex().upper()}\n" + ) + with (target / f"dsset-{self.zone.name}.").open("a") as f: + f.write(text) + + def write_private_key_pem(self, path: Path | str) -> None: + """Write the private key to path in PKCS8 PEM format (no encryption).""" + Path(path).write_bytes( + self.private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + @staticmethod + def generate( + zone: Zone, + flags: int = int(Flag.ZONE | Flag.SEP), + alg: Algorithm | None = None, + ttl: int = DNSKEY_TTL, + ) -> PythonZoneKey: + """ + Generate a Python-native DNSSEC keypair for the given algorithm. + + Unlike FileZoneKey.generate(), this does not invoke + dnssec-keygen and produces no on-disk key files. The returned + PythonZoneKey is suitable for use with dns.dnssec.sign(), + dns.dnssec.sign_zone(), and dns.dnssec.make_ds(). + + The algorithm-to-key-type mapping is: + ECDSAP256SHA256 -> EC P-256 + ECDSAP384SHA384 -> EC P-384 + ED25519 -> Ed25519 + ED448 -> Ed448 + RSASHA1/256/512 -> RSA (key size from alg.bits) + + Args: + zone: The Zone to generate the keypair for. + flags: DNSKEY flags bitmask; defaults to ZONE|SEP (KSK). + alg: Algorithm to use; defaults to Algorithm.default(). + ttl: TTL for the DNSKEY RRset (default DNSKEY_TTL). + """ + if alg is None: + alg = Algorithm.default() + _generators: dict[str, Callable[[], PrivateKey]] = { + "ECDSAP256SHA256": lambda: ec.generate_private_key(ec.SECP256R1()), + "ECDSAP384SHA384": lambda: ec.generate_private_key(ec.SECP384R1()), + "ED25519": ed25519.Ed25519PrivateKey.generate, + "ED448": ed448.Ed448PrivateKey.generate, + "RSASHA1": lambda: rsa.generate_private_key(65537, alg.bits), + "RSASHA256": lambda: rsa.generate_private_key(65537, alg.bits), + "RSASHA512": lambda: rsa.generate_private_key(65537, alg.bits), + } + gen = _generators.get(alg.name) + if gen is None: + raise ValueError( + f"unsupported algorithm for Python-native key generation: {alg.name!r}" + ) + private_key = gen() + dnskey_rdata = dns.dnssec.make_dnskey( + private_key.public_key(), + dns.dnssec.Algorithm(alg.number), + flags=flags, + ) + return PythonZoneKey(zone, private_key, dnskey_rdata, ttl) class Zone: @@ -72,42 +332,37 @@ class Zone: ) self.delegations: list[Zone] = [] - self.keys: list[Key] = [] + self.keys: list[ZoneKey] = [] @property def filepath(self) -> Path: """Actual zone file — filepath_signed if signed, filepath_unsigned otherwise.""" return self.filepath_signed if self.signed else self.filepath_unsigned - def make_key(self, params: str = "", alg: Algorithm | None = None) -> Key: - """Generate a DNSSEC key and return it without adding it to self.keys.""" - debug(f"{self.name}: generating key") - keydir = Path(self.ns.name) / KEYDIR - keydir.mkdir(exist_ok=True) - if alg is None: - alg = Algorithm.default() - keygen = EnvCmd( - "KEYGEN", f"-q -a {alg.number} -b {alg.bits} -K {KEYDIR} -L 3600" - ) - key_name = keygen(f"{params} {self.name}", cwd=self.ns.name).out.strip() - return Key(key_name, keydir=keydir) - - def add_keys(self, ksk=True, zsk=True) -> None: - """Generate KSK and/or ZSK and append both to self.keys.""" + def add_keys(self, ksk: bool = True, zsk: bool = True) -> None: + """Generate KSK and/or ZSK via dnssec-keygen and append to self.keys.""" if ksk: - self.keys.append(self.make_key("-f KSK")) + self.keys.append(FileZoneKey.generate(self, "-f KSK")) if zsk: - self.keys.append(self.make_key()) + self.keys.append(FileZoneKey.generate(self)) def copy_dssets(self) -> None: - """Copy dsset-* files from each delegation's ns dir into self.ns dir.""" + """Write dsset-* files for each signed delegation into self.ns dir.""" for zone in self.delegations: - try: - shutil.copy(f"{zone.ns.name}/dsset-{zone.name}.", self.ns.name) - except FileNotFoundError: - debug(f"{zone.name}: delegation is insecure (no dsset found)") + ksks = [k for k in zone.keys if k.is_ksk()] + has_file = any(isinstance(k, FileZoneKey) for k in ksks) + has_python = any(isinstance(k, PythonZoneKey) for k in ksks) + if has_file and has_python: + raise TypeError( + f"{zone.name}: cannot mix FileZoneKey and PythonZoneKey KSKs; " + "dsset writing is order-dependent (FileZoneKey overwrites, " + "PythonZoneKey appends)" + ) + if ksks: + for key in ksks: + key.write_dsset(Path(self.ns.name)) else: - debug(f"{zone.name}: delegation is secure") + debug(f"{zone.name}: delegation is insecure (no KSK)") def render(self, template: str | None = None) -> None: """Render the unsigned zone file from a jinja2 template.""" @@ -128,8 +383,20 @@ class Zone: templates.render(str(output), data, template=template) def sign(self, params: str = "") -> None: - """Sign the rendered zone file. Requires self.signed == True.""" + """ + Sign the rendered zone file via dnssec-signzone. + + Requires self.signed == True. Raises TypeError if self.keys contains + any PythonZoneKey — dnssec-signzone cannot use in-memory keys; use + dns.dnssec.sign_zone() directly for Python-native signing. + """ assert self.signed, f"{self.name}: zone is not configured for signing" + python_keys = [k for k in self.keys if isinstance(k, PythonZoneKey)] + if python_keys: + raise TypeError( + f"{self.name}: Zone.sign() invokes dnssec-signzone which requires " + "file-backed keys; use dns.dnssec.sign_zone() for Python-native keys" + ) debug(f"{self.name}: signing zone") signer = EnvCmd("SIGNER", f"-S -g -K {KEYDIR} {params}") signer( @@ -156,11 +423,11 @@ class Zone: if self.signed: self.sign(sign_params) - def trust_anchor( - self, type: str = "static-ds" # pylint: disable=redefined-builtin - ) -> TrustAnchor: - assert self.keys, "no zone keys configured" - return self.keys[0].into_ta(type) + def trust_anchors(self, ta_type: str = "static-ds") -> list[TrustAnchor]: + """Return a trust-anchor stanza for every KSK in zone.keys.""" + ksks = [k for k in self.keys if k.is_ksk()] + assert ksks, f"{self.name}: no KSK in zone.keys" + return [k.into_ta(ta_type) for k in ksks] def configure_root(