1# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
2#
3# SPDX-License-Identifier: MPL-2.0
4#
5# This Source Code Form is subject to the terms of the Mozilla Public
6# License, v. 2.0.  If a copy of the MPL was not distributed with this
7# file, you can obtain one at https://mozilla.org/MPL/2.0/.
8#
9# See the COPYRIGHT file distributed with this work for additional
10# information regarding copyright ownership.
11
12import os
13import time
14import calendar
15from subprocess import Popen, PIPE
16
17
18########################################################################
19# Class dnskey
20########################################################################
21class TimePast(Exception):
22    def __init__(self, key, prop, value):
23        super(TimePast, self).__init__(
24            "%s time for key %s (%d) is already past" % (prop, key, value)
25        )
26
27
28class dnskey:
29    """An individual DNSSEC key.  Identified by path, name, algorithm, keyid.
30    Contains a dictionary of metadata events."""
31
32    _PROPS = (
33        "Created",
34        "Publish",
35        "Activate",
36        "Inactive",
37        "Delete",
38        "Revoke",
39        "DSPublish",
40        "SyncPublish",
41        "SyncDelete",
42    )
43    _OPTS = (None, "-P", "-A", "-I", "-D", "-R", None, "-Psync", "-Dsync")
44
45    _ALGNAMES = (
46        None,
47        "RSAMD5",
48        "DH",
49        "DSA",
50        None,
51        "RSASHA1",
52        "NSEC3DSA",
53        "NSEC3RSASHA1",
54        "RSASHA256",
55        None,
56        "RSASHA512",
57        None,
58        "ECCGOST",
59        "ECDSAP256SHA256",
60        "ECDSAP384SHA384",
61        "ED25519",
62        "ED448",
63    )
64
65    def __init__(self, key, directory=None, keyttl=None):
66        # this makes it possible to use algname as a class or instance method
67        if isinstance(key, tuple) and len(key) == 3:
68            self._dir = directory or "."
69            (name, alg, keyid) = key
70            self.fromtuple(name, alg, keyid, keyttl)
71
72        self._dir = directory or os.path.dirname(key) or "."
73        key = os.path.basename(key)
74        (name, alg, keyid) = key.split("+")
75        name = name[1:-1]
76        alg = int(alg)
77        keyid = int(keyid.split(".")[0])
78        self.fromtuple(name, alg, keyid, keyttl)
79
80    def fromtuple(self, name, alg, keyid, keyttl):
81        if name.endswith("."):
82            fullname = name
83            name = name.rstrip(".")
84        else:
85            fullname = name + "."
86
87        keystr = "K%s+%03d+%05d" % (fullname, alg, keyid)
88        key_file = self._dir + (self._dir and os.sep or "") + keystr + ".key"
89        private_file = self._dir + (self._dir and os.sep or "") + keystr + ".private"
90
91        self.keystr = keystr
92
93        self.name = name
94        self.alg = int(alg)
95        self.keyid = int(keyid)
96        self.fullname = fullname
97
98        kfp = open(key_file, "r")
99        for line in kfp:
100            if line[0] == ";":
101                continue
102            tokens = line.split()
103            if not tokens:
104                continue
105
106            if tokens[1].lower() in ("in", "ch", "hs"):
107                septoken = 3
108                self.ttl = keyttl
109            else:
110                septoken = 4
111                self.ttl = int(tokens[1]) if not keyttl else keyttl
112
113            if (int(tokens[septoken]) & 0x1) == 1:
114                self.sep = True
115            else:
116                self.sep = False
117        kfp.close()
118
119        pfp = open(private_file, "r")
120
121        self.metadata = dict()
122        self._changed = dict()
123        self._delete = dict()
124        self._times = dict()
125        self._fmttime = dict()
126        self._timestamps = dict()
127        self._original = dict()
128        self._origttl = None
129
130        for line in pfp:
131            line = line.strip()
132            if not line or line[0] in ("!#"):
133                continue
134            punctuation = [line.find(c) for c in ":= "] + [len(line)]
135            found = min([pos for pos in punctuation if pos != -1])
136            name = line[:found].rstrip()
137            value = line[found:].lstrip(":= ").rstrip()
138            self.metadata[name] = value
139
140        for prop in dnskey._PROPS:
141            self._changed[prop] = False
142            if prop in self.metadata:
143                t = self.parsetime(self.metadata[prop])
144                self._times[prop] = t
145                self._fmttime[prop] = self.formattime(t)
146                self._timestamps[prop] = self.epochfromtime(t)
147                self._original[prop] = self._timestamps[prop]
148            else:
149                self._times[prop] = None
150                self._fmttime[prop] = None
151                self._timestamps[prop] = None
152                self._original[prop] = None
153
154        pfp.close()
155
156    def commit(self, settime_bin, **kwargs):
157        quiet = kwargs.get("quiet", False)
158        cmd = []
159        first = True
160
161        if self._origttl is not None:
162            cmd += ["-L", str(self.ttl)]
163
164        for prop, opt in zip(dnskey._PROPS, dnskey._OPTS):
165            if not opt or not self._changed[prop]:
166                continue
167
168            delete = False
169            if prop in self._delete and self._delete[prop]:
170                delete = True
171
172            when = "none" if delete else self._fmttime[prop]
173            cmd += [opt, when]
174            first = False
175
176        if cmd:
177            fullcmd = (
178                [settime_bin, "-K", self._dir]
179                + cmd
180                + [
181                    self.keystr,
182                ]
183            )
184            if not quiet:
185                print("# " + " ".join(fullcmd))
186            try:
187                p = Popen(fullcmd, stdout=PIPE, stderr=PIPE)
188                stdout, stderr = p.communicate()
189                if stderr:
190                    raise Exception(str(stderr))
191            except Exception as e:
192                raise Exception("unable to run %s: %s" % (settime_bin, str(e)))
193            self._origttl = None
194            for prop in dnskey._PROPS:
195                self._original[prop] = self._timestamps[prop]
196                self._changed[prop] = False
197
198    @classmethod
199    def generate(
200        cls,
201        keygen_bin,
202        randomdev,
203        keys_dir,
204        name,
205        alg,
206        keysize,
207        sep,
208        ttl,
209        publish=None,
210        activate=None,
211        **kwargs
212    ):
213        quiet = kwargs.get("quiet", False)
214
215        keygen_cmd = [keygen_bin, "-q", "-K", keys_dir, "-L", str(ttl)]
216
217        if randomdev:
218            keygen_cmd += ["-r", randomdev]
219
220        if sep:
221            keygen_cmd.append("-fk")
222
223        if alg:
224            keygen_cmd += ["-a", alg]
225
226        if keysize:
227            keygen_cmd += ["-b", str(keysize)]
228
229        if publish:
230            t = dnskey.timefromepoch(publish)
231            keygen_cmd += ["-P", dnskey.formattime(t)]
232
233        if activate:
234            t = dnskey.timefromepoch(activate)
235            keygen_cmd += ["-A", dnskey.formattime(activate)]
236
237        keygen_cmd.append(name)
238
239        if not quiet:
240            print("# " + " ".join(keygen_cmd))
241
242        p = Popen(keygen_cmd, stdout=PIPE, stderr=PIPE)
243        stdout, stderr = p.communicate()
244        if stderr:
245            raise Exception("unable to generate key: " + str(stderr))
246
247        try:
248            keystr = stdout.splitlines()[0].decode("ascii")
249            newkey = dnskey(keystr, keys_dir, ttl)
250            return newkey
251        except Exception as e:
252            raise Exception("unable to parse generated key: %s" % str(e))
253
254    def generate_successor(self, keygen_bin, randomdev, prepublish, **kwargs):
255        quiet = kwargs.get("quiet", False)
256
257        if not self.inactive():
258            raise Exception("predecessor key %s has no inactive date" % self)
259
260        keygen_cmd = [keygen_bin, "-q", "-K", self._dir, "-S", self.keystr]
261
262        if self.ttl:
263            keygen_cmd += ["-L", str(self.ttl)]
264
265        if randomdev:
266            keygen_cmd += ["-r", randomdev]
267
268        if prepublish:
269            keygen_cmd += ["-i", str(prepublish)]
270
271        if not quiet:
272            print("# " + " ".join(keygen_cmd))
273
274        p = Popen(keygen_cmd, stdout=PIPE, stderr=PIPE)
275        stdout, stderr = p.communicate()
276        if stderr:
277            raise Exception("unable to generate key: " + stderr)
278
279        try:
280            keystr = stdout.splitlines()[0].decode("ascii")
281            newkey = dnskey(keystr, self._dir, self.ttl)
282            return newkey
283        except:
284            raise Exception("unable to generate successor for key %s" % self)
285
286    @staticmethod
287    def algstr(alg):
288        name = None
289        if alg in range(len(dnskey._ALGNAMES)):
290            name = dnskey._ALGNAMES[alg]
291        return name if name else ("%03d" % alg)
292
293    @staticmethod
294    def algnum(alg):
295        if not alg:
296            return None
297        alg = alg.upper()
298        try:
299            return dnskey._ALGNAMES.index(alg)
300        except ValueError:
301            return None
302
303    def algname(self, alg=None):
304        return self.algstr(alg or self.alg)
305
306    @staticmethod
307    def timefromepoch(secs):
308        return time.gmtime(secs)
309
310    @staticmethod
311    def parsetime(string):
312        return time.strptime(string, "%Y%m%d%H%M%S")
313
314    @staticmethod
315    def epochfromtime(t):
316        return calendar.timegm(t)
317
318    @staticmethod
319    def formattime(t):
320        return time.strftime("%Y%m%d%H%M%S", t)
321
322    def setmeta(self, prop, secs, now, **kwargs):
323        force = kwargs.get("force", False)
324
325        if self._timestamps[prop] == secs:
326            return
327
328        if (
329            self._original[prop] is not None
330            and self._original[prop] < now
331            and not force
332        ):
333            raise TimePast(self, prop, self._original[prop])
334
335        if secs is None:
336            self._changed[prop] = False if self._original[prop] is None else True
337
338            self._delete[prop] = True
339            self._timestamps[prop] = None
340            self._times[prop] = None
341            self._fmttime[prop] = None
342            return
343
344        t = self.timefromepoch(secs)
345        self._timestamps[prop] = secs
346        self._times[prop] = t
347        self._fmttime[prop] = self.formattime(t)
348        self._changed[prop] = (
349            False if self._original[prop] == self._timestamps[prop] else True
350        )
351
352    def gettime(self, prop):
353        return self._times[prop]
354
355    def getfmttime(self, prop):
356        return self._fmttime[prop]
357
358    def gettimestamp(self, prop):
359        return self._timestamps[prop]
360
361    def created(self):
362        return self._timestamps["Created"]
363
364    def syncpublish(self):
365        return self._timestamps["SyncPublish"]
366
367    def setsyncpublish(self, secs, now=time.time(), **kwargs):
368        self.setmeta("SyncPublish", secs, now, **kwargs)
369
370    def publish(self):
371        return self._timestamps["Publish"]
372
373    def setpublish(self, secs, now=time.time(), **kwargs):
374        self.setmeta("Publish", secs, now, **kwargs)
375
376    def activate(self):
377        return self._timestamps["Activate"]
378
379    def setactivate(self, secs, now=time.time(), **kwargs):
380        self.setmeta("Activate", secs, now, **kwargs)
381
382    def revoke(self):
383        return self._timestamps["Revoke"]
384
385    def setrevoke(self, secs, now=time.time(), **kwargs):
386        self.setmeta("Revoke", secs, now, **kwargs)
387
388    def inactive(self):
389        return self._timestamps["Inactive"]
390
391    def setinactive(self, secs, now=time.time(), **kwargs):
392        self.setmeta("Inactive", secs, now, **kwargs)
393
394    def delete(self):
395        return self._timestamps["Delete"]
396
397    def setdelete(self, secs, now=time.time(), **kwargs):
398        self.setmeta("Delete", secs, now, **kwargs)
399
400    def syncdelete(self):
401        return self._timestamps["SyncDelete"]
402
403    def setsyncdelete(self, secs, now=time.time(), **kwargs):
404        self.setmeta("SyncDelete", secs, now, **kwargs)
405
406    def setttl(self, ttl):
407        if ttl is None or self.ttl == ttl:
408            return
409        elif self._origttl is None:
410            self._origttl = self.ttl
411            self.ttl = ttl
412        elif self._origttl == ttl:
413            self._origttl = None
414            self.ttl = ttl
415        else:
416            self.ttl = ttl
417
418    def keytype(self):
419        return "KSK" if self.sep else "ZSK"
420
421    def __str__(self):
422        return "%s/%s/%05d" % (self.name, self.algname(), self.keyid)
423
424    def __repr__(self):
425        return "%s/%s/%05d (%s)" % (
426            self.name,
427            self.algname(),
428            self.keyid,
429            ("KSK" if self.sep else "ZSK"),
430        )
431
432    def date(self):
433        return self.activate() or self.publish() or self.created()
434
435    # keys are sorted first by zone name, then by algorithm. within
436    # the same name/algorithm, they are sorted according to their
437    # 'date' value: the activation date if set, OR the publication
438    # if set, OR the creation date.
439    def __lt__(self, other):
440        if self.name != other.name:
441            return self.name < other.name
442        if self.alg != other.alg:
443            return self.alg < other.alg
444        return self.date() < other.date()
445
446    def check_prepub(self, output=None):
447        def noop(*args, **kwargs):
448            pass
449
450        if not output:
451            output = noop
452
453        now = int(time.time())
454        a = self.activate()
455        p = self.publish()
456
457        if not a:
458            return False
459
460        if not p:
461            if a > now:
462                output(
463                    "WARNING: Key %s is scheduled for\n"
464                    "\t activation but not for publication." % repr(self)
465                )
466            return False
467
468        if p <= now and a <= now:
469            return True
470
471        if p == a:
472            output(
473                "WARNING: %s is scheduled to be\n"
474                "\t published and activated at the same time. This\n"
475                "\t could result in a coverage gap if the zone was\n"
476                "\t previously signed. Activation should be at least\n"
477                "\t %s after publication."
478                % (repr(self), dnskey.duration(self.ttl) or "one DNSKEY TTL")
479            )
480            return True
481
482        if a < p:
483            output("WARNING: Key %s is active before it is published" % repr(self))
484            return False
485
486        if self.ttl is not None and a - p < self.ttl:
487            output(
488                "WARNING: Key %s is activated too soon\n"
489                "\t after publication; this could result in coverage \n"
490                "\t gaps due to resolver caches containing old data.\n"
491                "\t Activation should be at least %s after\n"
492                "\t publication."
493                % (repr(self), dnskey.duration(self.ttl) or "one DNSKEY TTL")
494            )
495            return False
496
497        return True
498
499    def check_postpub(self, output=None, timespan=None):
500        def noop(*args, **kwargs):
501            pass
502
503        if output is None:
504            output = noop
505
506        if timespan is None:
507            timespan = self.ttl
508
509        if timespan is None:
510            output("WARNING: Key %s using default TTL." % repr(self))
511            timespan = 60 * 60 * 24
512
513        now = time.time()
514        d = self.delete()
515        i = self.inactive()
516
517        if not d:
518            return False
519
520        if not i:
521            if d > now:
522                output(
523                    "WARNING: Key %s is scheduled for\n"
524                    "\t deletion but not for inactivation." % repr(self)
525                )
526            return False
527
528        if d < now and i < now:
529            return True
530
531        if d < i:
532            output(
533                "WARNING: Key %s is scheduled for\n"
534                "\t deletion before inactivation." % repr(self)
535            )
536            return False
537
538        if d - i < timespan:
539            output(
540                "WARNING: Key %s scheduled for\n"
541                "\t deletion too soon after deactivation; this may \n"
542                "\t result in coverage gaps due to resolver caches\n"
543                "\t containing old data.  Deletion should be at least\n"
544                "\t %s after inactivation." % (repr(self), dnskey.duration(timespan))
545            )
546            return False
547
548        return True
549
550    @staticmethod
551    def duration(secs):
552        if not secs:
553            return None
554
555        units = [
556            ("year", 60 * 60 * 24 * 365),
557            ("month", 60 * 60 * 24 * 30),
558            ("day", 60 * 60 * 24),
559            ("hour", 60 * 60),
560            ("minute", 60),
561            ("second", 1),
562        ]
563
564        output = []
565        for unit in units:
566            v, secs = secs // unit[1], secs % unit[1]
567            if v > 0:
568                output.append("%d %s%s" % (v, unit[0], "s" if v > 1 else ""))
569
570        return ", ".join(output)
571