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