summaryrefslogtreecommitdiffstats
path: root/bin/python/isc
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--bin/python/isc/Makefile.in45
-rw-r--r--bin/python/isc/__init__.py.in38
-rw-r--r--bin/python/isc/checkds.py.in226
-rw-r--r--bin/python/isc/coverage.py.in333
-rw-r--r--bin/python/isc/dnskey.py.in570
-rw-r--r--bin/python/isc/eventlist.py.in178
-rw-r--r--bin/python/isc/keydict.py.in87
-rw-r--r--bin/python/isc/keyevent.py.in80
-rw-r--r--bin/python/isc/keymgr.py.in207
-rw-r--r--bin/python/isc/keyseries.py.in232
-rw-r--r--bin/python/isc/keyzone.py.in59
-rw-r--r--bin/python/isc/policy.py.in761
-rw-r--r--bin/python/isc/rndc.py.in193
-rw-r--r--bin/python/isc/tests/Makefile.in33
-rw-r--r--bin/python/isc/tests/dnskey_test.py.in54
-rw-r--r--bin/python/isc/tests/policy_test.py.in104
-rw-r--r--bin/python/isc/tests/test-policies/01-keysize.pol54
-rw-r--r--bin/python/isc/tests/test-policies/02-prepublish.pol44
-rw-r--r--bin/python/isc/tests/test-policies/03-postpublish.pol44
-rw-r--r--bin/python/isc/tests/test-policies/04-combined-pre-post.pol68
-rw-r--r--bin/python/isc/tests/test-policies/05-numeric-zone.pol17
-rw-r--r--bin/python/isc/tests/testdata/Kexample.com.+007+35529.key8
-rw-r--r--bin/python/isc/tests/testdata/Kexample.com.+007+35529.private18
-rw-r--r--bin/python/isc/utils.py.in71
24 files changed, 3524 insertions, 0 deletions
diff --git a/bin/python/isc/Makefile.in b/bin/python/isc/Makefile.in
new file mode 100644
index 0000000..f175c6c
--- /dev/null
+++ b/bin/python/isc/Makefile.in
@@ -0,0 +1,45 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+srcdir = @srcdir@
+VPATH = @srcdir@
+top_srcdir = @top_srcdir@
+
+@BIND9_MAKE_INCLUDES@
+
+SUBDIRS = tests
+
+PYTHON = @PYTHON@
+
+PYSRCS = __init__.py checkds.py coverage.py dnskey.py eventlist.py \
+ keydict.py keyevent.py keymgr.py keyseries.py keyzone.py \
+ policy.py rndc.py utils.py
+
+TARGETS = parsetab.py
+
+@BIND9_MAKE_RULES@
+
+.SUFFIXES: .py .pyc
+.py.pyc:
+ $(PYTHON) -m compileall .
+
+parsetab.py: policy.py
+ $(PYTHON) policy.py parse /dev/null > /dev/null
+ PYTHONPATH=${srcdir} $(PYTHON) -m parsetab
+
+check test: subdirs
+
+clean distclean::
+ rm -f *.pyc parser.out parsetab.py
+ rm -rf __pycache__ build
+
+distclean::
+ rm -rf ${PYSRCS}
diff --git a/bin/python/isc/__init__.py.in b/bin/python/isc/__init__.py.in
new file mode 100644
index 0000000..405c3d7
--- /dev/null
+++ b/bin/python/isc/__init__.py.in
@@ -0,0 +1,38 @@
+#!/usr/bin/python3
+
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+__all__ = [
+ "checkds",
+ "coverage",
+ "keymgr",
+ "dnskey",
+ "eventlist",
+ "keydict",
+ "keyevent",
+ "keyseries",
+ "keyzone",
+ "policy",
+ "parsetab",
+ "rndc",
+ "utils",
+]
+
+from isc.dnskey import *
+from isc.eventlist import *
+from isc.keydict import *
+from isc.keyevent import *
+from isc.keyseries import *
+from isc.keyzone import *
+from isc.policy import *
+from isc.rndc import *
+from isc.utils import *
diff --git a/bin/python/isc/checkds.py.in b/bin/python/isc/checkds.py.in
new file mode 100644
index 0000000..f2e6562
--- /dev/null
+++ b/bin/python/isc/checkds.py.in
@@ -0,0 +1,226 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+import argparse
+import os
+import sys
+from subprocess import Popen, PIPE
+
+from isc.utils import prefix, version
+
+prog = "dnssec-checkds"
+
+
+############################################################################
+# SECRR class:
+# Class for DS resource record
+############################################################################
+class SECRR:
+ hashalgs = {1: "SHA-1", 2: "SHA-256", 3: "GOST", 4: "SHA-384"}
+ rrname = ""
+ rrclass = "IN"
+ keyid = None
+ keyalg = None
+ hashalg = None
+ digest = ""
+ ttl = 0
+
+ def __init__(self, rrtext):
+ if not rrtext:
+ raise Exception
+
+ # 'str' does not have decode method in python3
+ if type(rrtext) is not str:
+ fields = rrtext.decode("ascii").split()
+ else:
+ fields = rrtext.split()
+ if len(fields) < 7:
+ raise Exception
+
+ self.rrtype = "DS"
+ self.rrname = fields[0].lower()
+
+ fields = fields[1:]
+ if fields[0].upper() in ["IN", "CH", "HS"]:
+ self.rrclass = fields[0].upper()
+ fields = fields[1:]
+ else:
+ self.ttl = int(fields[0])
+ self.rrclass = fields[1].upper()
+ fields = fields[2:]
+
+ if fields[0].upper() != self.rrtype:
+ raise Exception("%s does not match %s" % (fields[0].upper(), self.rrtype))
+
+ self.keyid, self.keyalg, self.hashalg = map(int, fields[1:4])
+ self.digest = "".join(fields[4:]).upper()
+
+ def __repr__(self):
+ return "%s %s %s %d %d %d %s" % (
+ self.rrname,
+ self.rrclass,
+ self.rrtype,
+ self.keyid,
+ self.keyalg,
+ self.hashalg,
+ self.digest,
+ )
+
+ def __eq__(self, other):
+ return self.__repr__() == other.__repr__()
+
+
+############################################################################
+# check:
+# Fetch DS RRset for the given zone from the DNS; fetch DNSKEY
+# RRset from the masterfile if specified, or from DNS if not.
+# Generate a set of expected DS records from the DNSKEY RRset,
+# and report on congruency.
+############################################################################
+def check(zone, args):
+ rrlist = []
+ if args.dssetfile:
+ fp = open(args.dssetfile).read()
+ else:
+ cmd = [args.dig, "+noall", "+answer", "-t", "ds", "-q", zone]
+ fp, _ = Popen(cmd, stdout=PIPE).communicate()
+
+ for line in fp.splitlines():
+ if type(line) is not str:
+ line = line.decode("ascii")
+ rrlist.append(SECRR(line))
+ rrlist = sorted(rrlist, key=lambda rr: (rr.keyid, rr.keyalg, rr.hashalg))
+
+ klist = []
+
+ cmd = [args.dsfromkey]
+ for algo in args.algo:
+ cmd += ["-a", algo]
+
+ if args.masterfile:
+ cmd += ["-f", args.masterfile, zone]
+ fp, _ = Popen(cmd, stdout=PIPE).communicate()
+ else:
+ intods, _ = Popen(
+ [args.dig, "+noall", "+answer", "-t", "dnskey", "-q", zone], stdout=PIPE
+ ).communicate()
+ cmd += ["-f", "-", zone]
+ fp, _ = Popen(cmd, stdin=PIPE, stdout=PIPE).communicate(intods)
+
+ for line in fp.splitlines():
+ if type(line) is not str:
+ line = line.decode("ascii")
+ klist.append(SECRR(line))
+
+ if len(klist) < 1:
+ print("No DNSKEY records found in zone apex")
+ return False
+
+ match = True
+ for rr in rrlist:
+ if rr not in klist:
+ print(
+ "KSK for %s %s/%03d/%05d (%s) missing from child"
+ % (
+ rr.rrtype,
+ rr.rrname.strip("."),
+ rr.keyalg,
+ rr.keyid,
+ SECRR.hashalgs[rr.hashalg],
+ )
+ )
+ match = False
+ for rr in klist:
+ if rr not in rrlist:
+ print(
+ "%s for KSK %s/%03d/%05d (%s) missing from parent"
+ % (
+ rr.rrtype,
+ rr.rrname.strip("."),
+ rr.keyalg,
+ rr.keyid,
+ SECRR.hashalgs[rr.hashalg],
+ )
+ )
+ match = False
+ for rr in klist:
+ if rr in rrlist:
+ print(
+ "%s for KSK %s/%03d/%05d (%s) found in parent"
+ % (
+ rr.rrtype,
+ rr.rrname.strip("."),
+ rr.keyalg,
+ rr.keyid,
+ SECRR.hashalgs[rr.hashalg],
+ )
+ )
+
+ return match
+
+
+############################################################################
+# parse_args:
+# Read command line arguments, set global 'args' structure
+############################################################################
+def parse_args():
+ parser = argparse.ArgumentParser(description=prog + ": checks DS coverage")
+
+ bindir = "bin"
+ sbindir = "bin" if os.name == "nt" else "sbin"
+
+ parser.add_argument("zone", type=str, help="zone to check")
+ parser.add_argument(
+ "-a",
+ "--algo",
+ dest="algo",
+ action="append",
+ default=[],
+ type=str,
+ help="DS digest algorithm",
+ )
+ parser.add_argument(
+ "-d",
+ "--dig",
+ dest="dig",
+ default=os.path.join(prefix(bindir), "dig"),
+ type=str,
+ help="path to 'dig'",
+ )
+ parser.add_argument(
+ "-D",
+ "--dsfromkey",
+ dest="dsfromkey",
+ default=os.path.join(prefix(sbindir), "dnssec-dsfromkey"),
+ type=str,
+ help="path to 'dnssec-dsfromkey'",
+ )
+ parser.add_argument(
+ "-f", "--file", dest="masterfile", type=str, help="zone master file"
+ )
+ parser.add_argument(
+ "-s", "--dsset", dest="dssetfile", type=str, help="prepared DSset file"
+ )
+ parser.add_argument("-v", "--version", action="version", version=version)
+ args = parser.parse_args()
+
+ args.zone = args.zone.strip(".")
+
+ return args
+
+
+############################################################################
+# Main
+############################################################################
+def main():
+ args = parse_args()
+ match = check(args.zone, args)
+ exit(0 if match else 1)
diff --git a/bin/python/isc/coverage.py.in b/bin/python/isc/coverage.py.in
new file mode 100644
index 0000000..e9be265
--- /dev/null
+++ b/bin/python/isc/coverage.py.in
@@ -0,0 +1,333 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+from __future__ import print_function
+import os
+import sys
+import argparse
+import glob
+import re
+import time
+import calendar
+import pprint
+from collections import defaultdict
+
+prog = "dnssec-coverage"
+
+from isc import dnskey, eventlist, keydict, keyevent, keyzone, utils
+
+
+############################################################################
+# print a fatal error and exit
+############################################################################
+def fatal(*args, **kwargs):
+ print(*args, **kwargs)
+ sys.exit(1)
+
+
+############################################################################
+# output:
+############################################################################
+_firstline = True
+
+
+def output(*args, **kwargs):
+ """output text, adding a vertical space this is *not* the first
+ first section being printed since a call to vreset()"""
+ global _firstline
+ if "skip" in kwargs:
+ skip = kwargs["skip"]
+ kwargs.pop("skip", None)
+ else:
+ skip = True
+ if _firstline:
+ _firstline = False
+ elif skip:
+ print("")
+ if args:
+ print(*args, **kwargs)
+
+
+def vreset():
+ """reset vertical spacing"""
+ global _firstline
+ _firstline = True
+
+
+############################################################################
+# parse_time
+############################################################################
+def parse_time(s):
+ """convert a formatted time (e.g., 1y, 6mo, 15mi, etc) into seconds
+ :param s: String with some text representing a time interval
+ :return: Integer with the number of seconds in the time interval
+ """
+ s = s.strip()
+
+ # if s is an integer, we're done already
+ try:
+ return int(s)
+ except ValueError:
+ pass
+
+ # try to parse as a number with a suffix indicating unit of time
+ r = re.compile(r"([0-9][0-9]*)\s*([A-Za-z]*)")
+ m = r.match(s)
+ if not m:
+ raise ValueError("Cannot parse %s" % s)
+ n, unit = m.groups()
+ n = int(n)
+ unit = unit.lower()
+ if unit.startswith("y"):
+ return n * 31536000
+ elif unit.startswith("mo"):
+ return n * 2592000
+ elif unit.startswith("w"):
+ return n * 604800
+ elif unit.startswith("d"):
+ return n * 86400
+ elif unit.startswith("h"):
+ return n * 3600
+ elif unit.startswith("mi"):
+ return n * 60
+ elif unit.startswith("s"):
+ return n
+ else:
+ raise ValueError("Invalid suffix %s" % unit)
+
+
+############################################################################
+# set_path:
+############################################################################
+def set_path(command, default=None):
+ """find the location of a specified command. if a default is supplied
+ and it works, we use it; otherwise we search PATH for a match.
+ :param command: string with a command to look for in the path
+ :param default: default location to use
+ :return: detected location for the desired command
+ """
+
+ fpath = default
+ if not fpath or not os.path.isfile(fpath) or not os.access(fpath, os.X_OK):
+ path = os.environ["PATH"]
+ if not path:
+ path = os.path.defpath
+ for directory in path.split(os.pathsep):
+ fpath = os.path.join(directory, command)
+ if os.path.isfile(fpath) and os.access(fpath, os.X_OK):
+ break
+ fpath = None
+
+ return fpath
+
+
+############################################################################
+# parse_args:
+############################################################################
+def parse_args():
+ """Read command line arguments, set global 'args' structure"""
+ compilezone = set_path(
+ "named-compilezone", os.path.join(utils.prefix("sbin"), "named-compilezone")
+ )
+
+ parser = argparse.ArgumentParser(
+ description=prog + ": checks future " + "DNSKEY coverage for a zone"
+ )
+
+ parser.add_argument(
+ "zone",
+ type=str,
+ nargs="*",
+ default=None,
+ help="zone(s) to check" + "(default: all zones in the directory)",
+ )
+ parser.add_argument(
+ "-K",
+ dest="path",
+ default=".",
+ type=str,
+ help="a directory containing keys to process",
+ metavar="dir",
+ )
+ parser.add_argument(
+ "-f", dest="filename", type=str, help="zone master file", metavar="file"
+ )
+ parser.add_argument(
+ "-m",
+ dest="maxttl",
+ type=str,
+ help="the longest TTL in the zone(s)",
+ metavar="time",
+ )
+ parser.add_argument(
+ "-d", dest="keyttl", type=str, help="the DNSKEY TTL", metavar="time"
+ )
+ parser.add_argument(
+ "-r",
+ dest="resign",
+ default="1944000",
+ type=str,
+ help="the RRSIG refresh interval " "in seconds [default: 22.5 days]",
+ metavar="time",
+ )
+ parser.add_argument(
+ "-c",
+ dest="compilezone",
+ default=compilezone,
+ type=str,
+ help="path to 'named-compilezone'",
+ metavar="path",
+ )
+ parser.add_argument(
+ "-l",
+ dest="checklimit",
+ type=str,
+ default="0",
+ help="Length of time to check for " "DNSSEC coverage [default: 0 (unlimited)]",
+ metavar="time",
+ )
+ parser.add_argument(
+ "-z",
+ dest="no_ksk",
+ action="store_true",
+ default=False,
+ help="Only check zone-signing keys (ZSKs)",
+ )
+ parser.add_argument(
+ "-k",
+ dest="no_zsk",
+ action="store_true",
+ default=False,
+ help="Only check key-signing keys (KSKs)",
+ )
+ parser.add_argument(
+ "-D",
+ "--debug",
+ dest="debug_mode",
+ action="store_true",
+ default=False,
+ help="Turn on debugging output",
+ )
+ parser.add_argument("-v", "--version", action="version", version=utils.version)
+
+ args = parser.parse_args()
+
+ if args.no_zsk and args.no_ksk:
+ fatal("ERROR: -z and -k cannot be used together.")
+ elif args.no_zsk or args.no_ksk:
+ args.keytype = "KSK" if args.no_zsk else "ZSK"
+ else:
+ args.keytype = None
+
+ if args.filename and len(args.zone) > 1:
+ fatal("ERROR: -f can only be used with one zone.")
+
+ # strip trailing dots if any
+ args.zone = [x[:-1] if (len(x) > 1 and x[-1] == ".") else x for x in args.zone]
+
+ # convert from time arguments to seconds
+ try:
+ if args.maxttl:
+ m = parse_time(args.maxttl)
+ args.maxttl = m
+ except ValueError:
+ pass
+
+ try:
+ if args.keyttl:
+ k = parse_time(args.keyttl)
+ args.keyttl = k
+ except ValueError:
+ pass
+
+ try:
+ if args.resign:
+ r = parse_time(args.resign)
+ args.resign = r
+ except ValueError:
+ pass
+
+ try:
+ if args.checklimit:
+ lim = args.checklimit
+ r = parse_time(args.checklimit)
+ if r == 0:
+ args.checklimit = None
+ else:
+ args.checklimit = time.time() + r
+ except ValueError:
+ pass
+
+ # if we've got the values we need from the command line, stop now
+ if args.maxttl and args.keyttl:
+ return args
+
+ # load keyttl and maxttl data from zonefile
+ if args.zone and args.filename:
+ try:
+ zone = keyzone(args.zone[0], args.filename, args.compilezone)
+ args.maxttl = args.maxttl or zone.maxttl
+ args.keyttl = args.maxttl or zone.keyttl
+ except Exception as e:
+ print("Unable to load zone data from %s: " % args.filename, e)
+
+ if not args.maxttl:
+ output(
+ "WARNING: Maximum TTL value was not specified. Using 1 week\n"
+ "\t (604800 seconds); re-run with the -m option to get more\n"
+ "\t accurate results."
+ )
+ args.maxttl = 604800
+
+ return args
+
+
+############################################################################
+# Main
+############################################################################
+def main():
+ args = parse_args()
+
+ print("PHASE 1--Loading keys to check for internal timing problems")
+
+ try:
+ kd = keydict(path=args.path, zones=args.zone, keyttl=args.keyttl)
+ except Exception as e:
+ fatal("ERROR: Unable to build key dictionary: " + str(e))
+
+ for key in kd:
+ key.check_prepub(output)
+ if key.sep:
+ key.check_postpub(output)
+ else:
+ key.check_postpub(output, args.maxttl + args.resign)
+
+ output("PHASE 2--Scanning future key events for coverage failures")
+ vreset()
+
+ try:
+ elist = eventlist(kd)
+ except Exception as e:
+ fatal("ERROR: Unable to build event list: " + str(e))
+
+ errors = False
+ if not args.zone:
+ if not elist.coverage(None, args.keytype, args.checklimit, output):
+ errors = True
+ else:
+ for zone in args.zone:
+ try:
+ if not elist.coverage(zone, args.keytype, args.checklimit, output):
+ errors = True
+ except:
+ output("ERROR: Coverage check failed for zone " + zone)
+
+ sys.exit(1 if errors else 0)
diff --git a/bin/python/isc/dnskey.py.in b/bin/python/isc/dnskey.py.in
new file mode 100644
index 0000000..c4cdc57
--- /dev/null
+++ b/bin/python/isc/dnskey.py.in
@@ -0,0 +1,570 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+import os
+import time
+import calendar
+from subprocess import Popen, PIPE
+
+
+########################################################################
+# Class dnskey
+########################################################################
+class TimePast(Exception):
+ def __init__(self, key, prop, value):
+ super(TimePast, self).__init__(
+ "%s time for key %s (%d) is already past" % (prop, key, value)
+ )
+
+
+class dnskey:
+ """An individual DNSSEC key. Identified by path, name, algorithm, keyid.
+ Contains a dictionary of metadata events."""
+
+ _PROPS = (
+ "Created",
+ "Publish",
+ "Activate",
+ "Inactive",
+ "Delete",
+ "Revoke",
+ "DSPublish",
+ "SyncPublish",
+ "SyncDelete",
+ )
+ _OPTS = (None, "-P", "-A", "-I", "-D", "-R", None, "-Psync", "-Dsync")
+
+ _ALGNAMES = (
+ None,
+ "RSAMD5",
+ "DH",
+ "DSA",
+ None,
+ "RSASHA1",
+ "NSEC3DSA",
+ "NSEC3RSASHA1",
+ "RSASHA256",
+ None,
+ "RSASHA512",
+ None,
+ "ECCGOST",
+ "ECDSAP256SHA256",
+ "ECDSAP384SHA384",
+ "ED25519",
+ "ED448",
+ )
+
+ def __init__(self, key, directory=None, keyttl=None):
+ # this makes it possible to use algname as a class or instance method
+ if isinstance(key, tuple) and len(key) == 3:
+ self._dir = directory or "."
+ (name, alg, keyid) = key
+ self.fromtuple(name, alg, keyid, keyttl)
+
+ self._dir = directory or os.path.dirname(key) or "."
+ key = os.path.basename(key)
+ (name, alg, keyid) = key.split("+")
+ name = name[1:-1]
+ alg = int(alg)
+ keyid = int(keyid.split(".")[0])
+ self.fromtuple(name, alg, keyid, keyttl)
+
+ def fromtuple(self, name, alg, keyid, keyttl):
+ if name.endswith("."):
+ fullname = name
+ name = name.rstrip(".")
+ else:
+ fullname = name + "."
+
+ keystr = "K%s+%03d+%05d" % (fullname, alg, keyid)
+ key_file = self._dir + (self._dir and os.sep or "") + keystr + ".key"
+ private_file = self._dir + (self._dir and os.sep or "") + keystr + ".private"
+
+ self.keystr = keystr
+
+ self.name = name
+ self.alg = int(alg)
+ self.keyid = int(keyid)
+ self.fullname = fullname
+
+ kfp = open(key_file, "r")
+ for line in kfp:
+ if line[0] == ";":
+ continue
+ tokens = line.split()
+ if not tokens:
+ continue
+
+ if tokens[1].lower() in ("in", "ch", "hs"):
+ septoken = 3
+ self.ttl = keyttl
+ else:
+ septoken = 4
+ self.ttl = int(tokens[1]) if not keyttl else keyttl
+
+ if (int(tokens[septoken]) & 0x1) == 1:
+ self.sep = True
+ else:
+ self.sep = False
+ kfp.close()
+
+ pfp = open(private_file, "r")
+
+ self.metadata = dict()
+ self._changed = dict()
+ self._delete = dict()
+ self._times = dict()
+ self._fmttime = dict()
+ self._timestamps = dict()
+ self._original = dict()
+ self._origttl = None
+
+ for line in pfp:
+ line = line.strip()
+ if not line or line[0] in ("!#"):
+ continue
+ punctuation = [line.find(c) for c in ":= "] + [len(line)]
+ found = min([pos for pos in punctuation if pos != -1])
+ name = line[:found].rstrip()
+ value = line[found:].lstrip(":= ").rstrip()
+ self.metadata[name] = value
+
+ for prop in dnskey._PROPS:
+ self._changed[prop] = False
+ if prop in self.metadata:
+ t = self.parsetime(self.metadata[prop])
+ self._times[prop] = t
+ self._fmttime[prop] = self.formattime(t)
+ self._timestamps[prop] = self.epochfromtime(t)
+ self._original[prop] = self._timestamps[prop]
+ else:
+ self._times[prop] = None
+ self._fmttime[prop] = None
+ self._timestamps[prop] = None
+ self._original[prop] = None
+
+ pfp.close()
+
+ def commit(self, settime_bin, **kwargs):
+ quiet = kwargs.get("quiet", False)
+ cmd = []
+ first = True
+
+ if self._origttl is not None:
+ cmd += ["-L", str(self.ttl)]
+
+ for prop, opt in zip(dnskey._PROPS, dnskey._OPTS):
+ if not opt or not self._changed[prop]:
+ continue
+
+ delete = False
+ if prop in self._delete and self._delete[prop]:
+ delete = True
+
+ when = "none" if delete else self._fmttime[prop]
+ cmd += [opt, when]
+ first = False
+
+ if cmd:
+ fullcmd = (
+ [settime_bin, "-K", self._dir]
+ + cmd
+ + [
+ self.keystr,
+ ]
+ )
+ if not quiet:
+ print("# " + " ".join(fullcmd))
+ try:
+ p = Popen(fullcmd, stdout=PIPE, stderr=PIPE)
+ stdout, stderr = p.communicate()
+ if stderr:
+ raise Exception(str(stderr))
+ except Exception as e:
+ raise Exception("unable to run %s: %s" % (settime_bin, str(e)))
+ self._origttl = None
+ for prop in dnskey._PROPS:
+ self._original[prop] = self._timestamps[prop]
+ self._changed[prop] = False
+
+ @classmethod
+ def generate(
+ cls,
+ keygen_bin,
+ randomdev,
+ keys_dir,
+ name,
+ alg,
+ keysize,
+ sep,
+ ttl,
+ publish=None,
+ activate=None,
+ **kwargs
+ ):
+ quiet = kwargs.get("quiet", False)
+
+ keygen_cmd = [keygen_bin, "-q", "-K", keys_dir, "-L", str(ttl)]
+
+ if randomdev:
+ keygen_cmd += ["-r", randomdev]
+
+ if sep:
+ keygen_cmd.append("-fk")
+
+ if alg:
+ keygen_cmd += ["-a", alg]
+
+ if keysize:
+ keygen_cmd += ["-b", str(keysize)]
+
+ if publish:
+ t = dnskey.timefromepoch(publish)
+ keygen_cmd += ["-P", dnskey.formattime(t)]
+
+ if activate:
+ t = dnskey.timefromepoch(activate)
+ keygen_cmd += ["-A", dnskey.formattime(activate)]
+
+ keygen_cmd.append(name)
+
+ if not quiet:
+ print("# " + " ".join(keygen_cmd))
+
+ p = Popen(keygen_cmd, stdout=PIPE, stderr=PIPE)
+ stdout, stderr = p.communicate()
+ if stderr:
+ raise Exception("unable to generate key: " + str(stderr))
+
+ try:
+ keystr = stdout.splitlines()[0].decode("ascii")
+ newkey = dnskey(keystr, keys_dir, ttl)
+ return newkey
+ except Exception as e:
+ raise Exception("unable to parse generated key: %s" % str(e))
+
+ def generate_successor(self, keygen_bin, randomdev, prepublish, **kwargs):
+ quiet = kwargs.get("quiet", False)
+
+ if not self.inactive():
+ raise Exception("predecessor key %s has no inactive date" % self)
+
+ keygen_cmd = [keygen_bin, "-q", "-K", self._dir, "-S", self.keystr]
+
+ if self.ttl:
+ keygen_cmd += ["-L", str(self.ttl)]
+
+ if randomdev:
+ keygen_cmd += ["-r", randomdev]
+
+ if prepublish:
+ keygen_cmd += ["-i", str(prepublish)]
+
+ if not quiet:
+ print("# " + " ".join(keygen_cmd))
+
+ p = Popen(keygen_cmd, stdout=PIPE, stderr=PIPE)
+ stdout, stderr = p.communicate()
+ if stderr:
+ raise Exception("unable to generate key: " + stderr)
+
+ try:
+ keystr = stdout.splitlines()[0].decode("ascii")
+ newkey = dnskey(keystr, self._dir, self.ttl)
+ return newkey
+ except:
+ raise Exception("unable to generate successor for key %s" % self)
+
+ @staticmethod
+ def algstr(alg):
+ name = None
+ if alg in range(len(dnskey._ALGNAMES)):
+ name = dnskey._ALGNAMES[alg]
+ return name if name else ("%03d" % alg)
+
+ @staticmethod
+ def algnum(alg):
+ if not alg:
+ return None
+ alg = alg.upper()
+ try:
+ return dnskey._ALGNAMES.index(alg)
+ except ValueError:
+ return None
+
+ def algname(self, alg=None):
+ return self.algstr(alg or self.alg)
+
+ @staticmethod
+ def timefromepoch(secs):
+ return time.gmtime(secs)
+
+ @staticmethod
+ def parsetime(string):
+ return time.strptime(string, "%Y%m%d%H%M%S")
+
+ @staticmethod
+ def epochfromtime(t):
+ return calendar.timegm(t)
+
+ @staticmethod
+ def formattime(t):
+ return time.strftime("%Y%m%d%H%M%S", t)
+
+ def setmeta(self, prop, secs, now, **kwargs):
+ force = kwargs.get("force", False)
+
+ if self._timestamps[prop] == secs:
+ return
+
+ if (
+ self._original[prop] is not None
+ and self._original[prop] < now
+ and not force
+ ):
+ raise TimePast(self, prop, self._original[prop])
+
+ if secs is None:
+ self._changed[prop] = False if self._original[prop] is None else True
+
+ self._delete[prop] = True
+ self._timestamps[prop] = None
+ self._times[prop] = None
+ self._fmttime[prop] = None
+ return
+
+ t = self.timefromepoch(secs)
+ self._timestamps[prop] = secs
+ self._times[prop] = t
+ self._fmttime[prop] = self.formattime(t)
+ self._changed[prop] = (
+ False if self._original[prop] == self._timestamps[prop] else True
+ )
+
+ def gettime(self, prop):
+ return self._times[prop]
+
+ def getfmttime(self, prop):
+ return self._fmttime[prop]
+
+ def gettimestamp(self, prop):
+ return self._timestamps[prop]
+
+ def created(self):
+ return self._timestamps["Created"]
+
+ def syncpublish(self):
+ return self._timestamps["SyncPublish"]
+
+ def setsyncpublish(self, secs, now=time.time(), **kwargs):
+ self.setmeta("SyncPublish", secs, now, **kwargs)
+
+ def publish(self):
+ return self._timestamps["Publish"]
+
+ def setpublish(self, secs, now=time.time(), **kwargs):
+ self.setmeta("Publish", secs, now, **kwargs)
+
+ def activate(self):
+ return self._timestamps["Activate"]
+
+ def setactivate(self, secs, now=time.time(), **kwargs):
+ self.setmeta("Activate", secs, now, **kwargs)
+
+ def revoke(self):
+ return self._timestamps["Revoke"]
+
+ def setrevoke(self, secs, now=time.time(), **kwargs):
+ self.setmeta("Revoke", secs, now, **kwargs)
+
+ def inactive(self):
+ return self._timestamps["Inactive"]
+
+ def setinactive(self, secs, now=time.time(), **kwargs):
+ self.setmeta("Inactive", secs, now, **kwargs)
+
+ def delete(self):
+ return self._timestamps["Delete"]
+
+ def setdelete(self, secs, now=time.time(), **kwargs):
+ self.setmeta("Delete", secs, now, **kwargs)
+
+ def syncdelete(self):
+ return self._timestamps["SyncDelete"]
+
+ def setsyncdelete(self, secs, now=time.time(), **kwargs):
+ self.setmeta("SyncDelete", secs, now, **kwargs)
+
+ def setttl(self, ttl):
+ if ttl is None or self.ttl == ttl:
+ return
+ elif self._origttl is None:
+ self._origttl = self.ttl
+ self.ttl = ttl
+ elif self._origttl == ttl:
+ self._origttl = None
+ self.ttl = ttl
+ else:
+ self.ttl = ttl
+
+ def keytype(self):
+ return "KSK" if self.sep else "ZSK"
+
+ def __str__(self):
+ return "%s/%s/%05d" % (self.name, self.algname(), self.keyid)
+
+ def __repr__(self):
+ return "%s/%s/%05d (%s)" % (
+ self.name,
+ self.algname(),
+ self.keyid,
+ ("KSK" if self.sep else "ZSK"),
+ )
+
+ def date(self):
+ return self.activate() or self.publish() or self.created()
+
+ # keys are sorted first by zone name, then by algorithm. within
+ # the same name/algorithm, they are sorted according to their
+ # 'date' value: the activation date if set, OR the publication
+ # if set, OR the creation date.
+ def __lt__(self, other):
+ if self.name != other.name:
+ return self.name < other.name
+ if self.alg != other.alg:
+ return self.alg < other.alg
+ return self.date() < other.date()
+
+ def check_prepub(self, output=None):
+ def noop(*args, **kwargs):
+ pass
+
+ if not output:
+ output = noop
+
+ now = int(time.time())
+ a = self.activate()
+ p = self.publish()
+
+ if not a:
+ return False
+
+ if not p:
+ if a > now:
+ output(
+ "WARNING: Key %s is scheduled for\n"
+ "\t activation but not for publication." % repr(self)
+ )
+ return False
+
+ if p <= now and a <= now:
+ return True
+
+ if p == a:
+ output(
+ "WARNING: %s is scheduled to be\n"
+ "\t published and activated at the same time. This\n"
+ "\t could result in a coverage gap if the zone was\n"
+ "\t previously signed. Activation should be at least\n"
+ "\t %s after publication."
+ % (repr(self), dnskey.duration(self.ttl) or "one DNSKEY TTL")
+ )
+ return True
+
+ if a < p:
+ output("WARNING: Key %s is active before it is published" % repr(self))
+ return False
+
+ if self.ttl is not None and a - p < self.ttl:
+ output(
+ "WARNING: Key %s is activated too soon\n"
+ "\t after publication; this could result in coverage \n"
+ "\t gaps due to resolver caches containing old data.\n"
+ "\t Activation should be at least %s after\n"
+ "\t publication."
+ % (repr(self), dnskey.duration(self.ttl) or "one DNSKEY TTL")
+ )
+ return False
+
+ return True
+
+ def check_postpub(self, output=None, timespan=None):
+ def noop(*args, **kwargs):
+ pass
+
+ if output is None:
+ output = noop
+
+ if timespan is None:
+ timespan = self.ttl
+
+ if timespan is None:
+ output("WARNING: Key %s using default TTL." % repr(self))
+ timespan = 60 * 60 * 24
+
+ now = time.time()
+ d = self.delete()
+ i = self.inactive()
+
+ if not d:
+ return False
+
+ if not i:
+ if d > now:
+ output(
+ "WARNING: Key %s is scheduled for\n"
+ "\t deletion but not for inactivation." % repr(self)
+ )
+ return False
+
+ if d < now and i < now:
+ return True
+
+ if d < i:
+ output(
+ "WARNING: Key %s is scheduled for\n"
+ "\t deletion before inactivation." % repr(self)
+ )
+ return False
+
+ if d - i < timespan:
+ output(
+ "WARNING: Key %s scheduled for\n"
+ "\t deletion too soon after deactivation; this may \n"
+ "\t result in coverage gaps due to resolver caches\n"
+ "\t containing old data. Deletion should be at least\n"
+ "\t %s after inactivation." % (repr(self), dnskey.duration(timespan))
+ )
+ return False
+
+ return True
+
+ @staticmethod
+ def duration(secs):
+ if not secs:
+ return None
+
+ units = [
+ ("year", 60 * 60 * 24 * 365),
+ ("month", 60 * 60 * 24 * 30),
+ ("day", 60 * 60 * 24),
+ ("hour", 60 * 60),
+ ("minute", 60),
+ ("second", 1),
+ ]
+
+ output = []
+ for unit in units:
+ v, secs = secs // unit[1], secs % unit[1]
+ if v > 0:
+ output.append("%d %s%s" % (v, unit[0], "s" if v > 1 else ""))
+
+ return ", ".join(output)
diff --git a/bin/python/isc/eventlist.py.in b/bin/python/isc/eventlist.py.in
new file mode 100644
index 0000000..f2f26aa
--- /dev/null
+++ b/bin/python/isc/eventlist.py.in
@@ -0,0 +1,178 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+from collections import defaultdict
+from .dnskey import *
+from .keydict import *
+from .keyevent import *
+
+
+class eventlist:
+ _K = defaultdict(lambda: defaultdict(list))
+ _Z = defaultdict(lambda: defaultdict(list))
+ _zones = set()
+ _kdict = None
+
+ def __init__(self, kdict):
+ properties = [
+ "SyncPublish",
+ "Publish",
+ "SyncDelete",
+ "Activate",
+ "Inactive",
+ "Delete",
+ ]
+ self._kdict = kdict
+ for zone in kdict.zones():
+ self._zones.add(zone)
+ for alg, keys in kdict[zone].items():
+ for k in keys.values():
+ for prop in properties:
+ t = k.gettime(prop)
+ if not t:
+ continue
+ e = keyevent(prop, k, t)
+ if k.sep:
+ self._K[zone][alg].append(e)
+ else:
+ self._Z[zone][alg].append(e)
+
+ self._K[zone][alg] = sorted(
+ self._K[zone][alg], key=lambda event: event.when
+ )
+ self._Z[zone][alg] = sorted(
+ self._Z[zone][alg], key=lambda event: event.when
+ )
+
+ # scan events per zone, algorithm, and key type, in order of
+ # occurrence, noting inconsistent states when found
+ def coverage(self, zone, keytype, until, output=None):
+ def noop(*args, **kwargs):
+ pass
+
+ if not output:
+ output = noop
+
+ no_zsk = True if (keytype and keytype == "KSK") else False
+ no_ksk = True if (keytype and keytype == "ZSK") else False
+ kok = zok = True
+ found = False
+
+ if zone and not zone in self._zones:
+ output("ERROR: No key events found for %s" % zone)
+ return False
+
+ if zone:
+ found = True
+ if not no_ksk:
+ kok = self.checkzone(zone, "KSK", until, output)
+ if not no_zsk:
+ zok = self.checkzone(zone, "ZSK", until, output)
+ else:
+ for z in self._zones:
+ if not no_ksk and z in self._K.keys():
+ found = True
+ kok = self.checkzone(z, "KSK", until, output)
+ if not no_zsk and z in self._Z.keys():
+ found = True
+ zok = self.checkzone(z, "ZSK", until, output)
+
+ if not found:
+ output("ERROR: No key events found")
+ return False
+
+ return kok and zok
+
+ def checkzone(self, zone, keytype, until, output):
+ allok = True
+ if keytype == "KSK":
+ kz = self._K[zone]
+ else:
+ kz = self._Z[zone]
+
+ for alg in kz.keys():
+ output(
+ "Checking scheduled %s events for zone %s, "
+ "algorithm %s..." % (keytype, zone, dnskey.algstr(alg))
+ )
+ ok = eventlist.checkset(kz[alg], keytype, until, output)
+ if ok:
+ output("No errors found")
+ allok = allok and ok
+
+ return allok
+
+ @staticmethod
+ def showset(eventset, output):
+ if not eventset:
+ return
+ output(" " + eventset[0].showtime() + ":", skip=False)
+ for event in eventset:
+ output(" %s: %s" % (event.what, repr(event.key)), skip=False)
+
+ @staticmethod
+ def checkset(eventset, keytype, until, output):
+ groups = list()
+ group = list()
+
+ # collect up all events that have the same time
+ eventsfound = False
+ for event in eventset:
+ # we found an event
+ eventsfound = True
+
+ # add event to current group
+ if not group or group[0].when == event.when:
+ group.append(event)
+
+ # if we're at the end of the list, we're done. if
+ # we've found an event with a later time, start a new group
+ if group[0].when != event.when:
+ groups.append(group)
+ group = list()
+ group.append(event)
+
+ if group:
+ groups.append(group)
+
+ if not eventsfound:
+ output("ERROR: No %s events found" % keytype)
+ return False
+
+ active = published = None
+ for group in groups:
+ if until and calendar.timegm(group[0].when) > until:
+ output(
+ "Ignoring events after %s"
+ % time.strftime("%a %b %d %H:%M:%S UTC %Y", time.gmtime(until))
+ )
+ return True
+
+ for event in group:
+ (active, published) = event.status(active, published)
+
+ eventlist.showset(group, output)
+
+ # and then check for inconsistencies:
+ if not active:
+ output("ERROR: No %s's are active after this event" % keytype)
+ return False
+ elif not published:
+ output("ERROR: No %s's are published after this event" % keytype)
+ return False
+ elif not published.intersection(active):
+ output(
+ "ERROR: No %s's are both active and published "
+ "after this event" % keytype
+ )
+ return False
+
+ return True
diff --git a/bin/python/isc/keydict.py.in b/bin/python/isc/keydict.py.in
new file mode 100644
index 0000000..723a32a
--- /dev/null
+++ b/bin/python/isc/keydict.py.in
@@ -0,0 +1,87 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+from collections import defaultdict
+from . import dnskey
+import os
+import glob
+
+
+########################################################################
+# Class keydict
+########################################################################
+class keydict:
+ """A dictionary of keys, indexed by name, algorithm, and key id"""
+
+ _keydict = defaultdict(lambda: defaultdict(dict))
+ _defttl = None
+ _missing = []
+
+ def __init__(self, dp=None, **kwargs):
+ self._defttl = kwargs.get("keyttl", None)
+ zones = kwargs.get("zones", None)
+
+ if not zones:
+ path = kwargs.get("path", None) or "."
+ self.readall(path)
+ else:
+ for zone in zones:
+ if "path" in kwargs and kwargs["path"] is not None:
+ path = kwargs["path"]
+ else:
+ path = dp and dp.policy(zone).directory or "."
+ if not self.readone(path, zone):
+ self._missing.append(zone)
+
+ def readall(self, path):
+ files = glob.glob(os.path.join(path, "*.private"))
+
+ for infile in files:
+ key = dnskey(infile, path, self._defttl)
+ self._keydict[key.name][key.alg][key.keyid] = key
+
+ def readone(self, path, zone):
+ if not zone.endswith("."):
+ zone += "."
+ match = "K" + zone + "+*.private"
+ files = glob.glob(os.path.join(path, match))
+
+ found = False
+ for infile in files:
+ key = dnskey(infile, path, self._defttl)
+ if key.fullname != zone: # shouldn't ever happen
+ continue
+ keyname = key.name if zone != "." else "."
+ self._keydict[keyname][key.alg][key.keyid] = key
+ found = True
+
+ return found
+
+ def __iter__(self):
+ for zone, algorithms in self._keydict.items():
+ for alg, keys in algorithms.items():
+ for key in keys.values():
+ yield key
+
+ def __getitem__(self, name):
+ return self._keydict[name]
+
+ def zones(self):
+ return self._keydict.keys()
+
+ def algorithms(self, zone):
+ return self._keydict[zone].keys()
+
+ def keys(self, zone, alg):
+ return self._keydict[zone][alg].keys()
+
+ def missing(self):
+ return self._missing
diff --git a/bin/python/isc/keyevent.py.in b/bin/python/isc/keyevent.py.in
new file mode 100644
index 0000000..d01c97a
--- /dev/null
+++ b/bin/python/isc/keyevent.py.in
@@ -0,0 +1,80 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+import time
+
+
+########################################################################
+# Class keyevent
+########################################################################
+class keyevent:
+ """A discrete key event, e.g., Publish, Activate, Inactive, Delete,
+ etc. Stores the date of the event, and identifying information
+ about the key to which the event will occur."""
+
+ def __init__(self, what, key, when=None):
+ self.what = what
+ self.when = when or key.gettime(what)
+ self.key = key
+ self.sep = key.sep
+ self.zone = key.name
+ self.alg = key.alg
+ self.keyid = key.keyid
+
+ def __repr__(self):
+ return repr((self.when, self.what, self.keyid, self.sep, self.zone, self.alg))
+
+ def showtime(self):
+ return time.strftime("%a %b %d %H:%M:%S UTC %Y", self.when)
+
+ # update sets of active and published keys, based on
+ # the contents of this keyevent
+ def status(self, active, published, output=None):
+ def noop(*args, **kwargs):
+ pass
+
+ if not output:
+ output = noop
+
+ if not active:
+ active = set()
+ if not published:
+ published = set()
+
+ if self.what == "Activate":
+ active.add(self.keyid)
+ elif self.what == "Publish":
+ published.add(self.keyid)
+ elif self.what == "Inactive":
+ if self.keyid not in active:
+ output(
+ "\tWARNING: %s scheduled to become inactive "
+ "before it is active" % repr(self.key)
+ )
+ else:
+ active.remove(self.keyid)
+ elif self.what == "Delete":
+ if self.keyid in published:
+ published.remove(self.keyid)
+ else:
+ output(
+ "WARNING: key %s is scheduled for deletion "
+ "before it is published" % repr(self.key)
+ )
+ elif self.what == "Revoke":
+ # We don't need to worry about the logic of this one;
+ # just stop counting this key as either active or published
+ if self.keyid in published:
+ published.remove(self.keyid)
+ if self.keyid in active:
+ active.remove(self.keyid)
+
+ return active, published
diff --git a/bin/python/isc/keymgr.py.in b/bin/python/isc/keymgr.py.in
new file mode 100644
index 0000000..67fe4c7
--- /dev/null
+++ b/bin/python/isc/keymgr.py.in
@@ -0,0 +1,207 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+from __future__ import print_function
+import os, sys, argparse, glob, re, time, calendar, pprint
+from collections import defaultdict
+
+prog = "dnssec-keymgr"
+
+from isc import dnskey, keydict, keyseries, policy, parsetab, utils
+
+
+############################################################################
+# print a fatal error and exit
+############################################################################
+def fatal(*args, **kwargs):
+ print(*args, **kwargs)
+ sys.exit(1)
+
+
+############################################################################
+# find the location of an external command
+############################################################################
+def set_path(command, default=None):
+ """find the location of a specified command. If a default is supplied,
+ exists and it's an executable, we use it; otherwise we search PATH
+ for an alternative.
+ :param command: command to look for
+ :param default: default value to use
+ :return: PATH with the location of a suitable binary
+ """
+ fpath = default
+ if not fpath or not os.path.isfile(fpath) or not os.access(fpath, os.X_OK):
+ path = os.environ["PATH"]
+ if not path:
+ path = os.path.defpath
+ for directory in path.split(os.pathsep):
+ fpath = directory + os.sep + command
+ if os.path.isfile(fpath) and os.access(fpath, os.X_OK):
+ break
+ fpath = None
+
+ return fpath
+
+
+############################################################################
+# parse arguments
+############################################################################
+def parse_args():
+ """Read command line arguments, returns 'args' object
+ :return: args object properly prepared
+ """
+
+ keygen = set_path(
+ "dnssec-keygen", os.path.join(utils.prefix("sbin"), "dnssec-keygen")
+ )
+ settime = set_path(
+ "dnssec-settime", os.path.join(utils.prefix("sbin"), "dnssec-settime")
+ )
+
+ parser = argparse.ArgumentParser(
+ description=prog + ": schedule "
+ "DNSSEC key rollovers according to a "
+ "pre-defined policy"
+ )
+
+ parser.add_argument(
+ "zone",
+ type=str,
+ nargs="*",
+ default=None,
+ help="Zone(s) to which the policy should be applied "
+ + "(default: all zones in the directory)",
+ )
+ parser.add_argument(
+ "-K", dest="path", type=str, help="Directory containing keys", metavar="dir"
+ )
+ parser.add_argument(
+ "-c", dest="policyfile", type=str, help="Policy definition file", metavar="file"
+ )
+ parser.add_argument(
+ "-g",
+ dest="keygen",
+ default=keygen,
+ type=str,
+ help="Path to 'dnssec-keygen'",
+ metavar="path",
+ )
+ parser.add_argument(
+ "-r",
+ dest="randomdev",
+ type=str,
+ default=None,
+ help="DEPRECATED",
+ metavar="path",
+ )
+ parser.add_argument(
+ "-s",
+ dest="settime",
+ default=settime,
+ type=str,
+ help="Path to 'dnssec-settime'",
+ metavar="path",
+ )
+ parser.add_argument(
+ "-k",
+ dest="no_zsk",
+ action="store_true",
+ default=False,
+ help="Only apply policy to key-signing keys (KSKs)",
+ )
+ parser.add_argument(
+ "-z",
+ dest="no_ksk",
+ action="store_true",
+ default=False,
+ help="Only apply policy to zone-signing keys (ZSKs)",
+ )
+ parser.add_argument(
+ "-f",
+ "--force",
+ dest="force",
+ action="store_true",
+ default=False,
+ help="Force updates to key events " + "even if they are in the past",
+ )
+ parser.add_argument(
+ "-q",
+ "--quiet",
+ dest="quiet",
+ action="store_true",
+ default=False,
+ help="Update keys silently",
+ )
+ parser.add_argument("-v", "--version", action="version", version=utils.version)
+
+ args = parser.parse_args()
+
+ if args.randomdev:
+ fatal("ERROR: -r option has been deprecated.")
+
+ if args.no_zsk and args.no_ksk:
+ fatal("ERROR: -z and -k cannot be used together.")
+
+ if args.keygen is None:
+ fatal("ERROR: dnssec-keygen not found")
+
+ if args.settime is None:
+ fatal("ERROR: dnssec-settime not found")
+
+ # if a policy file was specified, check that it exists.
+ # if not, use the default file, unless it doesn't exist
+ if args.policyfile is not None:
+ if not os.path.exists(args.policyfile):
+ fatal('ERROR: Policy file "%s" not found' % args.policyfile)
+ else:
+ args.policyfile = os.path.join(utils.sysconfdir, "dnssec-policy.conf")
+ if not os.path.exists(args.policyfile):
+ args.policyfile = None
+
+ return args
+
+
+############################################################################
+# main
+############################################################################
+def main():
+ args = parse_args()
+
+ # As we may have specific locations for the binaries, we put that info
+ # into a context object that can be passed around
+ context = {
+ "keygen_path": args.keygen,
+ "settime_path": args.settime,
+ "keys_path": args.path,
+ "randomdev": args.randomdev,
+ }
+
+ try:
+ dp = policy.dnssec_policy(args.policyfile)
+ except Exception as e:
+ fatal("Unable to load DNSSEC policy: " + str(e))
+
+ try:
+ kd = keydict(dp, path=args.path, zones=args.zone)
+ except Exception as e:
+ fatal("Unable to build key dictionary: " + str(e))
+
+ try:
+ ks = keyseries(kd, context=context)
+ except Exception as e:
+ fatal("Unable to build key series: " + str(e))
+
+ try:
+ ks.enforce_policy(
+ dp, ksk=args.no_zsk, zsk=args.no_ksk, force=args.force, quiet=args.quiet
+ )
+ except Exception as e:
+ fatal("Unable to apply policy: " + str(e))
diff --git a/bin/python/isc/keyseries.py.in b/bin/python/isc/keyseries.py.in
new file mode 100644
index 0000000..e75f82b
--- /dev/null
+++ b/bin/python/isc/keyseries.py.in
@@ -0,0 +1,232 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+from collections import defaultdict
+from .dnskey import *
+from .keydict import *
+from .keyevent import *
+from .policy import *
+import time
+
+
+class keyseries:
+ _K = defaultdict(lambda: defaultdict(list))
+ _Z = defaultdict(lambda: defaultdict(list))
+ _zones = set()
+ _kdict = None
+ _context = None
+
+ def __init__(self, kdict, now=time.time(), context=None):
+ self._kdict = kdict
+ self._context = context
+ self._zones = set(kdict.missing())
+
+ for zone in kdict.zones():
+ self._zones.add(zone)
+ for alg, keys in kdict[zone].items():
+ for k in keys.values():
+ if k.sep:
+ if not (k.delete() and k.delete() < now):
+ self._K[zone][alg].append(k)
+ else:
+ if not (k.delete() and k.delete() < now):
+ self._Z[zone][alg].append(k)
+
+ self._K[zone][alg].sort()
+ self._Z[zone][alg].sort()
+
+ def __iter__(self):
+ for zone in self._zones:
+ for collection in [self._K, self._Z]:
+ if zone not in collection:
+ continue
+ for alg, keys in collection[zone].items():
+ for key in keys:
+ yield key
+
+ def dump(self):
+ for k in self:
+ print("%s" % repr(k))
+
+ def fixseries(self, keys, policy, now, **kwargs):
+ force = kwargs.get("force", False)
+ if not keys:
+ return
+
+ # handle the first key
+ key = keys[0]
+ if key.sep:
+ rp = policy.ksk_rollperiod
+ prepub = policy.ksk_prepublish or (30 * 86400)
+ postpub = policy.ksk_postpublish or (30 * 86400)
+ else:
+ rp = policy.zsk_rollperiod
+ prepub = policy.zsk_prepublish or (30 * 86400)
+ postpub = policy.zsk_postpublish or (30 * 86400)
+
+ # the first key should be published and active
+ p = key.publish()
+ a = key.activate()
+ if not p or p > now:
+ key.setpublish(now)
+ p = now
+ if not a or a > now:
+ key.setactivate(now)
+ a = now
+
+ i = key.inactive()
+ fudge = 300
+ if not rp:
+ key.setinactive(None, **kwargs)
+ key.setdelete(None, **kwargs)
+ elif not i or a + rp != i:
+ if not i and a + rp > now + prepub + fudge:
+ key.setinactive(a + rp, **kwargs)
+ key.setdelete(a + rp + postpub, **kwargs)
+ elif not i:
+ key.setinactive(now + prepub + fudge, **kwargs)
+ key.setdelete(now + prepub + postpub + fudge, **kwargs)
+ elif i < now:
+ pass
+ elif a + rp > i:
+ key.setinactive(a + rp, **kwargs)
+ key.setdelete(a + rp + postpub, **kwargs)
+ elif a + rp > now + prepub + fudge:
+ key.setinactive(a + rp, **kwargs)
+ key.setdelete(a + rp + postpub, **kwargs)
+ else:
+ key.setinactive(now + prepub + fudge, **kwargs)
+ key.setdelete(now + prepub + postpub + fudge, **kwargs)
+ else:
+ d = key.delete()
+ if not d or i + postpub > now + fudge:
+ key.setdelete(i + postpub, **kwargs)
+ elif not d:
+ key.setdelete(now + postpub + fudge, **kwargs)
+ elif d < now + fudge:
+ pass
+ elif d < i + postpub:
+ key.setdelete(i + postpub, **kwargs)
+
+ if policy.keyttl != key.ttl:
+ key.setttl(policy.keyttl)
+
+ # handle all the subsequent keys
+ prev = key
+ for key in keys[1:]:
+ # if no rollperiod, then all keys after the first in
+ # the series kept inactive.
+ # (XXX: we need to change this to allow standby keys)
+ if not rp:
+ key.setpublish(None, **kwargs)
+ key.setactivate(None, **kwargs)
+ key.setinactive(None, **kwargs)
+ key.setdelete(None, **kwargs)
+ if policy.keyttl != key.ttl:
+ key.setttl(policy.keyttl)
+ continue
+
+ # otherwise, ensure all dates are set correctly based on
+ # the initial key
+ a = prev.inactive()
+ p = a - prepub
+ key.setactivate(a, **kwargs)
+ key.setpublish(p, **kwargs)
+ key.setinactive(a + rp, **kwargs)
+ key.setdelete(a + rp + postpub, **kwargs)
+ prev.setdelete(a + postpub, **kwargs)
+ if policy.keyttl != key.ttl:
+ key.setttl(policy.keyttl)
+ prev = key
+
+ # if we haven't got sufficient coverage, create successor key(s)
+ while rp and prev.inactive() and prev.inactive() < now + policy.coverage:
+ # commit changes to predecessor: a successor can only be
+ # generated if Inactive has been set in the predecessor key
+ prev.commit(self._context["settime_path"], **kwargs)
+ key = prev.generate_successor(
+ self._context["keygen_path"],
+ self._context["randomdev"],
+ prepub,
+ **kwargs
+ )
+
+ key.setinactive(key.activate() + rp, **kwargs)
+ key.setdelete(key.inactive() + postpub, **kwargs)
+ keys.append(key)
+ prev = key
+
+ # last key? we already know we have sufficient coverage now, so
+ # disable the inactivation of the final key (if it was set),
+ # ensuring that if dnssec-keymgr isn't run again, the last key
+ # in the series will at least remain usable.
+ prev.setinactive(None, **kwargs)
+ prev.setdelete(None, **kwargs)
+
+ # commit changes
+ for key in keys:
+ key.commit(self._context["settime_path"], **kwargs)
+
+ def enforce_policy(self, policies, now=time.time(), **kwargs):
+ # If zones is provided as a parameter, use that list.
+ # If not, use what we have in this object
+ zones = kwargs.get("zones", self._zones)
+ keys_dir = kwargs.get("dir", self._context.get("keys_path", None))
+ force = kwargs.get("force", False)
+
+ for zone in zones:
+ collections = []
+ policy = policies.policy(zone)
+ keys_dir = keys_dir or policy.directory or "."
+ alg = policy.algorithm
+ algnum = dnskey.algnum(alg)
+ if "ksk" not in kwargs or not kwargs["ksk"]:
+ if len(self._Z[zone][algnum]) == 0:
+ k = dnskey.generate(
+ self._context["keygen_path"],
+ self._context["randomdev"],
+ keys_dir,
+ zone,
+ alg,
+ policy.zsk_keysize,
+ False,
+ policy.keyttl or 3600,
+ **kwargs
+ )
+ self._Z[zone][algnum].append(k)
+ collections.append(self._Z[zone])
+
+ if "zsk" not in kwargs or not kwargs["zsk"]:
+ if len(self._K[zone][algnum]) == 0:
+ k = dnskey.generate(
+ self._context["keygen_path"],
+ self._context["randomdev"],
+ keys_dir,
+ zone,
+ alg,
+ policy.ksk_keysize,
+ True,
+ policy.keyttl or 3600,
+ **kwargs
+ )
+ self._K[zone][algnum].append(k)
+ collections.append(self._K[zone])
+
+ for collection in collections:
+ for algorithm, keys in collection.items():
+ if algorithm != algnum:
+ continue
+ try:
+ self.fixseries(keys, policy, now, **kwargs)
+ except Exception as e:
+ raise Exception(
+ "%s/%s: %s" % (zone, dnskey.algstr(algnum), str(e))
+ )
diff --git a/bin/python/isc/keyzone.py.in b/bin/python/isc/keyzone.py.in
new file mode 100644
index 0000000..c0c7043
--- /dev/null
+++ b/bin/python/isc/keyzone.py.in
@@ -0,0 +1,59 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+import os
+import sys
+import re
+from subprocess import Popen, PIPE
+
+
+########################################################################
+# Exceptions
+########################################################################
+class KeyZoneException(Exception):
+ pass
+
+
+########################################################################
+# class keyzone
+########################################################################
+class keyzone:
+ """reads a zone file to find data relevant to keys"""
+
+ def __init__(self, name, filename, czpath):
+ self.maxttl = None
+ self.keyttl = None
+
+ if not name:
+ return
+
+ if not czpath or not os.path.isfile(czpath) or not os.access(czpath, os.X_OK):
+ raise KeyZoneException('"named-compilezone" not found')
+ return
+
+ maxttl = keyttl = None
+
+ fp, _ = Popen(
+ [czpath, "-o", "-", name, filename], stdout=PIPE, stderr=PIPE
+ ).communicate()
+ for line in fp.splitlines():
+ if type(line) is not str:
+ line = line.decode("ascii")
+ if re.search("^[:space:]*;", line):
+ continue
+ fields = line.split()
+ if not maxttl or int(fields[1]) > maxttl:
+ maxttl = int(fields[1])
+ if fields[3] == "DNSKEY":
+ keyttl = int(fields[1])
+
+ self.keyttl = keyttl
+ self.maxttl = maxttl
diff --git a/bin/python/isc/policy.py.in b/bin/python/isc/policy.py.in
new file mode 100644
index 0000000..0fa231c
--- /dev/null
+++ b/bin/python/isc/policy.py.in
@@ -0,0 +1,761 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+import re
+import ply.lex as lex
+import ply.yacc as yacc
+from string import *
+from copy import copy
+
+
+############################################################################
+# PolicyLex: a lexer for the policy file syntax.
+############################################################################
+class PolicyLex:
+ reserved = (
+ "POLICY",
+ "ALGORITHM_POLICY",
+ "ZONE",
+ "ALGORITHM",
+ "DIRECTORY",
+ "KEYTTL",
+ "KEY_SIZE",
+ "ROLL_PERIOD",
+ "PRE_PUBLISH",
+ "POST_PUBLISH",
+ "COVERAGE",
+ "STANDBY",
+ "NONE",
+ )
+
+ tokens = reserved + (
+ "DATESUFFIX",
+ "KEYTYPE",
+ "ALGNAME",
+ "STR",
+ "QSTRING",
+ "NUMBER",
+ "LBRACE",
+ "RBRACE",
+ "SEMI",
+ )
+ reserved_map = {}
+
+ t_ignore = " \t"
+ t_ignore_olcomment = r"(//|\#).*"
+
+ t_LBRACE = r"\{"
+ t_RBRACE = r"\}"
+ t_SEMI = r";"
+
+ def t_newline(self, t):
+ r"\n+"
+ t.lexer.lineno += t.value.count("\n")
+
+ def t_comment(self, t):
+ r"/\*(.|\n)*?\*/"
+ t.lexer.lineno += t.value.count("\n")
+
+ def t_DATESUFFIX(self, t):
+ r"(?<=[0-9 \t])(y(?:ears|ear|ea|e)?|mo(?:nths|nth|nt|n)?|w(?:eeks|eek|ee|e)?|d(?:ays|ay|a)?|h(?:ours|our|ou|o)?|mi(?:nutes|nute|nut|nu|n)?|s(?:econds|econd|econ|eco|ec|e)?)\b"
+ t.value = (
+ re.match(r"(y|mo|w|d|h|mi|s)([a-z]*)", t.value, re.IGNORECASE)
+ .group(1)
+ .lower()
+ )
+ return t
+
+ def t_KEYTYPE(self, t):
+ r"\b(KSK|ZSK)\b"
+ t.value = t.value.upper()
+ return t
+
+ def t_ALGNAME(self, t):
+ r"\b(DH|ECC|RSASHA1|NSEC3RSASHA1|RSASHA256|RSASHA512|ECDSAP256SHA256|ECDSAP384SHA384|ED25519|ED448)\b"
+ t.value = t.value.upper()
+ return t
+
+ def t_STR(self, t):
+ r"[A-Za-z._-][\w._-]*"
+ t.type = self.reserved_map.get(t.value, "STR")
+ return t
+
+ def t_QSTRING(self, t):
+ r'"([^"\n]|(\\"))*"'
+ t.type = self.reserved_map.get(t.value, "QSTRING")
+ t.value = t.value[1:-1]
+ return t
+
+ def t_NUMBER(self, t):
+ r"\d+"
+ t.value = int(t.value)
+ return t
+
+ def t_error(self, t):
+ print("Illegal character '%s'" % t.value[0])
+ t.lexer.skip(1)
+
+ def __init__(self, **kwargs):
+ if "maketrans" in dir(str):
+ trans = str.maketrans("_", "-")
+ else:
+ trans = maketrans("_", "-")
+ for r in self.reserved:
+ self.reserved_map[r.lower().translate(trans)] = r
+ self.lexer = lex.lex(object=self, reflags=re.VERBOSE | re.IGNORECASE, **kwargs)
+
+ def test(self, text):
+ self.lexer.input(text)
+ while True:
+ t = self.lexer.token()
+ if not t:
+ break
+ print(t)
+
+
+############################################################################
+# Policy: this object holds a set of DNSSEC policy settings.
+############################################################################
+class Policy:
+ is_zone = False
+ is_alg = False
+ is_constructed = False
+ ksk_rollperiod = None
+ zsk_rollperiod = None
+ ksk_prepublish = None
+ zsk_prepublish = None
+ ksk_postpublish = None
+ zsk_postpublish = None
+ ksk_keysize = None
+ zsk_keysize = None
+ ksk_standby = None
+ zsk_standby = None
+ keyttl = None
+ coverage = None
+ directory = None
+ valid_key_sz_per_algo = {
+ "RSASHA1": [1024, 4096],
+ "NSEC3RSASHA1": [512, 4096],
+ "RSASHA256": [1024, 4096],
+ "RSASHA512": [1024, 4096],
+ "ECDSAP256SHA256": None,
+ "ECDSAP384SHA384": None,
+ "ED25519": None,
+ "ED448": None,
+ }
+
+ def __init__(self, name=None, algorithm=None, parent=None):
+ self.name = name
+ self.algorithm = algorithm
+ self.parent = parent
+ pass
+
+ def __repr__(self):
+ return (
+ "%spolicy %s:\n"
+ "\tinherits %s\n"
+ "\tdirectory %s\n"
+ "\talgorithm %s\n"
+ "\tcoverage %s\n"
+ "\tksk_keysize %s\n"
+ "\tzsk_keysize %s\n"
+ "\tksk_rollperiod %s\n"
+ "\tzsk_rollperiod %s\n"
+ "\tksk_prepublish %s\n"
+ "\tksk_postpublish %s\n"
+ "\tzsk_prepublish %s\n"
+ "\tzsk_postpublish %s\n"
+ "\tksk_standby %s\n"
+ "\tzsk_standby %s\n"
+ "\tkeyttl %s\n"
+ % (
+ (
+ self.is_constructed
+ and "constructed "
+ or self.is_zone
+ and "zone "
+ or self.is_alg
+ and "algorithm "
+ or ""
+ ),
+ self.name or "UNKNOWN",
+ self.parent and self.parent.name or "None",
+ self.directory and ('"' + str(self.directory) + '"') or "None",
+ self.algorithm or "None",
+ self.coverage and str(self.coverage) or "None",
+ self.ksk_keysize and str(self.ksk_keysize) or "None",
+ self.zsk_keysize and str(self.zsk_keysize) or "None",
+ self.ksk_rollperiod and str(self.ksk_rollperiod) or "None",
+ self.zsk_rollperiod and str(self.zsk_rollperiod) or "None",
+ self.ksk_prepublish and str(self.ksk_prepublish) or "None",
+ self.ksk_postpublish and str(self.ksk_postpublish) or "None",
+ self.zsk_prepublish and str(self.zsk_prepublish) or "None",
+ self.zsk_postpublish and str(self.zsk_postpublish) or "None",
+ self.ksk_standby and str(self.ksk_standby) or "None",
+ self.zsk_standby and str(self.zsk_standby) or "None",
+ self.keyttl and str(self.keyttl) or "None",
+ )
+ )
+
+ def __verify_size(self, key_size, size_range):
+ return size_range[0] <= key_size <= size_range[1]
+
+ def get_name(self):
+ return self.name
+
+ def constructed(self):
+ return self.is_constructed
+
+ def validate(self):
+ """Check if the values in the policy make sense
+ :return: True/False if the policy passes validation
+ """
+ if (
+ self.ksk_rollperiod
+ and self.ksk_prepublish is not None
+ and self.ksk_prepublish > self.ksk_rollperiod
+ ):
+ print(self.ksk_rollperiod)
+ return (
+ False,
+ (
+ "KSK pre-publish period (%d) exceeds rollover period %d"
+ % (self.ksk_prepublish, self.ksk_rollperiod)
+ ),
+ )
+
+ if (
+ self.ksk_rollperiod
+ and self.ksk_postpublish is not None
+ and self.ksk_postpublish > self.ksk_rollperiod
+ ):
+ return (
+ False,
+ (
+ "KSK post-publish period (%d) exceeds rollover period %d"
+ % (self.ksk_postpublish, self.ksk_rollperiod)
+ ),
+ )
+
+ if (
+ self.zsk_rollperiod
+ and self.zsk_prepublish is not None
+ and self.zsk_prepublish >= self.zsk_rollperiod
+ ):
+ return (
+ False,
+ (
+ "ZSK pre-publish period (%d) exceeds rollover period %d"
+ % (self.zsk_prepublish, self.zsk_rollperiod)
+ ),
+ )
+
+ if (
+ self.zsk_rollperiod
+ and self.zsk_postpublish is not None
+ and self.zsk_postpublish >= self.zsk_rollperiod
+ ):
+ return (
+ False,
+ (
+ "ZSK post-publish period (%d) exceeds rollover period %d"
+ % (self.zsk_postpublish, self.zsk_rollperiod)
+ ),
+ )
+
+ if (
+ self.ksk_rollperiod
+ and self.ksk_prepublish
+ and self.ksk_postpublish
+ and self.ksk_prepublish + self.ksk_postpublish >= self.ksk_rollperiod
+ ):
+ return (
+ False,
+ (
+ (
+ "KSK pre/post-publish periods (%d/%d) "
+ + "combined exceed rollover period %d"
+ )
+ % (self.ksk_prepublish, self.ksk_postpublish, self.ksk_rollperiod)
+ ),
+ )
+
+ if (
+ self.zsk_rollperiod
+ and self.zsk_prepublish
+ and self.zsk_postpublish
+ and self.zsk_prepublish + self.zsk_postpublish >= self.zsk_rollperiod
+ ):
+ return (
+ False,
+ (
+ (
+ "ZSK pre/post-publish periods (%d/%d) "
+ + "combined exceed rollover period %d"
+ )
+ % (self.zsk_prepublish, self.zsk_postpublish, self.zsk_rollperiod)
+ ),
+ )
+
+ if self.algorithm is not None:
+ # Validate the key size
+ key_sz_range = self.valid_key_sz_per_algo.get(self.algorithm)
+ if key_sz_range is not None:
+ # Verify KSK
+ if not self.__verify_size(self.ksk_keysize, key_sz_range):
+ return False, "KSK key size %d outside valid range %s" % (
+ self.ksk_keysize,
+ key_sz_range,
+ )
+
+ # Verify ZSK
+ if not self.__verify_size(self.zsk_keysize, key_sz_range):
+ return False, "ZSK key size %d outside valid range %s" % (
+ self.zsk_keysize,
+ key_sz_range,
+ )
+
+ if self.algorithm in [
+ "ECDSAP256SHA256",
+ "ECDSAP384SHA384",
+ "ED25519",
+ "ED448",
+ ]:
+ self.ksk_keysize = None
+ self.zsk_keysize = None
+
+ return True, ""
+
+
+############################################################################
+# dnssec_policy:
+# This class reads a dnssec.policy file and creates a dictionary of
+# DNSSEC policy rules from which a policy for a specific zone can
+# be generated.
+############################################################################
+class PolicyException(Exception):
+ pass
+
+
+class dnssec_policy:
+ alg_policy = {}
+ named_policy = {}
+ zone_policy = {}
+ current = None
+ filename = None
+ initial = True
+
+ def __init__(self, filename=None, **kwargs):
+ self.plex = PolicyLex()
+ self.tokens = self.plex.tokens
+ if "debug" not in kwargs:
+ kwargs["debug"] = False
+ if "write_tables" not in kwargs:
+ kwargs["write_tables"] = False
+ self.parser = yacc.yacc(module=self, **kwargs)
+
+ # set defaults
+ self.setup(
+ """policy global { algorithm rsasha256;
+ key-size ksk 2048;
+ key-size zsk 2048;
+ roll-period ksk 0;
+ roll-period zsk 1y;
+ pre-publish ksk 1mo;
+ pre-publish zsk 1mo;
+ post-publish ksk 1mo;
+ post-publish zsk 1mo;
+ standby ksk 0;
+ standby zsk 0;
+ keyttl 1h;
+ coverage 6mo; };
+ policy default { policy global; };"""
+ )
+
+ p = Policy()
+ p.algorithm = None
+ p.is_alg = True
+ p.ksk_keysize = 2048
+ p.zsk_keysize = 2048
+
+ # set default algorithm policies
+
+ # these can use default settings
+ self.alg_policy["RSASHA1"] = copy(p)
+ self.alg_policy["RSASHA1"].algorithm = "RSASHA1"
+ self.alg_policy["RSASHA1"].name = "RSASHA1"
+
+ self.alg_policy["NSEC3RSASHA1"] = copy(p)
+ self.alg_policy["NSEC3RSASHA1"].algorithm = "NSEC3RSASHA1"
+ self.alg_policy["NSEC3RSASHA1"].name = "NSEC3RSASHA1"
+
+ self.alg_policy["RSASHA256"] = copy(p)
+ self.alg_policy["RSASHA256"].algorithm = "RSASHA256"
+ self.alg_policy["RSASHA256"].name = "RSASHA256"
+
+ self.alg_policy["RSASHA512"] = copy(p)
+ self.alg_policy["RSASHA512"].algorithm = "RSASHA512"
+ self.alg_policy["RSASHA512"].name = "RSASHA512"
+
+ self.alg_policy["ECDSAP256SHA256"] = copy(p)
+ self.alg_policy["ECDSAP256SHA256"].algorithm = "ECDSAP256SHA256"
+ self.alg_policy["ECDSAP256SHA256"].name = "ECDSAP256SHA256"
+ self.alg_policy["ECDSAP256SHA256"].ksk_keysize = None
+ self.alg_policy["ECDSAP256SHA256"].zsk_keysize = None
+
+ self.alg_policy["ECDSAP384SHA384"] = copy(p)
+ self.alg_policy["ECDSAP384SHA384"].algorithm = "ECDSAP384SHA384"
+ self.alg_policy["ECDSAP384SHA384"].name = "ECDSAP384SHA384"
+ self.alg_policy["ECDSAP384SHA384"].ksk_keysize = None
+ self.alg_policy["ECDSAP384SHA384"].zsk_keysize = None
+
+ self.alg_policy["ED25519"] = copy(p)
+ self.alg_policy["ED25519"].algorithm = "ED25519"
+ self.alg_policy["ED25519"].name = "ED25519"
+ self.alg_policy["ED25519"].ksk_keysize = None
+ self.alg_policy["ED25519"].zsk_keysize = None
+
+ self.alg_policy["ED448"] = copy(p)
+ self.alg_policy["ED448"].algorithm = "ED448"
+ self.alg_policy["ED448"].name = "ED448"
+ self.alg_policy["ED448"].ksk_keysize = None
+ self.alg_policy["ED448"].zsk_keysize = None
+
+ if filename:
+ self.load(filename)
+
+ def load(self, filename):
+ self.filename = filename
+ self.initial = True
+ with open(filename) as f:
+ text = f.read()
+ self.plex.lexer.lineno = 0
+ self.parser.parse(text)
+
+ self.filename = None
+
+ def setup(self, text):
+ self.initial = True
+ self.plex.lexer.lineno = 0
+ self.parser.parse(text)
+
+ def policy(self, zone, **kwargs):
+ z = zone.lower()
+ p = None
+
+ if z in self.zone_policy:
+ p = self.zone_policy[z]
+
+ if p is None:
+ p = copy(self.named_policy["default"])
+ p.name = zone
+ p.is_constructed = True
+
+ if p.algorithm is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent and not parent.algorithm:
+ parent = parent.parent
+ p.algorithm = parent and parent.algorithm or None
+
+ if p.algorithm in self.alg_policy:
+ ap = self.alg_policy[p.algorithm]
+ else:
+ raise PolicyException("algorithm not found")
+
+ if p.directory is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent is not None and not parent.directory:
+ parent = parent.parent
+ p.directory = parent and parent.directory
+
+ if p.coverage is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent and not parent.coverage:
+ parent = parent.parent
+ p.coverage = parent and parent.coverage or ap.coverage
+
+ if p.ksk_keysize is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent.parent and not parent.ksk_keysize:
+ parent = parent.parent
+ p.ksk_keysize = parent and parent.ksk_keysize or ap.ksk_keysize
+
+ if p.zsk_keysize is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent.parent and not parent.zsk_keysize:
+ parent = parent.parent
+ p.zsk_keysize = parent and parent.zsk_keysize or ap.zsk_keysize
+
+ if p.ksk_rollperiod is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent.parent and not parent.ksk_rollperiod:
+ parent = parent.parent
+ p.ksk_rollperiod = parent and parent.ksk_rollperiod or ap.ksk_rollperiod
+
+ if p.zsk_rollperiod is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent.parent and not parent.zsk_rollperiod:
+ parent = parent.parent
+ p.zsk_rollperiod = parent and parent.zsk_rollperiod or ap.zsk_rollperiod
+
+ if p.ksk_prepublish is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent.parent and not parent.ksk_prepublish:
+ parent = parent.parent
+ p.ksk_prepublish = parent and parent.ksk_prepublish or ap.ksk_prepublish
+
+ if p.zsk_prepublish is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent.parent and not parent.zsk_prepublish:
+ parent = parent.parent
+ p.zsk_prepublish = parent and parent.zsk_prepublish or ap.zsk_prepublish
+
+ if p.ksk_postpublish is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent.parent and not parent.ksk_postpublish:
+ parent = parent.parent
+ p.ksk_postpublish = parent and parent.ksk_postpublish or ap.ksk_postpublish
+
+ if p.zsk_postpublish is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent.parent and not parent.zsk_postpublish:
+ parent = parent.parent
+ p.zsk_postpublish = parent and parent.zsk_postpublish or ap.zsk_postpublish
+
+ if p.keyttl is None:
+ parent = p.parent or self.named_policy["default"]
+ while parent is not None and not parent.keyttl:
+ parent = parent.parent
+ p.keyttl = parent and parent.keyttl
+
+ if "novalidate" not in kwargs or not kwargs["novalidate"]:
+ (valid, msg) = p.validate()
+ if not valid:
+ raise PolicyException(msg)
+ return None
+
+ return p
+
+ def p_policylist(self, p):
+ """policylist : init policy
+ | policylist policy"""
+ pass
+
+ def p_init(self, p):
+ "init :"
+ self.initial = False
+
+ def p_policy(self, p):
+ """policy : alg_policy
+ | zone_policy
+ | named_policy"""
+ pass
+
+ def p_name(self, p):
+ """name : STR
+ | KEYTYPE
+ | DATESUFFIX"""
+ p[0] = p[1]
+ pass
+
+ def p_domain(self, p):
+ """domain : STR
+ | QSTRING
+ | KEYTYPE
+ | DATESUFFIX"""
+ p[0] = p[1].strip()
+ if not re.match(r"^[\w.-][\w.-]*$", p[0]):
+ raise PolicyException("invalid domain")
+ pass
+
+ def p_new_policy(self, p):
+ "new_policy :"
+ self.current = Policy()
+
+ def p_alg_policy(self, p):
+ "alg_policy : ALGORITHM_POLICY ALGNAME new_policy alg_option_group SEMI"
+ self.current.name = p[2]
+ self.current.is_alg = True
+ self.alg_policy[p[2]] = self.current
+ pass
+
+ def p_zone_policy(self, p):
+ "zone_policy : ZONE domain new_policy policy_option_group SEMI"
+ self.current.name = p[2].rstrip(".")
+ self.current.is_zone = True
+ self.zone_policy[p[2].rstrip(".").lower()] = self.current
+ pass
+
+ def p_named_policy(self, p):
+ "named_policy : POLICY name new_policy policy_option_group SEMI"
+ self.current.name = p[2]
+ self.named_policy[p[2].lower()] = self.current
+ pass
+
+ def p_duration_1(self, p):
+ "duration : NUMBER"
+ p[0] = p[1]
+ pass
+
+ def p_duration_2(self, p):
+ "duration : NONE"
+ p[0] = None
+ pass
+
+ def p_duration_3(self, p):
+ "duration : NUMBER DATESUFFIX"
+ if p[2] == "y":
+ p[0] = p[1] * 31536000 # year
+ elif p[2] == "mo":
+ p[0] = p[1] * 2592000 # month
+ elif p[2] == "w":
+ p[0] = p[1] * 604800 # week
+ elif p[2] == "d":
+ p[0] = p[1] * 86400 # day
+ elif p[2] == "h":
+ p[0] = p[1] * 3600 # hour
+ elif p[2] == "mi":
+ p[0] = p[1] * 60 # minute
+ elif p[2] == "s":
+ p[0] = p[1] # second
+ else:
+ raise PolicyException("invalid duration")
+
+ def p_policy_option_group(self, p):
+ "policy_option_group : LBRACE policy_option_list RBRACE"
+ pass
+
+ def p_policy_option_list(self, p):
+ """policy_option_list : policy_option SEMI
+ | policy_option_list policy_option SEMI"""
+ pass
+
+ def p_policy_option(self, p):
+ """policy_option : parent_option
+ | directory_option
+ | coverage_option
+ | rollperiod_option
+ | prepublish_option
+ | postpublish_option
+ | keysize_option
+ | algorithm_option
+ | keyttl_option
+ | standby_option"""
+ pass
+
+ def p_alg_option_group(self, p):
+ "alg_option_group : LBRACE alg_option_list RBRACE"
+ pass
+
+ def p_alg_option_list(self, p):
+ """alg_option_list : alg_option SEMI
+ | alg_option_list alg_option SEMI"""
+ pass
+
+ def p_alg_option(self, p):
+ """alg_option : coverage_option
+ | rollperiod_option
+ | prepublish_option
+ | postpublish_option
+ | keyttl_option
+ | keysize_option
+ | standby_option"""
+ pass
+
+ def p_parent_option(self, p):
+ "parent_option : POLICY name"
+ self.current.parent = self.named_policy[p[2].lower()]
+
+ def p_directory_option(self, p):
+ "directory_option : DIRECTORY QSTRING"
+ self.current.directory = p[2]
+
+ def p_coverage_option(self, p):
+ "coverage_option : COVERAGE duration"
+ self.current.coverage = p[2]
+
+ def p_rollperiod_option(self, p):
+ "rollperiod_option : ROLL_PERIOD KEYTYPE duration"
+ if p[2] == "KSK":
+ self.current.ksk_rollperiod = p[3]
+ else:
+ self.current.zsk_rollperiod = p[3]
+
+ def p_prepublish_option(self, p):
+ "prepublish_option : PRE_PUBLISH KEYTYPE duration"
+ if p[2] == "KSK":
+ self.current.ksk_prepublish = p[3]
+ else:
+ self.current.zsk_prepublish = p[3]
+
+ def p_postpublish_option(self, p):
+ "postpublish_option : POST_PUBLISH KEYTYPE duration"
+ if p[2] == "KSK":
+ self.current.ksk_postpublish = p[3]
+ else:
+ self.current.zsk_postpublish = p[3]
+
+ def p_keysize_option(self, p):
+ "keysize_option : KEY_SIZE KEYTYPE NUMBER"
+ if p[2] == "KSK":
+ self.current.ksk_keysize = p[3]
+ else:
+ self.current.zsk_keysize = p[3]
+
+ def p_standby_option(self, p):
+ "standby_option : STANDBY KEYTYPE NUMBER"
+ if p[2] == "KSK":
+ self.current.ksk_standby = p[3]
+ else:
+ self.current.zsk_standby = p[3]
+
+ def p_keyttl_option(self, p):
+ "keyttl_option : KEYTTL duration"
+ self.current.keyttl = p[2]
+
+ def p_algorithm_option(self, p):
+ "algorithm_option : ALGORITHM ALGNAME"
+ self.current.algorithm = p[2]
+
+ def p_error(self, p):
+ if p:
+ print(
+ "%s%s%d:syntax error near '%s'"
+ % (self.filename or "", ":" if self.filename else "", p.lineno, p.value)
+ )
+ else:
+ if not self.initial:
+ raise PolicyException(
+ "%s%s%d:unexpected end of input"
+ % (
+ self.filename or "",
+ ":" if self.filename else "",
+ p and p.lineno or 0,
+ )
+ )
+
+
+if __name__ == "__main__":
+ import sys
+
+ if sys.argv[1] == "lex":
+ file = open(sys.argv[2])
+ text = file.read()
+ file.close()
+ plex = PolicyLex(debug=1)
+ plex.test(text)
+ elif sys.argv[1] == "parse":
+ try:
+ pp = dnssec_policy(sys.argv[2], write_tables=True, debug=True)
+ print(pp.named_policy["default"])
+ print(pp.policy("nonexistent.zone"))
+ except Exception as e:
+ print(e.args[0])
diff --git a/bin/python/isc/rndc.py.in b/bin/python/isc/rndc.py.in
new file mode 100644
index 0000000..a8af767
--- /dev/null
+++ b/bin/python/isc/rndc.py.in
@@ -0,0 +1,193 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+############################################################################
+# rndc.py
+# This module implements the RNDC control protocol.
+############################################################################
+
+from collections import OrderedDict
+import time
+import struct
+import hashlib
+import hmac
+import base64
+import random
+import socket
+
+
+class rndc(object):
+ """RNDC protocol client library"""
+
+ __algos = {
+ "md5": 157,
+ "sha1": 161,
+ "sha224": 162,
+ "sha256": 163,
+ "sha384": 164,
+ "sha512": 165,
+ }
+
+ def __init__(self, host, algo, secret):
+ """Creates a persistent connection to RNDC and logs in
+ host - (ip, port) tuple
+ algo - HMAC algorithm: one of md5, sha1, sha224, sha256, sha384, sha512
+ (with optional prefix 'hmac-')
+ secret - HMAC secret, base64 encoded"""
+ self.host = host
+ algo = algo.lower()
+ if algo.startswith("hmac-"):
+ algo = algo[5:]
+ self.algo = algo
+ self.hlalgo = getattr(hashlib, algo)
+ self.secret = base64.b64decode(secret)
+ self.ser = random.randint(0, 1 << 24)
+ self.nonce = None
+ self.__connect_login()
+
+ def call(self, cmd):
+ """Call a RNDC command, all parsing is done on the server side
+ cmd - a complete string with a command (eg 'reload zone example.com')
+ """
+ return dict(self.__command(type=cmd)["_data"])
+
+ def __serialize_dict(self, data, ignore_auth=False):
+ rv = bytearray()
+ for k, v in data.items():
+ if ignore_auth and k == "_auth":
+ continue
+ rv += struct.pack("B", len(k)) + k.encode("ascii")
+ if type(v) == str:
+ rv += struct.pack(">BI", 1, len(v)) + v.encode("ascii")
+ elif type(v) == bytes:
+ rv += struct.pack(">BI", 1, len(v)) + v
+ elif type(v) == bytearray:
+ rv += struct.pack(">BI", 1, len(v)) + v
+ elif type(v) == OrderedDict:
+ sd = self.__serialize_dict(v)
+ rv += struct.pack(">BI", 2, len(sd)) + sd
+ else:
+ raise NotImplementedError(
+ "Cannot serialize element of type %s" % type(v)
+ )
+ return rv
+
+ def __prep_message(self, *args, **kwargs):
+ self.ser += 1
+ now = int(time.time())
+ data = OrderedDict(*args, **kwargs)
+
+ d = OrderedDict()
+ d["_auth"] = OrderedDict()
+ d["_ctrl"] = OrderedDict()
+ d["_ctrl"]["_ser"] = str(self.ser)
+ d["_ctrl"]["_tim"] = str(now)
+ d["_ctrl"]["_exp"] = str(now + 60)
+ if self.nonce is not None:
+ d["_ctrl"]["_nonce"] = self.nonce
+ d["_data"] = data
+
+ msg = self.__serialize_dict(d, ignore_auth=True)
+ hash = hmac.new(self.secret, msg, self.hlalgo).digest()
+ bhash = base64.b64encode(hash)
+ if self.algo == "md5":
+ d["_auth"]["hmd5"] = struct.pack("22s", bhash)
+ else:
+ d["_auth"]["hsha"] = bytearray(
+ struct.pack("B88s", self.__algos[self.algo], bhash)
+ )
+ msg = self.__serialize_dict(d)
+ msg = struct.pack(">II", len(msg) + 4, 1) + msg
+ return msg
+
+ def __verify_msg(self, msg):
+ if self.nonce is not None and msg["_ctrl"]["_nonce"] != self.nonce:
+ return False
+ if self.algo == "md5":
+ bhash = msg["_auth"]["hmd5"]
+ else:
+ bhash = msg["_auth"]["hsha"][1:]
+ if type(bhash) == bytes:
+ bhash = bhash.decode("ascii")
+ bhash += "=" * (4 - (len(bhash) % 4))
+ remote_hash = base64.b64decode(bhash)
+ my_msg = self.__serialize_dict(msg, ignore_auth=True)
+ my_hash = hmac.new(self.secret, my_msg, self.hlalgo).digest()
+ return my_hash == remote_hash
+
+ def __command(self, *args, **kwargs):
+ msg = self.__prep_message(*args, **kwargs)
+ sent = self.socket.send(msg)
+ if sent != len(msg):
+ raise IOError("Cannot send the message")
+
+ header = self.socket.recv(8)
+ if len(header) != 8:
+ # What should we throw here? Bad auth can cause this...
+ raise IOError("Can't read response header")
+
+ length, version = struct.unpack(">II", header)
+ if version != 1:
+ raise NotImplementedError("Wrong message version %d" % version)
+
+ # it includes the header
+ length -= 4
+ data = self.socket.recv(length, socket.MSG_WAITALL)
+ if len(data) != length:
+ raise IOError("Can't read response data")
+
+ if type(data) == str:
+ data = bytearray(data)
+ msg = self.__parse_message(data)
+ if not self.__verify_msg(msg):
+ raise IOError("Authentication failure")
+
+ return msg
+
+ def __connect_login(self):
+ self.socket = socket.create_connection(self.host)
+ self.nonce = None
+ msg = self.__command(type="null")
+ self.nonce = msg["_ctrl"]["_nonce"]
+
+ def __parse_element(self, input):
+ pos = 0
+ labellen = input[pos]
+ pos += 1
+ label = input[pos : pos + labellen].decode("ascii")
+ pos += labellen
+ type = input[pos]
+ pos += 1
+ datalen = struct.unpack(">I", input[pos : pos + 4])[0]
+ pos += 4
+ data = input[pos : pos + datalen]
+ pos += datalen
+ rest = input[pos:]
+
+ if type == 1: # raw binary value
+ return label, data, rest
+ elif type == 2: # dictionary
+ d = OrderedDict()
+ while len(data) > 0:
+ ilabel, value, data = self.__parse_element(data)
+ d[ilabel] = value
+ return label, d, rest
+ # TODO type 3 - list
+ else:
+ raise NotImplementedError("Unknown element type %d" % type)
+
+ def __parse_message(self, input):
+ rv = OrderedDict()
+ hdata = None
+ while len(input) > 0:
+ label, value, input = self.__parse_element(input)
+ rv[label] = value
+ return rv
diff --git a/bin/python/isc/tests/Makefile.in b/bin/python/isc/tests/Makefile.in
new file mode 100644
index 0000000..233f7f9
--- /dev/null
+++ b/bin/python/isc/tests/Makefile.in
@@ -0,0 +1,33 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+srcdir = @srcdir@
+VPATH = @srcdir@
+top_srcdir = @top_srcdir@
+
+@BIND9_MAKE_INCLUDES@
+
+PYTHON = @PYTHON@
+
+PYTESTS = dnskey_test.py policy_test.py
+
+@BIND9_MAKE_RULES@
+
+check test:
+ for test in $(PYTESTS); do \
+ PYTHONPATH=${srcdir}/../.. $(PYTHON) ${srcdir}/$$test; \
+ done
+
+clean distclean::
+ rm -rf *.pyc __pycache__
+
+distclean::
+ rm -f ${PYTESTS}
diff --git a/bin/python/isc/tests/dnskey_test.py.in b/bin/python/isc/tests/dnskey_test.py.in
new file mode 100644
index 0000000..e3ce3f7
--- /dev/null
+++ b/bin/python/isc/tests/dnskey_test.py.in
@@ -0,0 +1,54 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+import sys
+import unittest
+
+sys.path.append("../..")
+from isc import *
+
+kdict = None
+
+
+def getkey():
+ global kdict
+ if not kdict:
+ kd = keydict(path="testdata")
+ for key in kd:
+ return key
+
+
+class DnskeyTest(unittest.TestCase):
+ def test_metdata(self):
+ key = getkey()
+ self.assertEqual(key.created(), 1448055647)
+ self.assertEqual(key.publish(), 1445463714)
+ self.assertEqual(key.activate(), 1448055714)
+ self.assertEqual(key.revoke(), 1479591714)
+ self.assertEqual(key.inactive(), 1511127714)
+ self.assertEqual(key.delete(), 1542663714)
+ self.assertEqual(key.syncpublish(), 1442871714)
+ self.assertEqual(key.syncdelete(), 1448919714)
+
+ def test_fmttime(self):
+ key = getkey()
+ self.assertEqual(key.getfmttime("Created"), "20151120214047")
+ self.assertEqual(key.getfmttime("Publish"), "20151021214154")
+ self.assertEqual(key.getfmttime("Activate"), "20151120214154")
+ self.assertEqual(key.getfmttime("Revoke"), "20161119214154")
+ self.assertEqual(key.getfmttime("Inactive"), "20171119214154")
+ self.assertEqual(key.getfmttime("Delete"), "20181119214154")
+ self.assertEqual(key.getfmttime("SyncPublish"), "20150921214154")
+ self.assertEqual(key.getfmttime("SyncDelete"), "20151130214154")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/bin/python/isc/tests/policy_test.py.in b/bin/python/isc/tests/policy_test.py.in
new file mode 100644
index 0000000..c115a31
--- /dev/null
+++ b/bin/python/isc/tests/policy_test.py.in
@@ -0,0 +1,104 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+import sys
+import unittest
+
+sys.path.append("../..")
+from isc import *
+
+
+class PolicyTest(unittest.TestCase):
+ def test_keysize(self):
+ pol = policy.dnssec_policy()
+ pol.load("test-policies/01-keysize.pol")
+
+ p = pol.policy("good_rsa.test", novalidate=True)
+ self.assertEqual(p.get_name(), "good_rsa.test")
+ self.assertEqual(p.constructed(), False)
+ self.assertEqual(p.validate(), (True, ""))
+
+ def test_prepublish(self):
+ pol = policy.dnssec_policy()
+ pol.load("test-policies/02-prepublish.pol")
+ p = pol.policy("good_prepublish.test", novalidate=True)
+ self.assertEqual(p.validate(), (True, ""))
+
+ p = pol.policy("bad_prepublish.test", novalidate=True)
+ self.assertEqual(
+ p.validate(),
+ (
+ False,
+ "KSK pre/post-publish periods "
+ "(10368000/5184000) combined exceed "
+ "rollover period 10368000",
+ ),
+ )
+
+ def test_postpublish(self):
+ pol = policy.dnssec_policy()
+ pol.load("test-policies/03-postpublish.pol")
+
+ p = pol.policy("good_postpublish.test", novalidate=True)
+ self.assertEqual(p.validate(), (True, ""))
+
+ p = pol.policy("bad_postpublish.test", novalidate=True)
+ self.assertEqual(
+ p.validate(),
+ (
+ False,
+ "KSK pre/post-publish periods "
+ "(10368000/5184000) combined exceed "
+ "rollover period 10368000",
+ ),
+ )
+
+ def test_combined_pre_post(self):
+ pol = policy.dnssec_policy()
+ pol.load("test-policies/04-combined-pre-post.pol")
+
+ p = pol.policy("good_combined_pre_post_ksk.test", novalidate=True)
+ self.assertEqual(p.validate(), (True, ""))
+
+ p = pol.policy("bad_combined_pre_post_ksk.test", novalidate=True)
+ self.assertEqual(
+ p.validate(),
+ (
+ False,
+ "KSK pre/post-publish periods "
+ "(5184000/5184000) combined exceed "
+ "rollover period 10368000",
+ ),
+ )
+
+ p = pol.policy("good_combined_pre_post_zsk.test", novalidate=True)
+ self.assertEqual(p.validate(), (True, ""))
+ p = pol.policy("bad_combined_pre_post_zsk.test", novalidate=True)
+ self.assertEqual(
+ p.validate(),
+ (
+ False,
+ "ZSK pre/post-publish periods "
+ "(5184000/5184000) combined exceed "
+ "rollover period 7776000",
+ ),
+ )
+
+ def test_numeric_zone(self):
+ pol = policy.dnssec_policy()
+ pol.load("test-policies/05-numeric-zone.pol")
+
+ p = pol.policy("99example.test", novalidate=True)
+ self.assertEqual(p.validate(), (True, ""))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/bin/python/isc/tests/test-policies/01-keysize.pol b/bin/python/isc/tests/test-policies/01-keysize.pol
new file mode 100644
index 0000000..db22058
--- /dev/null
+++ b/bin/python/isc/tests/test-policies/01-keysize.pol
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * See the COPYRIGHT file distributed with this work for additional
+ * information regarding copyright ownership.
+ */
+
+policy keysize_rsa {
+ algorithm rsasha1;
+ coverage 1y;
+ roll-period zsk 3mo;
+ pre-publish zsk 2w;
+ post-publish zsk 2w;
+ roll-period ksk 1y;
+ pre-publish ksk 1mo;
+ post-publish ksk 2mo;
+ keyttl 1h;
+ key-size ksk 2048;
+ key-size zsk 1024;
+};
+
+policy keysize_dsa {
+ algorithm dsa;
+ coverage 1y;
+ key-size ksk 2048;
+ key-size zsk 1024;
+};
+
+zone good_rsa.test {
+ policy keysize_rsa;
+};
+
+zone bad_rsa.test {
+ policy keysize_rsa;
+ key-size ksk 511;
+};
+
+zone good_dsa.test {
+ policy keysize_dsa;
+ key-size ksk 1024;
+ key-size zsk 768;
+};
+
+zone bad_dsa.test {
+ policy keysize_dsa;
+ key-size ksk 1024;
+ key-size zsk 769;
+};
diff --git a/bin/python/isc/tests/test-policies/02-prepublish.pol b/bin/python/isc/tests/test-policies/02-prepublish.pol
new file mode 100644
index 0000000..7dd1b32
--- /dev/null
+++ b/bin/python/isc/tests/test-policies/02-prepublish.pol
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * See the COPYRIGHT file distributed with this work for additional
+ * information regarding copyright ownership.
+ */
+
+policy prepublish_rsa {
+ algorithm rsasha1;
+ coverage 1y;
+ roll-period zsk 3mo;
+ pre-publish zsk 2w;
+ post-publish zsk 2w;
+ roll-period ksk 1y;
+ pre-publish ksk 1mo;
+ post-publish ksk 2mo;
+ keyttl 1h;
+ key-size ksk 2048;
+ key-size zsk 1024;
+};
+
+// Policy that defines a pre-publish period lower than the rollover period
+zone good_prepublish.test {
+ policy prepublish_rsa;
+ coverage 6mo;
+ roll-period ksk 4mo;
+ pre-publish ksk 1mo;
+};
+
+// Policy that defines a pre-publish period equal to the rollover period
+zone bad_prepublish.test {
+ policy prepublish_rsa;
+ coverage 6mo;
+ roll-period ksk 4mo;
+ pre-publish ksk 4mo;
+};
+
+
diff --git a/bin/python/isc/tests/test-policies/03-postpublish.pol b/bin/python/isc/tests/test-policies/03-postpublish.pol
new file mode 100644
index 0000000..74bd822
--- /dev/null
+++ b/bin/python/isc/tests/test-policies/03-postpublish.pol
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * See the COPYRIGHT file distributed with this work for additional
+ * information regarding copyright ownership.
+ */
+
+policy postpublish_rsa {
+ algorithm rsasha1;
+ coverage 1y;
+ roll-period zsk 3mo;
+ pre-publish zsk 2w;
+ post-publish zsk 2w;
+ roll-period ksk 1y;
+ pre-publish ksk 1mo;
+ post-publish ksk 2mo;
+ keyttl 1h;
+ key-size ksk 2048;
+ key-size zsk 1024;
+};
+
+// Policy that defines a post-publish period lower than the rollover period
+zone good_postpublish.test {
+ policy postpublish_rsa;
+ coverage 6mo;
+ roll-period ksk 4mo;
+ pre-publish ksk 1mo;
+};
+
+// Policy that defines a post-publish period equal to the rollover period
+zone bad_postpublish.test {
+ policy postpublish_rsa;
+ coverage 6mo;
+ roll-period ksk 4mo;
+ pre-publish ksk 4mo;
+};
+
+
diff --git a/bin/python/isc/tests/test-policies/04-combined-pre-post.pol b/bin/python/isc/tests/test-policies/04-combined-pre-post.pol
new file mode 100644
index 0000000..82c001c
--- /dev/null
+++ b/bin/python/isc/tests/test-policies/04-combined-pre-post.pol
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * See the COPYRIGHT file distributed with this work for additional
+ * information regarding copyright ownership.
+ */
+
+policy combined_pre_post_rsa {
+ algorithm rsasha1;
+ coverage 1y;
+ roll-period zsk 3mo;
+ pre-publish zsk 2w;
+ post-publish zsk 2w;
+ roll-period ksk 1y;
+ pre-publish ksk 1mo;
+ post-publish ksk 2mo;
+ keyttl 1h;
+ key-size ksk 2048;
+ key-size zsk 1024;
+};
+
+// Policy that defines a combined pre-publish and post-publish period lower
+// than the rollover period
+zone good_combined_pre_post_ksk.test {
+ policy combined_pre_post_rsa;
+ coverage 6mo;
+ roll-period ksk 4mo;
+ pre-publish ksk 1mo;
+ post-publish ksk 1mo;
+};
+
+// Policy that defines a combined pre-publish and post-publish period higher
+// than the rollover period
+zone bad_combined_pre_post_ksk.test {
+ policy combined_pre_post_rsa;
+ coverage 6mo;
+ roll-period ksk 4mo;
+ pre-publish ksk 2mo;
+ post-publish ksk 2mo;
+};
+
+// Policy that defines a combined pre-publish and post-publish period lower
+// than the rollover period
+zone good_combined_pre_post_zsk.test {
+ policy combined_pre_post_rsa;
+ coverage 1y;
+ roll-period zsk 3mo;
+ pre-publish zsk 1mo;
+ post-publish zsk 1mo;
+};
+
+// Policy that defines a combined pre-publish and post-publish period higher
+// than the rollover period
+zone bad_combined_pre_post_zsk.test {
+ policy combined_pre_post_rsa;
+ coverage 1y;
+ roll-period zsk 3mo;
+ pre-publish zsk 2mo;
+ post-publish zsk 2mo;
+};
+
+
diff --git a/bin/python/isc/tests/test-policies/05-numeric-zone.pol b/bin/python/isc/tests/test-policies/05-numeric-zone.pol
new file mode 100644
index 0000000..26e546b
--- /dev/null
+++ b/bin/python/isc/tests/test-policies/05-numeric-zone.pol
@@ -0,0 +1,17 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * See the COPYRIGHT file distributed with this work for additional
+ * information regarding copyright ownership.
+ */
+
+// Zone policy that uses a numeric name
+zone "99example.test" {
+ coverage 6mo;
+};
diff --git a/bin/python/isc/tests/testdata/Kexample.com.+007+35529.key b/bin/python/isc/tests/testdata/Kexample.com.+007+35529.key
new file mode 100644
index 0000000..c5afbe2
--- /dev/null
+++ b/bin/python/isc/tests/testdata/Kexample.com.+007+35529.key
@@ -0,0 +1,8 @@
+; This is a key-signing key, keyid 35529, for example.com.
+; Created: 20151120214047 (Fri Nov 20 13:40:47 2015)
+; Publish: 20151021214154 (Wed Oct 21 14:41:54 2015)
+; Activate: 20151120214154 (Fri Nov 20 13:41:54 2015)
+; Revoke: 20161119214154 (Sat Nov 19 13:41:54 2016)
+; Inactive: 20171119214154 (Sun Nov 19 13:41:54 2017)
+; Delete: 20181119214154 (Mon Nov 19 13:41:54 2018)
+example.com. IN DNSKEY 257 3 7 AwEAAbbJK96tY8d4sF6RLxh9SVIhho5s2ZhrcijT5j1SNLECen7QLutj VJPEiG8UgBLaJSGkxPDxOygYv4hwh4JXBSj89o9rNabAJtCa9XzIXSpt /cfiCfvqmcOZb9nepmDCXsC7gn/gbae/4Y5ym9XOiCp8lu+tlFWgRiJ+ kxDGN48rRPrGfpq+SfwM9NUtftVa7B0EFVzDkADKedRj0SSGYOqH+WYH CnWjhPFmgJoAw3/m4slTHW1l+mDwFvsCMjXopg4JV0CNnTybnOmyuIwO LWRhB3q8ze24sYBU1fpE9VAMxZ++4Kqh/2MZFeDAs7iPPKSmI3wkRCW5 pkwDLO5lJ9c=
diff --git a/bin/python/isc/tests/testdata/Kexample.com.+007+35529.private b/bin/python/isc/tests/testdata/Kexample.com.+007+35529.private
new file mode 100644
index 0000000..af22c6a
--- /dev/null
+++ b/bin/python/isc/tests/testdata/Kexample.com.+007+35529.private
@@ -0,0 +1,18 @@
+Private-key-format: v1.3
+Algorithm: 7 (NSEC3RSASHA1)
+Modulus: tskr3q1jx3iwXpEvGH1JUiGGjmzZmGtyKNPmPVI0sQJ6ftAu62NUk8SIbxSAEtolIaTE8PE7KBi/iHCHglcFKPz2j2s1psAm0Jr1fMhdKm39x+IJ++qZw5lv2d6mYMJewLuCf+Btp7/hjnKb1c6IKnyW762UVaBGIn6TEMY3jytE+sZ+mr5J/Az01S1+1VrsHQQVXMOQAMp51GPRJIZg6of5ZgcKdaOE8WaAmgDDf+biyVMdbWX6YPAW+wIyNeimDglXQI2dPJuc6bK4jA4tZGEHerzN7bixgFTV+kT1UAzFn77gqqH/YxkV4MCzuI88pKYjfCREJbmmTAMs7mUn1w==
+PublicExponent: AQAB
+PrivateExponent: jfiM6YU1Rd6Y5qrPsK7HP1Ko54DmNbvmzI1hfGmYYZAyQsNCXjQloix5aAW9QGdNhecrzJUhxJAMXFZC+lrKuD5a56R25JDE1Sw21nft3SHXhuQrqw5Z5hIMTWXhRrBR1lMOFnLj2PJxqCmenp+vJYjl1z20RBmbv/keE15SExFRJIJ3G0lI4V0KxprY5rgsT/vID0pS32f7rmXhgEzyWDyuxceTMidBooD5BSeEmSTYa4rvCVZ2vgnzIGSxjYDPJE2rGve2dpvdXQuujRFaf4+/FzjaOgg35rTtUmC9klfB4D6KJIfc1PNUwcH7V0VJ2fFlgZgMYi4W331QORl9sQ==
+Prime1: 479rW3EeoBwHhUKDy5YeyfnMKjhaosrcYhW4resevLzatFrvS/n2KxJnsHoEzmGr2A13naI61RndgVBBOwNDWI3/tQ+aKvcr+V9m4omROV3xYa8s1FsDbEW0Z6G0UheaqRFir8WK98/Lj6Zht1uBXHSPPf91OW0qj+b5gbX7TK8=
+Prime2: zXXlxgIq+Ih6kxsUw4Ith0nd/d2P3d42QYPjxYjsg4xYicPAjva9HltnbBQ2lr4JEG9Yyb8KalSnJUSuvXtn7bGfBzLu8W6omCeVWXQVH4NIu9AjpO16NpMKWGRfiHHbbSYJs1daTZKHC2FEmi18MKX/RauHGGOakFQ/3A/GMVk=
+Exponent1: 0o9UQ1uHNAIWFedUEHJ/jr7LOrGVYnLpZCmu7+S0K0zzatGz8ets44+FnAyDywdUKFDzKSMm/4SFXRwE4vl2VzYZlp2RLG4PEuRYK9OCF6a6F1UsvjxTItQjIbjIDSnTjMINGnMps0lDa1EpgKsyI3eEQ46eI3TBZ//k6D6G0vM=
+Exponent2: d+CYJgXRyJzo17fvT3s+0TbaHWsOq+chROyNEw4m4UIbzpW2XjO8eF/gYgERMLbEVyCAb4XVr+CgfXArfEbqhpciMHMZUyi7mbtOupiuUmqpH1v70Bj3O6xjVtuJmfTEkFSnSEppV+VsgclI26Q6V7Ai1yWTdzl2T0u4zs8tVlE=
+Coefficient: E4EYw76gIChdQDn6+Uh44/xH9Uwmvq3OETR8w/kEZ0xQ8AkTdKFKUp84nlR6gN+ljb2mUxERKrVLwnBsU8EbUlo9UccMbBGkkZ/8MyfGCBb9nUyOFtOxdHY2M0MQadesRptXHt/m30XjdohwmT7qfSIENwtgUOHbwFnn7WPMc/k=
+Created: 20151120214047
+Publish: 20151021214154
+Activate: 20151120214154
+Revoke: 20161119214154
+Inactive: 20171119214154
+Delete: 20181119214154
+SyncPublish: 20150921214154
+SyncDelete: 20151130214154
diff --git a/bin/python/isc/utils.py.in b/bin/python/isc/utils.py.in
new file mode 100644
index 0000000..3ce4d4c
--- /dev/null
+++ b/bin/python/isc/utils.py.in
@@ -0,0 +1,71 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+import os
+
+# These routines permit platform-independent location of BIND 9 tools
+if os.name == "nt":
+ import win32con
+ import win32api
+
+
+def prefix(bindir=""):
+ if os.name != "nt":
+ return os.path.join("@prefix@", bindir)
+
+ hklm = win32con.HKEY_LOCAL_MACHINE
+ bind_subkey = "Software\\ISC\\BIND"
+ sam = win32con.KEY_READ
+ h_key = None
+ key_found = True
+ # can fail if the registry redirected for 32/64 bits
+ try:
+ h_key = win32api.RegOpenKeyEx(hklm, bind_subkey, 0, sam)
+ except:
+ key_found = False
+ # retry for 32 bit python with 64 bit bind9
+ if not key_found:
+ key_found = True
+ sam64 = sam | win32con.KEY_WOW64_64KEY
+ try:
+ h_key = win32api.RegOpenKeyEx(hklm, bind_subkey, 0, sam64)
+ except:
+ key_found = False
+ # retry 64 bit python with 32 bit bind9
+ if not key_found:
+ key_found = True
+ sam32 = sam | win32con.KEY_WOW64_32KEY
+ try:
+ h_key = win32api.RegOpenKeyEx(hklm, bind_subkey, 0, sam32)
+ except:
+ key_found = False
+ if key_found:
+ try:
+ (named_base, _) = win32api.RegQueryValueEx(h_key, "InstallDir")
+ except:
+ key_found = False
+ win32api.RegCloseKey(h_key)
+ if key_found:
+ return os.path.join(named_base, bindir)
+ return os.path.join(win32api.GetSystemDirectory(), bindir)
+
+
+def shellquote(s):
+ if os.name == "nt":
+ return '"' + s.replace('"', '"\\"') + '"'
+ return "'" + s.replace("'", "'\\''") + "'"
+
+
+version = "@BIND9_VERSION@"
+if os.name != "nt":
+ sysconfdir = "@expanded_sysconfdir@"
+else:
+ sysconfdir = prefix("etc")