mirror of
https://github.com/isc-projects/bind9.git
synced 2026-06-10 18:00:00 -04:00
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
This commit is contained in:
parent
fc5116ed91
commit
e11e2c9032
1 changed files with 297 additions and 30 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue