summaryrefslogtreecommitdiffstats
path: root/bin/python/isc
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--bin/python/isc/Makefile.in43
-rw-r--r--bin/python/isc/__init__.py.in24
-rw-r--r--bin/python/isc/checkds.py.in185
-rw-r--r--bin/python/isc/coverage.py.in286
-rw-r--r--bin/python/isc/dnskey.py.in507
-rw-r--r--bin/python/isc/eventlist.py.in166
-rw-r--r--bin/python/isc/keydict.py.in84
-rw-r--r--bin/python/isc/keyevent.py.in76
-rw-r--r--bin/python/isc/keymgr.py.in154
-rw-r--r--bin/python/isc/keyseries.py.in191
-rw-r--r--bin/python/isc/keyzone.py.in55
-rw-r--r--bin/python/isc/policy.py.in728
-rw-r--r--bin/python/isc/rndc.py.in188
-rw-r--r--bin/python/isc/tests/Makefile.in31
-rw-r--r--bin/python/isc/tests/dnskey_test.py.in52
-rw-r--r--bin/python/isc/tests/policy_test.py.in92
-rw-r--r--bin/python/isc/tests/test-policies/01-keysize.pol52
-rw-r--r--bin/python/isc/tests/test-policies/02-prepublish.pol42
-rw-r--r--bin/python/isc/tests/test-policies/03-postpublish.pol42
-rw-r--r--bin/python/isc/tests/test-policies/04-combined-pre-post.pol66
-rw-r--r--bin/python/isc/tests/test-policies/05-numeric-zone.pol15
-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, 3176 insertions, 0 deletions
diff --git a/bin/python/isc/Makefile.in b/bin/python/isc/Makefile.in
new file mode 100644
index 0000000..ec17b6b
--- /dev/null
+++ b/bin/python/isc/Makefile.in
@@ -0,0 +1,43 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+
+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..916333c
--- /dev/null
+++ b/bin/python/isc/__init__.py.in
@@ -0,0 +1,24 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+__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..42fbfcd
--- /dev/null
+++ b/bin/python/isc/checkds.py.in
@@ -0,0 +1,185 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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/DLV 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, dlvname = None):
+ if not rrtext:
+ raise Exception
+
+ fields = rrtext.decode('ascii').split()
+ if len(fields) < 7:
+ raise Exception
+
+ if dlvname:
+ self.rrtype = "DLV"
+ self.dlvname = dlvname.lower()
+ parent = fields[0].lower().strip('.').split('.')
+ parent.reverse()
+ dlv = dlvname.split('.')
+ dlv.reverse()
+ while len(dlv) != 0 and len(parent) != 0 and parent[0] == dlv[0]:
+ parent = parent[1:]
+ dlv = dlv[1:]
+ if dlv:
+ raise Exception
+ parent.reverse()
+ self.parent = '.'.join(parent)
+ self.rrname = self.parent + '.' + self.dlvname + '.'
+ else:
+ 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/DLV 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/DLV records from the DNSKEY RRset,
+# and report on congruency.
+############################################################################
+def check(zone, args, masterfile=None, lookaside=None):
+ rrlist = []
+ cmd = [args.dig, "+noall", "+answer", "-t", "dlv" if lookaside else "ds",
+ "-q", zone + "." + lookaside if lookaside else zone]
+ fp, _ = Popen(cmd, stdout=PIPE).communicate()
+
+ for line in fp.splitlines():
+ rrlist.append(SECRR(line, lookaside))
+ rrlist = sorted(rrlist, key=lambda rr: (rr.keyid, rr.keyalg, rr.hashalg))
+
+ klist = []
+
+ if masterfile:
+ cmd = [args.dsfromkey, "-f", masterfile]
+ if lookaside:
+ cmd += ["-l", lookaside]
+ cmd.append(zone)
+ fp, _ = Popen(cmd, stdout=PIPE).communicate()
+ else:
+ intods, _ = Popen([args.dig, "+noall", "+answer", "-t", "dnskey",
+ "-q", zone], stdout=PIPE).communicate()
+ cmd = [args.dsfromkey, "-f", "-"]
+ if lookaside:
+ cmd += ["-l", lookaside]
+ cmd.append(zone)
+ fp, _ = Popen(cmd, stdin=PIPE, stdout=PIPE).communicate(intods)
+
+ for line in fp.splitlines():
+ klist.append(SECRR(line, lookaside))
+
+ if len(klist) < 1:
+ print("No DNSKEY records found in zone apex")
+ return False
+
+ found = 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]))
+ found = True
+ else:
+ print("%s for KSK %s/%03d/%05d (%s) missing from parent" %
+ (rr.rrtype, rr.rrname.strip('.'), rr.keyalg,
+ rr.keyid, SECRR.hashalgs[rr.hashalg]))
+
+ if not found:
+ print("No %s records were found for any DNSKEY" % ("DLV" if lookaside else "DS"))
+
+ return found
+
+############################################################################
+# 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('-f', '--file', dest='masterfile', type=str,
+ help='zone master file')
+ parser.add_argument('-l', '--lookaside', dest='lookaside', type=str,
+ help='DLV lookaside zone')
+ 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 \'dig\'')
+ parser.add_argument('-v', '--version', action='version',
+ version=version)
+ args = parser.parse_args()
+
+ args.zone = args.zone.strip('.')
+ if args.lookaside:
+ args.lookaside = args.lookaside.strip('.')
+
+ return args
+
+
+############################################################################
+# Main
+############################################################################
+def main():
+ args = parse_args()
+ found = check(args.zone, args, args.masterfile, args.lookaside)
+ exit(0 if found else 1)
diff --git a/bin/python/isc/coverage.py.in b/bin/python/isc/coverage.py.in
new file mode 100644
index 0000000..4e392a7
--- /dev/null
+++ b/bin/python/isc/coverage.py.in
@@ -0,0 +1,286 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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.")
+
+ # 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, zone=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..8c5a80f
--- /dev/null
+++ b/bin/python/isc/dnskey.py.in
@@ -0,0 +1,507 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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', 'ECC', '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, "rU")
+
+ 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
+
+ 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..5c1b31a
--- /dev/null
+++ b/bin/python/isc/eventlist.py.in
@@ -0,0 +1,166 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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
+ # occurrance, 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
+ kok = 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..578a847
--- /dev/null
+++ b/bin/python/isc/keydict.py.in
@@ -0,0 +1,84 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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):
+ 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.name != zone: # shouldn't ever happen
+ continue
+ self._keydict[key.name][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..cfc935a
--- /dev/null
+++ b/bin/python/isc/keyevent.py.in
@@ -0,0 +1,76 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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..c193daa
--- /dev/null
+++ b/bin/python/isc/keymgr.py.in
@@ -0,0 +1,154 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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='Path to a file containing random data to pass to \'dnssec-keygen\'',
+ 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.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..e1241f0
--- /dev/null
+++ b/bin/python/isc/keyseries.py.in
@@ -0,0 +1,191 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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)
+ if not a or a > now:
+ key.setactivate(now)
+
+ if not rp:
+ key.setinactive(None, **kwargs)
+ key.setdelete(None, **kwargs)
+ else:
+ key.setinactive(a + rp, **kwargs)
+ key.setdelete(a + rp + 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..2451d43
--- /dev/null
+++ b/bin/python/isc/keyzone.py.in
@@ -0,0 +1,55 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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 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..f7829fa
--- /dev/null
+++ b/bin/python/isc/policy.py.in
@@ -0,0 +1,728 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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'(?i)(?<=[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'(?i)(y|mo|w|d|h|mi|s)([a-z]*)', t.value).group(1).lower()
+ return t
+
+ def t_KEYTYPE(self, t):
+ r'(?i)\b(KSK|ZSK)\b'
+ t.value = t.value.upper()
+ return t
+
+ def t_ALGNAME(self, t):
+ r'(?i)\b(RSAMD5|DH|DSA|NSEC3DSA|ECC|RSASHA1|NSEC3RSASHA1|RSASHA256|RSASHA512|ECCGOST|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, **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 = {'DSA': [512, 1024],
+ 'NSEC3DSA': [512, 1024],
+ 'RSAMD5': [512, 4096],
+ 'RSASHA1': [512, 4096],
+ 'NSEC3RSASHA1': [512, 4096],
+ 'RSASHA256': [512, 4096],
+ 'RSASHA512': [512, 4096],
+ 'ECCGOST': None,
+ '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)
+
+ # Specific check for DSA keys
+ if self.algorithm in ['DSA', 'NSEC3DSA'] and \
+ self.ksk_keysize % 64 != 0:
+ return False, \
+ ('KSK key size %d not divisible by 64 ' +
+ 'as required for DSA') % self.ksk_keysize
+
+ if self.algorithm in ['DSA', 'NSEC3DSA'] and \
+ self.zsk_keysize % 64 != 0:
+ return False, \
+ ('ZSK key size %d not divisible by 64 ' +
+ 'as required for DSA') % self.zsk_keysize
+
+ if self.algorithm in ['ECCGOST', \
+ '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 need a lower default key size:
+ self.alg_policy['DSA'] = copy(p)
+ self.alg_policy['DSA'].algorithm = "DSA"
+ self.alg_policy['DSA'].name = "DSA"
+ self.alg_policy['DSA'].ksk_keysize = 1024;
+
+ self.alg_policy['NSEC3DSA'] = copy(p)
+ self.alg_policy['NSEC3DSA'].algorithm = "NSEC3DSA"
+ self.alg_policy['NSEC3DSA'].name = "NSEC3DSA"
+ self.alg_policy['NSEC3DSA'].ksk_keysize = 1024;
+
+ # these can use default settings
+ self.alg_policy['RSAMD5'] = copy(p)
+ self.alg_policy['RSAMD5'].algorithm = "RSAMD5"
+ self.alg_policy['RSAMD5'].name = "RSAMD5"
+
+ 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['ECCGOST'] = copy(p)
+ self.alg_policy['ECCGOST'].algorithm = "ECCGOST"
+ self.alg_policy['ECCGOST'].name = "ECCGOST"
+
+ 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..e1a3e77
--- /dev/null
+++ b/bin/python/isc/rndc.py.in
@@ -0,0 +1,188 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+############################################################################
+# 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..60b9bd7
--- /dev/null
+++ b/bin/python/isc/tests/Makefile.in
@@ -0,0 +1,31 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+
+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..91d5245
--- /dev/null
+++ b/bin/python/isc/tests/dnskey_test.py.in
@@ -0,0 +1,52 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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..b09c62f
--- /dev/null
+++ b/bin/python/isc/tests/policy_test.py.in
@@ -0,0 +1,92 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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, ""))
+
+ p = pol.policy('good_dsa.test', novalidate=True)
+ self.assertEqual(p.get_name(), "good_dsa.test")
+ self.assertEqual(p.constructed(), False)
+ self.assertEqual(p.validate(), (True, ""))
+
+ p = pol.policy('bad_dsa.test', novalidate=True)
+ self.assertEqual(p.validate(),
+ (False, 'ZSK key size 769 not divisible by 64 as required for DSA'))
+
+ 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..8b62c5f
--- /dev/null
+++ b/bin/python/isc/tests/test-policies/01-keysize.pol
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * 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..bd35386
--- /dev/null
+++ b/bin/python/isc/tests/test-policies/02-prepublish.pol
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * 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..4d4a8ef
--- /dev/null
+++ b/bin/python/isc/tests/test-policies/03-postpublish.pol
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * 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..d612d81
--- /dev/null
+++ b/bin/python/isc/tests/test-policies/04-combined-pre-post.pol
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * 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..1db8c08
--- /dev/null
+++ b/bin/python/isc/tests/test-policies/05-numeric-zone.pol
@@ -0,0 +1,15 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * 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..0241871
--- /dev/null
+++ b/bin/python/isc/utils.py.in
@@ -0,0 +1,71 @@
+############################################################################
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# 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.
+############################################################################
+
+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')