]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Add ZoneKey helpers for key operations in isctest.zone 12191/head
authorNicki Křížek <nicki@isc.org>
Wed, 15 Apr 2026 17:03:52 +0000 (17:03 +0000)
committerNicki Křížek <nicki@isc.org>
Fri, 5 Jun 2026 08:44:46 +0000 (10:44 +0200)
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
(cherry picked from commit e11e2c903207cb2ebbad521a308d03efd1ee41ac)

bin/tests/system/isctest/zone.py

index 8db84f23f89daf219c6253bdbfe4a7e82f89a28f..df6593254143e4594fd466d1306c501b27fe623a 100644 (file)
@@ -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(