dnssec_keys_management.py 39.9 KB
Newer Older
Hamza Dely's avatar
Hamza Dely committed
1
#!/usr/bin/env python3
2
# -*- coding: utf8 -*-
3
# pylint: disable=locally-disabled,invalid-name
4
"""The script allow to manage bind dnssec keys (generate new keys and handle key rollover)."""
Valentin Samir's avatar
Valentin Samir committed
5 6
import os
import sys
7
import binascii
Valentin Samir's avatar
Valentin Samir committed
8
import datetime
Valentin Samir's avatar
Valentin Samir committed
9
import subprocess
10
import argparse
11
import pwd
12
import collections
13
import configparser
14
from functools import total_ordering
15 16 17 18
try:
    import dns.resolver
except ImportError:
    dns = None
19

Valentin Samir's avatar
Valentin Samir committed
20

21 22 23 24 25 26 27 28 29 30 31
class Config(object):  # pylint: disable=locally-disabled,too-many-instance-attributes
    """Holds configuration for dnssec keys management."""

    # Directory where dnssec keys will be stored
    BASE = "/etc/bind/keys"

    # Interval between 2 operations on the dns keys.
    # For example if you have KEY1 enabled, KEY2 is published INTERVAL before disabling KEY1. KEY1
    # is disabled when KEY2 is activated, KEY1 is deleted INTERVAL after being disabled.
    # INTERVAL MUST be greater than the longest TTL DS records can have.
    # INTERVAL MUST also be higher than the bind signature interval (default 22.5 days)
32
    # This partially depends of the parent zone configuration and you do not necessarily have
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
    # control over it.
    INTERVAL = datetime.timedelta(days=23)

    # Time after which a ZSK is replaced by a new ZSK.
    # Generation of ZSK and activation / deactivation / deletion is managed automatically as long as
    # dnssec_keys_management.py -c is called at least once a day.
    ZSK_VALIDITY = datetime.timedelta(days=30)  # ~1 month

    # Time after which a new KSK is generated and published for the zone (and activated after
    # INTERVAL). The old key is removed only INTERVAL after the new key was
    # dnssec_keys_management.py --ds-seen.
    # This usually requires a manual operation with the registrar (publish DS of the new key
    # in the parent zone). dnssec_keys_management.py -c displays a message as long as --ds-seen
    # needs to be called and has not yet be called
    KSK_VALIDITY = datetime.timedelta(days=366)  # ~1 year

    # Algorithm used to generate new keys. Only the first created KSK and ZSK of a zone will use
Valentin Samir's avatar
Valentin Samir committed
50
    # this algorithm. Any renewing key will use the exact same parameters (name, algorithm, size,
51 52 53 54 55 56 57 58 59 60 61
    # and type) as the renewed key.
    ALGORITHM = "RSASHA256"

    SUPPORTED_ALGORITHMS = {
        8: "RSASHA256",
        10: "RSASHA512",
        12: "ECCGOST",
        13: "ECDSAP256SHA256",
        14: "ECDSAP384SHA384",
    }

62 63 64
    DS_ALGORITHMS = {
        1: 'SHA-1',
        2: 'SHA-256',
65
        3: 'GOST',
66 67 68
        4: 'SHA-384',
    }

69
    # Size of the created KSK. Only the first created KSK of a zone will use this size.
Valentin Samir's avatar
Valentin Samir committed
70
    # Any renewing key will use the exact same parameters (name, algorithm, size, and type)
71 72 73 74
    # as the renewed key
    KSK_SIZE = "2048"

    # Size of the created ZSK. Only the first created ZSK of a zone will use this size.
Valentin Samir's avatar
Valentin Samir committed
75
    # Any renewing key will use the exact same parameters (name, algorithm, size, and type)
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108
    # as the renewed key.
    ZSK_SIZE = "1024"

    # path to the dnssec-settime binary
    DNSSEC_SETTIME = "/usr/sbin/dnssec-settime"
    # path to the dnssec-dsfromkey binary
    DNSSEC_DSFROMKEY = "/usr/sbin/dnssec-dsfromkey"
    # path to the dnssec-keygen binary
    DNSSEC_KEYGEN = "/usr/sbin/dnssec-keygen"
    # path to the rndc binary
    RNDC = "/usr/sbin/rndc"

    # Possible config paths. The first path whose exists will be used as configuration
    config_paths = [
        os.path.abspath(os.path.join(os.path.dirname(__file__), "config.ini")),
        os.path.abspath(os.path.join(os.path.dirname(__file__), "dnssec_keys_management.ini")),
        os.path.abspath(os.path.expanduser("~/.config/dnssec_keys_management.ini")),
        "/etc/dnssec_keys_management.ini",
    ]

    def show(self):
        """Display config."""
        print("Key base path: %s" % self.BASE)
        print("Interval between two operation: %s" % self.INTERVAL)
        print("ZSK validity duration: %s" % self.ZSK_VALIDITY)
        print("KSK validity duration: %s" % self.KSK_VALIDITY)
        print("DNSKEY algorithm: %s" % self.ALGORITHM)
        print("KSK size: %s" % self.KSK_SIZE)
        print("ZSK size: %s" % self.ZSK_SIZE)
        print("")
        print("Path to dnssec-settime: %s" % self.DNSSEC_SETTIME)
        print("Path to dnssec-dsfromkey: %s" % self.DNSSEC_DSFROMKEY)
        print("Path to dnssec-keygen: %s" % self.DNSSEC_KEYGEN)
109
        print("Path to rndc: %s" % self. RNDC)
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196

    def __init__(self, path=None):
        """Parse the config file and update attributes accordingly."""
        if path is None:
            for path in self.config_paths:
                if os.path.isfile(path):
                    self._parse(path)
                    break
        else:
            self._parse(path)
        self.check_paths()

    def _parse(self, config_file):
        config_parser = configparser.ConfigParser()
        config_parser.read(config_file)
        self._parse_dnssec_section(config_parser)
        self._parse_path_section(config_parser)

    def _parse_dnssec_section(self, config_parser):
        if config_parser.has_section("dnssec"):
            if config_parser.has_option("dnssec", "base_directory"):
                self.BASE = config_parser.get("dnssec", "base_directory")
            if config_parser.has_option("dnssec", "interval"):
                try:
                    self.INTERVAL = datetime.timedelta(
                        days=config_parser.getfloat("dnssec", "interval")
                    )
                except ValueError:
                    print(
                        "Unable to convert the config parameter 'interval' to a float",
                        file=sys.stderr
                    )
            if config_parser.has_option("dnssec", "zsk_validity"):
                try:
                    self.ZSK_VALIDITY = datetime.timedelta(
                        days=config_parser.getfloat("dnssec", "zsk_validity")
                    )
                except ValueError:
                    print(
                        "Unable to convert the config parameter 'zsk_validity' to a float",
                        file=sys.stderr
                    )
            if config_parser.has_option("dnssec", "ksk_validity"):
                try:
                    self.KSK_VALIDITY = datetime.timedelta(
                        days=config_parser.getfloat("dnssec", "ksk_validity")
                    )
                except ValueError:
                    print(
                        "Unable to convert the config parameter 'ksk_validity' to a float",
                        file=sys.stderr
                    )
            if config_parser.has_option("dnssec", "algorithm"):
                self.ALGORITHM = config_parser.get("dnssec", "algorithm")
                if self.ALGORITHM not in self.SUPPORTED_ALGORITHMS.values():
                    raise ValueError(
                        "Invalid algorithm %s."
                        "Supported algorithms are %s" % (
                            self.ALGORITHM, ", ".join(self.SUPPORTED_ALGORITHMS.values())
                        )
                    )
            if config_parser.has_option("dnssec", "zsk_size"):
                self.ZSK_SIZE = config_parser.get("dnssec", "zsk_size")

            if config_parser.has_option("dnssec", "ksk_size"):
                self.KSK_SIZE = config_parser.get("dnssec", "ksk_size")

    def _parse_path_section(self, config_parser):
        if config_parser.has_section("path"):
            if config_parser.has_option("path", "dnssec_settime"):
                self.DNSSEC_SETTIME = config_parser.get("path", "dnssec_settime")
            if config_parser.has_option("path", "dnssec_dsfromkey"):
                self.DNSSEC_DSFROMKEY = config_parser.get("path", "dnssec_dsfromkey")
            if config_parser.has_option("path", "dnssec_keygen"):
                self.DNSSEC_KEYGEN = config_parser.get("path", "dnssec_keygen")
            if config_parser.has_option("path", "rndc"):
                self.RNDC = config_parser.get("path", "rndc")

    def check_paths(self):
        """Check config path to needed binaries."""
        for path in [self.DNSSEC_SETTIME, self.DNSSEC_DSFROMKEY, self.DNSSEC_KEYGEN, self.RNDC]:
            if not os.path.isfile(path) or not os.access(path, os.X_OK):
                raise ValueError(
                    "%s not found or not executable. Is bind9utils installed ?\n" % path
                )


197
def get_zones(zone_names=None, config=None):
198 199 200
    """
    Return a list of :class:`Zone` instances.

201
    :param Config config: A :class:`Config` instance
202 203 204
    :param list zone_names: If provider return :class:`Zone` instance for the zone provided.
        Otherwise, return :class:`Zone` instance for all founded zones
    """
205 206 207
    if config is None:
        config = Config()
    zones = []
Valentin Samir's avatar
Valentin Samir committed
208
    if zone_names is None:
209 210 211
        for f in os.listdir(config.BASE):
            if os.path.isdir(os.path.join(config.BASE, f)) and not f.startswith('.'):
                zones.append(Zone(f, config=config))
Valentin Samir's avatar
Valentin Samir committed
212 213
    else:
        for name in zone_names:
214 215
            zones.append(Zone(name, config=config))
    return zones
Valentin Samir's avatar
Valentin Samir committed
216

Valentin Samir's avatar
Valentin Samir committed
217

Valentin Samir's avatar
Valentin Samir committed
218
def bind_chown(path):
219
    """Give the files to the bind user and sets the modes in a relevant way."""
220 221 222 223
    try:
        bind_uid = pwd.getpwnam('bind').pw_uid
        os.chown(path, bind_uid, -1)
        for root, dirs, files in os.walk(path):
224 225 226 227
            for dir_ in dirs:
                os.chown(os.path.join(root, dir_), bind_uid, -1)
            for file_ in files:
                os.chown(os.path.join(root, file_), bind_uid, -1)
228
    except KeyError:
229
        print("User bind not found, failing to give keys ownership to bind", file=sys.stderr)
Valentin Samir's avatar
Valentin Samir committed
230

Valentin Samir's avatar
Valentin Samir committed
231

232
def bind_reload(config=None):
233
    """Reload bind config."""
234 235 236
    if config is None:
        config = Config()
    cmd = [config.RNDC, "reload"]
Valentin Samir's avatar
Valentin Samir committed
237 238 239
    p = subprocess.Popen(cmd)
    p.wait()

Valentin Samir's avatar
Valentin Samir committed
240

Valentin Samir's avatar
Valentin Samir committed
241
class Zone(object):
242 243
    """Allow to manage dnssec keys for a dns zone."""

Valentin Samir's avatar
Valentin Samir committed
244 245 246
    ZSK = None
    KSK = None
    name = None
247 248
    _path = None
    _cfg = None
Valentin Samir's avatar
Valentin Samir committed
249 250

    def __str__(self):
251
        """Zone name."""
Valentin Samir's avatar
Valentin Samir committed
252 253 254
        return self.name

    def __repr__(self):
255
        """Zone representation."""
Valentin Samir's avatar
Valentin Samir committed
256 257 258
        return "Zone %s" % self.name

    @classmethod
259
    def create(cls, name, config=None):
260
        """Create the zone keys storage directory and return a :class:`Zone` instance."""
261 262 263
        if config is None:
            config = Config()
        path = os.path.join(config.BASE, name)
Valentin Samir's avatar
Valentin Samir committed
264
        if os.path.isdir(path):
265
            raise ValueError("%s exists" % path)
Valentin Samir's avatar
Valentin Samir committed
266 267
        os.mkdir(path)
        bind_chown(path)
268
        return cls(name, config=config)
Valentin Samir's avatar
Valentin Samir committed
269

270 271 272 273
    def nsec3(self, salt=None):
        """Enable NSEC3 for the zone ``zone``."""
        if salt is None:
            salt = binascii.hexlify(os.urandom(24)).decode()
274
        cmd = [self._cfg.RNDC, "signing", "-nsec3param", "1", "0", "10", salt, self.name]
275 276 277 278 279 280
        print("Enabling nsec3 for zone %s: " % self.name, file=sys.stdout)
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
        out = p.communicate()[0].decode()
        print(out, file=sys.stdout)
        p.wait()

Valentin Samir's avatar
Valentin Samir committed
281
    def do_zsk(self):
282
        """Perform daily routine on ZSK keys (generate new keys, delete old ones...)."""
283
        last_activate_zsk = None
Valentin Samir's avatar
Valentin Samir committed
284
        for zsk in self.ZSK:
285
            if zsk.is_activate and not zsk.is_inactive:
286 287
                zsk.inactive = zsk.activate + self._cfg.ZSK_VALIDITY
                zsk.delete = zsk.inactive + self._cfg.INTERVAL
288
                last_activate_zsk = zsk
289
        now = datetime.datetime.utcnow()
290
        zsk = self.ZSK[-1]
Valentin Samir's avatar
Valentin Samir committed
291
        if zsk.is_activate:
292 293
            zsk.inactive = max(zsk.inactive, now + self._cfg.INTERVAL)
            zsk.delete = zsk.inactive + self._cfg.INTERVAL
Valentin Samir's avatar
Valentin Samir committed
294
            zsk.gen_successor()
295
            bind_reload(self._cfg)
296
        elif last_activate_zsk is not None:
297
            zsk.activate = last_activate_zsk.inactive
298 299
        else:
            raise RuntimeError("No ZSK is activated, this should never happen")
Valentin Samir's avatar
Valentin Samir committed
300

301
    def do_ksk(self):
302
        """Perform daily routine on KSK keys (generate new keys...)."""
303 304 305
        ksk = self.KSK[-1]
        if ksk.need_renew:
            now = datetime.datetime.utcnow()
306
            new_ksk = Key.create("KSK", self.name, config=self._cfg)
307 308
            # do not activate the new key until ds-seen
            new_ksk.activate = None
309
            new_ksk.publish = now
310
            bind_reload(self._cfg)
Valentin Samir's avatar
Valentin Samir committed
311 312
        active_ksk = [key for key in self.KSK if key.is_publish and key.delete is None]
        if len(active_ksk) >= 2:
313
            print(
Valentin Samir's avatar
Valentin Samir committed
314 315
                (
                    "New KSK needs DS seen and/or old KSK needs "
316 317 318
                    "inactivate/remove for zone %s"
                ) % self.name,
                file=sys.stderr
Valentin Samir's avatar
Valentin Samir committed
319
            )
320

321 322 323 324
    def _get_ds_from_parents(self):
        parent = '.'.join(self.name.split('.')[1:])
        if not parent:
            parent = '.'
325
        nameservers = {
326
            ns.to_text(): [ip.to_text() for ip in dns.resolver.query(ns.to_text())]
327 328
            for ns in dns.resolver.query(parent, 'NS')
        }
329

330 331
        ds = {}
        for ns, ns_ips in nameservers.items():
332 333 334
            for ns_ip in ns_ips:
                r = dns.resolver.Resolver()
                r.nameservers = [ns_ip]
335 336
                ds[(ns, ns_ip)] = list(r.query(self.name, 'DS'))
        return ds
337

338
    def ds_check(self, keyid, key=None):
339 340 341 342 343 344
        """
        Check if a DS with ``keyid`` is present in the parent zone.

        :param int keyid: A key id
        :param Key key: A :class:`Key` instance
        """
345
        if dns is None:
346
            print("Python dnspython module not available, check failed", file=sys.stderr)
347
            return False
348 349 350
        if key is None:
            key = self._get_key_by_id(keyid)[0]
        if key is not None:
351 352
            ds_records = self._get_ds_from_parents()
            missing = collections.defaultdict(list)
353 354
            bad_digest = collections.defaultdict(list)
            founds = {}
355
            for (ns, ns_ip), ds in ds_records.items():
356 357 358 359 360 361 362 363 364 365 366 367 368
                keyids = set()
                for d in ds:
                    if d.key_tag == keyid:
                        if key is None:
                            break
                        algorithm = self._cfg.DS_ALGORITHMS[d.digest_type]
                        if d.digest == key.ds_digest(algorithm):
                            break
                        else:
                            bad_digest[ns].append(ns_ip)
                            break
                    keyids.add(d.key_tag)
                else:
369
                    missing[ns].append(ns_ip)
370
                    founds[(ns, ns_ip)] = keyids
371 372
            if missing or bad_digest:
                if missing:
373 374
                    print("DS not found on the following parent servers:", file=sys.stderr)
                    keyids = None
375
                    for ns, ns_ips in missing.items():
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395
                        print(" * %s (%s)" % (ns, ', '.join(ns_ips)), file=sys.stderr)
                        for ip in ns_ips:
                            if keyids is None:
                                keyids = founds[(ns, ip)]
                            else:
                                keyids &= founds[(ns, ip)]
                    keyids_list = list(keyids)
                    keyids_list.sort()
                    print(
                        "Found keys are %s" % ', '.join(str(id_) for id_ in keyids_list),
                        file=sys.stderr
                    )
                if bad_digest:
                    print(
                        "DS found but digest do not match on the following parent servers:",
                        file=sys.stderr
                    )
                    for ns, ns_ips in bad_digest.items():
                        print(" * %s (%s)" % (ns, ', '.join(ns_ips)), file=sys.stderr)
                return False
396
            else:
397 398
                print("DS for key %s found on all parent servers" % keyid)
                return True
399
        else:
400 401
            print("Key not found", file=sys.stderr)
            return False
402

403
    def _get_key_by_id(self, keyid):
404 405 406 407 408 409 410
        old_ksks = []
        for ksk in self.KSK:
            if ksk.keyid == keyid:
                seen_ksk = ksk
                break
            old_ksks.append(ksk)
        else:
411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436

            return None, []
        return seen_ksk, old_ksks

    def ds_seen(self, keyid, check=True):
        """Tell that the DS for the KSK ``keyid`` has been seen, programming KSK rotation."""
        seen_ksk, old_ksks = self._get_key_by_id(keyid)
        if seen_ksk is not None:
            if check:
                if not self.ds_check(keyid, key=seen_ksk):
                    print(
                        "You may use --no-check to bypass this check and force --ds-seen",
                        file=sys.stderr
                    )
                    return
            now = datetime.datetime.utcnow()
            if seen_ksk.activate is None:
                seen_ksk.activate = (now + self._cfg.INTERVAL)
            for ksk in old_ksks:
                print(" * program key %s removal" % ksk.keyid)
                # set inactive in at least INTERVAL
                ksk.inactive = seen_ksk.activate
                # delete INTERVAL after being inactive
                ksk.delete = ksk.inactive + self._cfg.INTERVAL
            bind_reload(self._cfg)
        else:
437
            print("Key not found", file=sys.stderr)
438 439

    def remove_deleted(self):
440
        """Move deleted keys to the deleted folder."""
441 442 443 444 445 446
        deleted_path = os.path.join(self._path, "deleted")
        try:
            os.mkdir(deleted_path)
        except OSError as error:
            if error.errno != 17:  # File exists
                raise
447
        now = datetime.datetime.utcnow()
448
        for key in self.ZSK + self.KSK:
449
            key.remove_deleted(deleted_path, now=now)
450

451
    def ds(self, algorithm=None):
452
        """Display the DS of the KSK of the zone."""
Valentin Samir's avatar
Valentin Samir committed
453
        for ksk in self.KSK:
454 455 456 457 458
            if algorithm == 'all':
                for algo in self._cfg.DS_ALGORITHMS.values():
                    sys.stdout.write(ksk.ds(algorithm=algo))
            else:
                sys.stdout.write(ksk.ds(algorithm=algorithm))
Valentin Samir's avatar
Valentin Samir committed
459

460
    def key(self, show_ksk=False, show_zsk=False):
461
        """Display the public keys of the KSK and/or ZSK."""
462 463
        if show_ksk:
            for ksk in self.KSK:
464
                print(ksk)
465 466
        if show_zsk:
            for zsk in self.ZSK:
467
                print(zsk)
468 469

    @staticmethod
470
    def _key_table_format(znl, show_all=False):
Valentin Samir's avatar
Valentin Samir committed
471
        format_string = "|{!s:^%d}|{}|{!s:>5}|" % znl
472
        if show_all:
473
            format_string += "{algorithm!s:^19}|"
474 475
            format_string += "{created!s:^19}|"
        format_string += "{!s:^19}|{!s:^19}|{!s:^19}|{!s:^19}|"
476
        separator = ("+" + "-" * znl + "+-+-----+" + ("-" * 19 + "+") * (6 if show_all else 4))
477
        return format_string, separator
478 479

    @classmethod
480 481
    def _key_table_header(cls, znl, show_all=False):
        (format_string, separator) = cls._key_table_format(znl, show_all)
482 483
        print(separator)
        print(format_string.format(
Valentin Samir's avatar
Valentin Samir committed
484
            "Zone name", "T", "KeyId", "Publish", "Activate",
485
            "Inactive", "Delete", created="Created", algorithm="Algorithm"
486
        ))
487
        print(separator)
488

489
    def _key_table_body(self, znl, show_all=False):
490
        format_string = self._key_table_format(znl, show_all)[0]
491
        for ksk in self.KSK:
492
            print(format_string.format(
493 494
                ksk.zone_name,
                "K",
Valentin Samir's avatar
Valentin Samir committed
495
                ksk.keyid,
496 497 498 499 500
                ksk.publish or "N/A",
                ksk.activate or "N/A",
                ksk.inactive or "N/A",
                ksk.delete or "N/A",
                created=ksk.created or "N/A",
501
                algorithm=ksk.algorithm or "N/A",
502 503
            ))
        for zsk in self.ZSK:
504
            print(format_string.format(
505 506
                zsk.zone_name,
                "Z",
Valentin Samir's avatar
Valentin Samir committed
507
                zsk.keyid,
508 509 510 511 512
                zsk.publish or "N/A",
                zsk.activate or "N/A",
                zsk.inactive or "N/A",
                zsk.delete or "N/A",
                created=zsk.created or "N/A",
513
                algorithm=zsk.algorithm or "N/A",
514 515 516
            ))

    @classmethod
517
    def _key_table_footer(cls, znl, show_all=False):
518
        separator = cls._key_table_format(znl, show_all)[1]
519
        print(separator)
520

521 522
    @classmethod
    def key_table(cls, zones, show_all=False):
523
        """Show meta data for the zone keys in a table."""
524 525 526
        znl = max(9, *[len(zone.name) for zone in zones])
        cls._key_table_header(znl, show_all)
        for zone in zones:
527
            # noinspection PyProtectedMember
528 529
            zone._key_table_body(znl, show_all)  # pylint: disable=locally-disabled,protected-access
        cls._key_table_footer(znl, show_all)
Valentin Samir's avatar
Valentin Samir committed
530

531
    def __init__(self, name, config=None):
532
        """Read every keys attached to the zone. If not keys is found, generate new ones."""
533 534 535 536 537
        if config is None:
            self._cfg = Config()
        else:
            self._cfg = config
        path = os.path.join(self._cfg.BASE, name)
Valentin Samir's avatar
Valentin Samir committed
538
        if not os.path.isdir(path):
Valentin Samir's avatar
Valentin Samir committed
539
            raise ValueError("%s is not a directory" % path)
Valentin Samir's avatar
Valentin Samir committed
540 541 542 543
        self.name = name
        self._path = path
        self.ZSK = []
        self.KSK = []
544 545
        for file_ in os.listdir(path):
            file_path = os.path.join(path, file_)
546
            if os.path.isfile(file_path) and file_path.endswith(".private"):
Valentin Samir's avatar
Valentin Samir committed
547
                try:
548
                    key = Key(file_path, config=self._cfg)
Valentin Samir's avatar
Valentin Samir committed
549 550 551 552 553 554 555
                    if key.type == "ZSK":
                        self.ZSK.append(key)
                    elif key.type == "KSK":
                        self.KSK.append(key)
                    else:
                        raise RuntimeError("impossible")
                except ValueError as error:
556
                    print("%s" % error, file=sys.stderr)
Valentin Samir's avatar
Valentin Samir committed
557 558 559
        self.ZSK.sort()
        self.KSK.sort()
        if not self.ZSK:
560
            self.ZSK.append(Key.create("ZSK", name, config=self._cfg))
561
            self.do_zsk()
Valentin Samir's avatar
Valentin Samir committed
562
        if not self.KSK:
563
            self.KSK.append(Key.create("KSK", name, config=self._cfg))
564
            self.do_ksk()
Valentin Samir's avatar
Valentin Samir committed
565

Valentin Samir's avatar
Valentin Samir committed
566

Valentin Samir's avatar
Valentin Samir committed
567 568
@total_ordering
class Key(object):
569 570
    """Allow to manage a specific dnssec key."""

571
    # pylint: disable=locally-disabled,too-many-instance-attributes
Valentin Samir's avatar
Valentin Samir committed
572 573 574 575 576 577 578
    _created = None
    _publish = None
    _activate = None
    _inactive = None
    _delete = None
    _data = None
    _path = None
579
    _cfg = None
Valentin Samir's avatar
Valentin Samir committed
580 581
    type = None
    keyid = None
582 583
    flag = None
    zone_name = None
584
    algorithm = None
Valentin Samir's avatar
Valentin Samir committed
585 586

    def __str__(self):
587
        """Verbatim content of the key file."""
Valentin Samir's avatar
Valentin Samir committed
588 589 590
        return self._data

    def __repr__(self):
591
        """Path to the key file."""
Valentin Samir's avatar
Valentin Samir committed
592 593
        r = os.path.basename(self._path)
        return r
Valentin Samir's avatar
Valentin Samir committed
594

595 596
    @staticmethod
    def _date_from_key(date):
Valentin Samir's avatar
Valentin Samir committed
597 598
        if date is not None:
            return datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
Valentin Samir's avatar
Valentin Samir committed
599

600 601
    @staticmethod
    def _date_to_key(date):
602 603 604 605
        if date is None:
            return 'none'
        else:
            return datetime.datetime.strftime(date, "%Y%m%d%H%M%S")
Valentin Samir's avatar
Valentin Samir committed
606

607 608 609 610 611 612 613 614 615
    def _date_check(self, value, needed_date, value_name, needed_date_name):
        if value is not None:
            if needed_date is None or value < needed_date:
                raise RuntimeError(
                    "Cannot set %s date before %s date on key %s on zone %s" % (
                        value_name,
                        needed_date_name,
                        self.keyid,
                        self.zone_name
Valentin Samir's avatar
Valentin Samir committed
616
                    )
617 618 619 620
                )

    def _date_check2(self, value, needed_date, value_name, needed_date_name):
        msg = "Cannot set %s date after %s date on key %s on zone %s" % (
Valentin Samir's avatar
Valentin Samir committed
621 622 623 624 625
            value_name,
            needed_date_name,
            self.keyid,
            self.zone_name
        )
626 627 628 629 630 631
        if value is None and needed_date is not None:
            raise RuntimeError(msg)
        elif value is not None and needed_date is not None:
            if value > needed_date:
                raise RuntimeError(msg)

Valentin Samir's avatar
Valentin Samir committed
632
    @classmethod
633
    def create(cls, typ, name, options=None, config=None):
634
        """
635
        Create a new dnssec key.
636 637 638 639

        :param str typ: The type of the key to create. Most be 'KSK' or 'ZSK'.
        :param str name: The zone name for which we are creating the key.
        :param list options: An optional list of extra parameters to pass to DNSSEC_KEYGEN binary.
640
        :param Config config: A :class:`Config` object
641
        """
642 643
        if config is None:
            config = Config()
644 645
        if options is None:
            options = []
646 647
        path = os.path.join(config.BASE, name)
        cmd = [config.DNSSEC_KEYGEN, "-a", config.ALGORITHM]
Valentin Samir's avatar
Valentin Samir committed
648
        if typ == "KSK":
649
            cmd.extend(["-b", config.KSK_SIZE, "-f", "KSK"])
Valentin Samir's avatar
Valentin Samir committed
650
        elif typ == "ZSK":
651
            cmd.extend(["-b", config.ZSK_SIZE])
652 653
        else:
            raise ValueError("typ must be KSK or ZSK")
654
        cmd.extend(options)
655
        cmd.extend(["-K", path, name])
Valentin Samir's avatar
Valentin Samir committed
656 657 658
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
        p.wait()
        if p.returncode != 0:
Valentin Samir's avatar
Valentin Samir committed
659
            raise ValueError("The key creation has failed")
660
        keyname = p.communicate()[0].strip().decode()
Valentin Samir's avatar
Valentin Samir committed
661
        bind_chown(path)
662
        return cls(os.path.join(path, "%s.private" % keyname), config=config)
Valentin Samir's avatar
Valentin Samir committed
663

Valentin Samir's avatar
Valentin Samir committed
664
    def gen_successor(self):
665 666 667 668 669
        """
        Create a new key which is an explicit successor to the current key.

        The name, algorithm, size, and type of the key will be set to match the existing key.
        The activation date of the new key will be set to the inactivation date of the existing one.
670
        The publication date will be set to the activation date minus the pre-publication interval.
671
        """
Valentin Samir's avatar
Valentin Samir committed
672
        cmd = [
673
            self._cfg.DNSSEC_KEYGEN, "-i", str(int(self._cfg.INTERVAL.total_seconds())),
Valentin Samir's avatar
Valentin Samir committed
674 675
            "-S", self._path, "-K", os.path.dirname(self._path)
        ]
Valentin Samir's avatar
Valentin Samir committed
676
        p = subprocess.Popen(cmd, stderr=subprocess.PIPE)
677
        err = p.communicate()[1].decode()
Valentin Samir's avatar
Valentin Samir committed
678 679 680
        if p.returncode != 0:
            raise ValueError("err %s: %s" % (p.returncode, err))
        if err:
681
            print(err, file=sys.stderr)
Valentin Samir's avatar
Valentin Samir committed
682 683
        bind_chown(os.path.dirname(self._path))

684 685 686
    def settime(self, flag, date):
        """Set the time of the flag ``flag`` for the key to ``date``."""
        cmd = [
687 688
            self._cfg.DNSSEC_SETTIME,
            "-i", str(int(self._cfg.INTERVAL.total_seconds())),
689 690 691 692 693 694 695 696 697
            "-%s" % flag, date, self._path
        ]
        p = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
        err = p.communicate()[1].decode()
        if p.returncode != 0:
            raise ValueError("err %s: %s" % (p.returncode, err))
        if err:
            print("%s" % err, file=sys.stderr)

Valentin Samir's avatar
Valentin Samir committed
698 699
    @property
    def created(self):
700
        """Date of creation of the key."""
701 702
        if self._created is not None:
            return self._date_from_key(self._created)
Valentin Samir's avatar
Valentin Samir committed
703 704 705

    @property
    def publish(self):
706
        """Date of publication of the key."""
707 708
        if self._publish is not None:
            return self._date_from_key(self._publish)
Valentin Samir's avatar
Valentin Samir committed
709

Valentin Samir's avatar
Valentin Samir committed
710 711
    @publish.setter
    def publish(self, value):
712 713
        self._date_check(value, self.created, "publish", "created")
        self._date_check2(value, self.activate, "publish", "activate")
Valentin Samir's avatar
Valentin Samir committed
714 715
        date = self._date_to_key(value)
        if date != self._publish:
716
            self.settime('P', date)
Valentin Samir's avatar
Valentin Samir committed
717
            self._publish = date
Valentin Samir's avatar
Valentin Samir committed
718
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
719 720 721 722
                self._data = f.read()

    @property
    def activate(self):
723
        """Date of activation of the key."""
724 725
        if self._activate is not None:
            return self._date_from_key(self._activate)
Valentin Samir's avatar
Valentin Samir committed
726

Valentin Samir's avatar
Valentin Samir committed
727 728
    @activate.setter
    def activate(self, value):
729 730
        self._date_check(value, self.publish, "active", "publish")
        self._date_check2(value, self.inactive, "activate", "inactive")
Valentin Samir's avatar
Valentin Samir committed
731 732
        date = self._date_to_key(value)
        if date != self._activate:
733
            self.settime('A', date)
Valentin Samir's avatar
Valentin Samir committed
734
            self._activate = date
Valentin Samir's avatar
Valentin Samir committed
735
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
736 737 738 739
                self._data = f.read()

    @property
    def inactive(self):
740
        """Date of inactivation of the key."""
741 742
        if self._inactive is not None:
            return self._date_from_key(self._inactive)
Valentin Samir's avatar
Valentin Samir committed
743

Valentin Samir's avatar
Valentin Samir committed
744 745
    @inactive.setter
    def inactive(self, value):
746 747
        self._date_check(value, self.activate, "inactive", "activate")
        self._date_check2(value, self.delete, "inactive", "delete")
Valentin Samir's avatar
Valentin Samir committed
748 749
        date = self._date_to_key(value)
        if date != self._inactive:
750
            self.settime('I', date)
Valentin Samir's avatar
Valentin Samir committed
751
            self._inactive = date
Valentin Samir's avatar
Valentin Samir committed
752
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
753 754 755 756
                self._data = f.read()

    @property
    def delete(self):
757
        """Date of deletion of the key."""
758 759
        if self._delete:
            return self._date_from_key(self._delete)
Valentin Samir's avatar
Valentin Samir committed
760

Valentin Samir's avatar
Valentin Samir committed
761 762
    @delete.setter
    def delete(self, value):
763
        self._date_check(value, self.inactive, "delete", "inactive")
Valentin Samir's avatar
Valentin Samir committed
764 765
        date = self._date_to_key(value)
        if date != self._delete:
766
            self.settime('D', date)
Valentin Samir's avatar
Valentin Samir committed
767
            self._delete = date
Valentin Samir's avatar
Valentin Samir committed
768
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
769 770
                self._data = f.read()

771 772
    @property
    def is_publish(self):
773
        """``True``if the key is published."""
774
        return self.publish is not None and self.publish <= datetime.datetime.utcnow()
Valentin Samir's avatar
Valentin Samir committed
775

776 777
    @property
    def is_activate(self):
778
        """``True``if the key is activated."""
779
        return self.activate is not None and self.activate <= datetime.datetime.utcnow()
Valentin Samir's avatar
Valentin Samir committed
780

781 782
    @property
    def is_inactive(self):
783
        """``True``if the key is inactivated."""
784
        return self.inactive is not None and self.inactive <= datetime.datetime.utcnow()
Valentin Samir's avatar
Valentin Samir committed
785

786 787
    @property
    def is_delete(self):
788
        """``True``if the key is deleted."""
789 790 791 792
        return self.delete is not None and self.delete <= datetime.datetime.utcnow()

    @property
    def need_renew(self):
793
        """``True`` is the current key needs to be renewed."""
794
        if self.type == "KSK":
795 796
            return (
                self.activate is not None and
797 798 799 800
                (
                    (self.activate + self._cfg.KSK_VALIDITY) <=
                    (datetime.datetime.utcnow() + self._cfg.INTERVAL)
                )
801
            )
802
        elif self.type == "ZSK":
803
            return (
804 805 806 807 808
                self.activate is not None and
                (
                    (self.activate + self._cfg.ZSK_VALIDITY) <=
                    (datetime.datetime.utcnow() + self._cfg.INTERVAL)
                )
809
            )
810 811 812
        else:
            raise RuntimeError("impossible")

813
    def ds(self, algorithm=None):
814
        """Display the DS of the key."""
815 816 817 818 819 820
        cmd = [self._cfg.DNSSEC_DSFROMKEY]
        if algorithm is not None:
            cmd.extend(['-a', algorithm])
        cmd.append(self._path)
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        out, err = p.communicate()
821
        p.wait()
822
        if err:
823
            print(err.decode('utf-8').strip(), file=sys.stderr)
824 825 826
        return out.decode('utf-8')

    def ds_digest(self, algorithm):
827
        """Return raw DS digest of the key computed with ``algorithm``."""
828 829
        ds = self.ds(algorithm)
        return binascii.a2b_hex(ds.split()[-1])
830 831 832 833 834 835 836 837 838 839 840 841

    def remove_deleted(self, deleted_path, now=None):
        """Move deleted keys to the deleted folder."""
        if now is None:
            now = datetime.datetime.utcnow()
        if self.delete and (self.delete + self._cfg.INTERVAL) <= now:
            for path in [self._path, self._path_private]:
                basename = os.path.basename(path)
                new_path = os.path.join(deleted_path, basename)
                os.rename(path, new_path)

    def __init__(self, path, config=None):
842
        """Parse the dnssec key file ``path``."""
843 844 845 846
        if config is None:
            self._cfg = Config()
        else:
            self._cfg = config
847
        if not path.endswith(".private"):
Valentin Samir's avatar
Valentin Samir committed
848
            raise ValueError("%s is not a valid private key (should ends with .private)" % path)
849
        if not os.path.isfile(path):
Valentin Samir's avatar
Valentin Samir committed
850
            raise ValueError("%s do not exists" % path)
851 852 853
        self._path = "%s.key" % path[:-8]
        if not os.path.isfile(self._path):
            raise ValueError("The public key (%s) of %s does not exist" % (self._path, path))
854 855 856 857 858 859 860 861 862 863 864
        self._path_private = path
        self._parse_public_key()
        self._parse_private_key()

        if self.flag == 256:
            self.type = "ZSK"
        elif self.flag == 257:
            self.type = "KSK"
        else:
            raise ValueError(
                "%s is not a valid key: flag %s unknown (known ones are 256 and 257)" % (
865
                    self._path,
866 867 868 869 870 871
                    self.flag
                )
            )

    def _parse_public_key(self):
        with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
872
            self._data = f.read()
873 874 875 876 877 878
        for line in self._data.split("\n"):
            if line.startswith(";") or not line:
                continue
            line = line.split(";", 1)[0].strip()
            line = line.split()
            if len(line) < 7:
Valentin Samir's avatar
Valentin Samir committed
879
                raise ValueError(
880
                    "The public key %s should have at least 7 fields: %r" % (self._path, line)
Valentin Samir's avatar
Valentin Samir committed
881
                )
882
            if not line[0].endswith('.'):
Valentin Samir's avatar
Valentin Samir committed
883 884
                raise ValueError(
                    (
Valentin Samir's avatar
Valentin Samir committed
885
                        "The public key %s should begin with the zone fqdn (ending with a .)"
886
                    ) % self._path
Valentin Samir's avatar
Valentin Samir committed
887
                )
888 889 890 891
            self.zone_name = line[0][:-1]
            try:
                self.flag = int(line[3])
            except ValueError:
Valentin Samir's avatar
Valentin Samir committed
892
                raise ValueError(
893
                    "The flag %s of the public key %s should be an integer" % (line[3], self._path)
Valentin Samir's avatar
Valentin Samir committed
894
                )
895 896 897

    def _parse_private_key(self):
        keyid = self._path_private.split('.')[-2].split('+')[-1]
898 899 900
        try:
            self.keyid = int(keyid)
        except ValueError:
901 902 903 904 905
            raise ValueError(
                "The keyid %s of the key %s should be an integer" % (keyid, self._path_private)
            )
        with open(self._path_private, 'r') as f:
            private_data = f.read()
906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921
        for line in private_data.split("\n"):
            if line.startswith("Created:"):
                self._created = line[8:].strip()
                self._date_from_key(self._created)
            elif line.startswith("Publish:"):
                self._publish = line[8:].strip()
                self._date_from_key(self._publish)
            elif line.startswith("Activate:"):
                self._activate = line[9:].strip()
                self._date_from_key(self._activate)
            elif line.startswith("Inactive:"):
                self._inactive = line[9:].strip()
                self._date_from_key(self._inactive)
            elif line.startswith("Delete:"):
                self._delete = line[7:].strip()
                self._date_from_key(self._delete)
922 923
            elif line.startswith("Algorithm:"):
                algorithm = int(line[11:13].strip())
924 925 926 927
                self.algorithm = self._cfg.SUPPORTED_ALGORITHMS.get(
                    algorithm,
                    "Unknown (%d)" % algorithm
                )
928
        if self.created is None:
929 930 931
            raise ValueError(
                "The key %s must have as list its Created field defined" % self._path_private
            )
Valentin Samir's avatar
Valentin Samir committed
932 933

    def __lt__(self, y):
934 935 936 937 938 939 940
        """
        Allow to compare two keys.

        Comparison is done on the keys activation date is possible, if not on the publication
        date, and finally, if not possible, on the creation date.
        Keys always have a creation date.
        """
Valentin Samir's avatar
Valentin Samir committed
941 942 943 944 945 946 947 948
        if not isinstance(y, Key):
            raise ValueError("can only compare two Keys")
        if self.activate is not None and y.activate is not None:
            return self.activate < y.activate
        elif self.publish is not None and y.publish is not None:
            return self.publish < y.publish
        else:
            return self.created < y.created
Valentin Samir's avatar
Valentin Samir committed
949 950

    def __eq__(self, y):
951 952 953 954 955
        """
        Allow to check if two key instances are equals.

        Two key instances are equals if they point to the same key file.
        """
956
        # pylint: disable=locally-disabled,protected-access
957
        # noinspection PyProtectedMember
Valentin Samir's avatar
Valentin Samir committed
958 959
        return isinstance(y, Key) and y._path == self._path

Valentin Samir's avatar
Valentin Samir committed
960

961
def parse_arguments(config):
962
    """Parse command line arguments."""
963 964 965 966 967 968 969
    parser = argparse.ArgumentParser()
    parser.add_argument('zone', nargs='*', help='A dns zone name.')
    parser.add_argument(
        '--config',
        help=(
            "Path to a config file. If not specified, the first file found "
            "among %s will be used." % ", ".join(Config.config_paths)
Valentin Samir's avatar
Valentin Samir committed
970
        )
971 972 973 974 975 976 977 978 979 980 981 982 983
    )
    parser.add_argument(
        '--make', '-m',
        action='store_true',
        help='Create initials keys for each supplied zone'
    )
    parser.add_argument(
        '--cron', '-c',
        action='store_true',
        help='Perform maintenance for each supplied zone or for all zones if no zone supplied'
    )
    parser.add_argument(
        '--ds',
984
        choices=list(config.DS_ALGORITHMS.values()) + ['all'],
985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003
        help='Show KSK DS for each supplied zone or for all zones if no zone supplied'
    )
    parser.add_argument(
        '--key',
        nargs='?', const="all", type=str, choices=["all", "ksk", "zsk"],
        help='Show DNSKEY for each zone supplied zone or for all zones if no zone supplied'
    )
    parser.add_argument(
        '--key-table',
        nargs='?', const="default", type=str, choices=["default", "all_fields"],
        help='Show a table with all non deleted DNSKEY meaningful dates'
    )
    parser.add_argument(
        '--ds-seen',
        metavar='KEYID',
        type=int,
        help=(
            'To call with the ID of a new KSK published in the parent zone. '
            'Programs old KSK removal. '
1004
            'If will check that the KSK DS appear on each servers of the parent '
1005
            'zone, except if called with --no-check.'
Valentin Samir's avatar
Valentin Samir committed
1006
        )
1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018
    )
    parser.add_argument(
        '--no-check',
        action='store_true',
        help='Allow to bypass DS check from parent zone in --ds-seen'
    )
    parser.add_argument(
        '--ds-check',
        metavar='KEYID',
        type=int,
        help=(
            'To call with the ID of a KSK published in the parent zone. '
1019
            'Check that the KSK DS appear on each servers of the parent zone. '
1020
        )
1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035
    )
    parser.add_argument(
        '--nsec3',
        action='store_true',
        help='Enable NSEC3 for the zones, using a random salt'
    )
    parser.add_argument(
        '--show-config',
        action='store_true',
        help='Show the current configuration'
    )
    return parser


def main():  # pylint: disable=locally-disabled,too-many-branches
1036
    """Run functions based on command line arguments."""
1037
    config = Config()
1038 1039
    parser = parse_arguments(config)
    args = parser.parse_args()
1040 1041 1042 1043 1044 1045
    zones = args.zone
    if args.show_config:
        config.show()
    if args.make:
        for zone in zones:
            Zone.create(zone, config=config)
1046
    zones = get_zones(zones if zones else None, config=config)
1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066
    if args.nsec3:
        for zone in zones:
            zone.nsec3()
    if args.ds_check:
        if len(zones) != 1:
            sys.exit("Please specify exactly ONE zone name\n")
        for zone in zones:
            zone.ds_check(args.ds_check)
    if args.ds_seen:
        if len(zones) != 1:
            sys.exit("Please specify exactly ONE zone name\n")
        for zone in zones:
            zone.ds_seen(args.ds_seen, check=not args.no_check)
    if args.cron:
        for zone in zones:
            zone.do_zsk()
            zone.do_ksk()
            zone.remove_deleted()
    if args.ds:
        for zone in zones:
1067
            zone.ds(args.ds)
1068 1069 1070 1071 1072 1073 1074
    if args.key:
        for zone in zones:
            zone.key(show_ksk=args.key in ["all", "ksk"],
                     show_zsk=args.key in ["all", "zsk"])
    if args.key_table:
        Zone.key_table(zones, args.key_table == "all_fields")
    if not any([
Valentin Samir's avatar
Valentin Samir committed
1075
            args.make, args.cron, args.ds, args.key, args.ds_seen, args.nsec3,
1076
            args.show_config, args.key_table, args.ds_check
1077 1078 1079 1080 1081 1082 1083
    ]):
        parser.print_help()


if __name__ == '__main__':
    try:
        main()
1084 1085
    except (ValueError, IOError) as main_error:
        sys.exit("%s" % main_error)