dnssec_keys_management.py 40.3 KB
Newer Older
Hamza Dely's avatar
Hamza Dely committed
1
#!/usr/bin/env python3
2
# -*- coding: utf8 -*-
3 4 5 6 7 8 9 10 11 12 13 14
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License version 3 for
# more details.
#
# You should have received a copy of the GNU General Public License version 3
# along with this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
# (c) 2015-2018 Valentin Samir
# (c) 2017 Hamza Dely
#
15
# pylint: disable=locally-disabled,invalid-name
16
"""The script allow to manage bind dnssec keys (generate new keys and handle key rollover)."""
Valentin Samir's avatar
Valentin Samir committed
17 18
import os
import sys
19
import binascii
Valentin Samir's avatar
Valentin Samir committed
20
import datetime
Valentin Samir's avatar
Valentin Samir committed
21
import subprocess
22
import argparse
23
import pwd
24
import collections
25
import configparser
26
from functools import total_ordering
27 28 29 30
try:
    import dns.resolver
except ImportError:
    dns = None
31

Valentin Samir's avatar
Valentin Samir committed
32

33 34 35 36 37 38 39 40 41 42 43
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)
44
    # This partially depends of the parent zone configuration and you do not necessarily have
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
    # 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
62
    # this algorithm. Any renewing key will use the exact same parameters (name, algorithm, size,
63 64 65 66 67 68 69 70 71 72 73
    # and type) as the renewed key.
    ALGORITHM = "RSASHA256"

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

74 75 76
    DS_ALGORITHMS = {
        1: 'SHA-1',
        2: 'SHA-256',
77
        3: 'GOST',
78 79 80
        4: 'SHA-384',
    }

81
    # Size of the created KSK. Only the first created KSK of a zone will use this size.
Valentin Samir's avatar
Valentin Samir committed
82
    # Any renewing key will use the exact same parameters (name, algorithm, size, and type)
83 84 85 86
    # 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
87
    # Any renewing key will use the exact same parameters (name, algorithm, size, and type)
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
    # 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)
121
        print("Path to rndc: %s" % self. RNDC)
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 197 198 199 200 201 202 203 204 205 206 207 208

    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
                )


209
def get_zones(zone_names=None, config=None):
210 211 212
    """
    Return a list of :class:`Zone` instances.

213
    :param Config config: A :class:`Config` instance
214 215 216
    :param list zone_names: If provider return :class:`Zone` instance for the zone provided.
        Otherwise, return :class:`Zone` instance for all founded zones
    """
217 218 219
    if config is None:
        config = Config()
    zones = []
Valentin Samir's avatar
Valentin Samir committed
220
    if zone_names is None:
221 222 223
        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
224 225
    else:
        for name in zone_names:
226 227
            zones.append(Zone(name, config=config))
    return zones
Valentin Samir's avatar
Valentin Samir committed
228

Valentin Samir's avatar
Valentin Samir committed
229

Valentin Samir's avatar
Valentin Samir committed
230
def bind_chown(path):
231
    """Give the files to the bind user and sets the modes in a relevant way."""
232 233 234 235
    try:
        bind_uid = pwd.getpwnam('bind').pw_uid
        os.chown(path, bind_uid, -1)
        for root, dirs, files in os.walk(path):
236 237 238 239
            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)
240
    except KeyError:
241
        print("User bind not found, failing to give keys ownership to bind", file=sys.stderr)
Valentin Samir's avatar
Valentin Samir committed
242

Valentin Samir's avatar
Valentin Samir committed
243

244
def bind_reload(config=None):
245
    """Reload bind config."""
246 247 248
    if config is None:
        config = Config()
    cmd = [config.RNDC, "reload"]
Valentin Samir's avatar
Valentin Samir committed
249 250 251
    p = subprocess.Popen(cmd)
    p.wait()

Valentin Samir's avatar
Valentin Samir committed
252

Valentin Samir's avatar
Valentin Samir committed
253
class Zone(object):
254 255
    """Allow to manage dnssec keys for a dns zone."""

Valentin Samir's avatar
Valentin Samir committed
256 257 258
    ZSK = None
    KSK = None
    name = None
259 260
    _path = None
    _cfg = None
Valentin Samir's avatar
Valentin Samir committed
261 262

    def __str__(self):
263
        """Zone name."""
Valentin Samir's avatar
Valentin Samir committed
264 265 266
        return self.name

    def __repr__(self):
267
        """Zone representation."""
Valentin Samir's avatar
Valentin Samir committed
268 269 270
        return "Zone %s" % self.name

    @classmethod
271
    def create(cls, name, config=None):
272
        """Create the zone keys storage directory and return a :class:`Zone` instance."""
273 274 275
        if config is None:
            config = Config()
        path = os.path.join(config.BASE, name)
Valentin Samir's avatar
Valentin Samir committed
276
        if os.path.isdir(path):
277
            raise ValueError("%s exists" % path)
Valentin Samir's avatar
Valentin Samir committed
278 279
        os.mkdir(path)
        bind_chown(path)
280
        return cls(name, config=config)
Valentin Samir's avatar
Valentin Samir committed
281

282 283 284 285
    def nsec3(self, salt=None):
        """Enable NSEC3 for the zone ``zone``."""
        if salt is None:
            salt = binascii.hexlify(os.urandom(24)).decode()
286
        cmd = [self._cfg.RNDC, "signing", "-nsec3param", "1", "0", "10", salt, self.name]
287 288 289 290 291 292
        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
293
    def do_zsk(self):
294
        """Perform daily routine on ZSK keys (generate new keys, delete old ones...)."""
295
        last_activate_zsk = None
Valentin Samir's avatar
Valentin Samir committed
296
        for zsk in self.ZSK:
297
            if zsk.is_activate and not zsk.is_inactive:
298 299
                zsk.inactive = zsk.activate + self._cfg.ZSK_VALIDITY
                zsk.delete = zsk.inactive + self._cfg.INTERVAL
300
                last_activate_zsk = zsk
301
        now = datetime.datetime.utcnow()
302
        zsk = self.ZSK[-1]
Valentin Samir's avatar
Valentin Samir committed
303
        if zsk.is_activate:
304 305
            zsk.inactive = max(zsk.inactive, now + self._cfg.INTERVAL)
            zsk.delete = zsk.inactive + self._cfg.INTERVAL
Valentin Samir's avatar
Valentin Samir committed
306
            zsk.gen_successor()
307
            bind_reload(self._cfg)
308
        elif last_activate_zsk is not None:
309
            zsk.activate = last_activate_zsk.inactive
310 311
        else:
            raise RuntimeError("No ZSK is activated, this should never happen")
Valentin Samir's avatar
Valentin Samir committed
312

313
    def do_ksk(self):
314
        """Perform daily routine on KSK keys (generate new keys...)."""
315 316 317
        ksk = self.KSK[-1]
        if ksk.need_renew:
            now = datetime.datetime.utcnow()
318
            new_ksk = Key.create("KSK", self.name, config=self._cfg)
319 320
            # do not activate the new key until ds-seen
            new_ksk.activate = None
321
            new_ksk.publish = now
322
            bind_reload(self._cfg)
Valentin Samir's avatar
Valentin Samir committed
323 324
        active_ksk = [key for key in self.KSK if key.is_publish and key.delete is None]
        if len(active_ksk) >= 2:
325
            print(
Valentin Samir's avatar
Valentin Samir committed
326 327
                (
                    "New KSK needs DS seen and/or old KSK needs "
328 329 330
                    "inactivate/remove for zone %s"
                ) % self.name,
                file=sys.stderr
Valentin Samir's avatar
Valentin Samir committed
331
            )
332

333
    def _get_ds_from_parents(self):
334
        parent = '.'.join(self.name.split('.')[1:]) + '.'
335
        nameservers = {
336
            ns.to_text(): [ip.to_text() for ip in dns.resolver.query(ns.to_text())]
337 338
            for ns in dns.resolver.query(parent, 'NS')
        }
339

340 341
        ds = {}
        for ns, ns_ips in nameservers.items():
342 343 344
            for ns_ip in ns_ips:
                r = dns.resolver.Resolver()
                r.nameservers = [ns_ip]
345 346
                ds[(ns, ns_ip)] = list(r.query(self.name, 'DS'))
        return ds
347

348
    def ds_check(self, keyid, key=None):
349 350 351 352 353 354
        """
        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
        """
355
        if dns is None:
356
            print("Python dnspython module not available, check failed", file=sys.stderr)
357
            return False
358 359 360
        if key is None:
            key = self._get_key_by_id(keyid)[0]
        if key is not None:
361 362
            ds_records = self._get_ds_from_parents()
            missing = collections.defaultdict(list)
363 364
            bad_digest = collections.defaultdict(list)
            founds = {}
365
            for (ns, ns_ip), ds in ds_records.items():
366 367 368 369 370 371 372 373 374 375 376 377 378
                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:
379
                    missing[ns].append(ns_ip)
380
                    founds[(ns, ns_ip)] = keyids
381 382
            if missing or bad_digest:
                if missing:
383 384
                    print("DS not found on the following parent servers:", file=sys.stderr)
                    keyids = None
385
                    for ns, ns_ips in missing.items():
386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405
                        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
406
            else:
407 408
                print("DS for key %s found on all parent servers" % keyid)
                return True
409
        else:
410 411
            print("Key not found", file=sys.stderr)
            return False
412

413
    def _get_key_by_id(self, keyid):
414 415 416 417 418 419 420
        old_ksks = []
        for ksk in self.KSK:
            if ksk.keyid == keyid:
                seen_ksk = ksk
                break
            old_ksks.append(ksk)
        else:
421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446

            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:
447
            print("Key not found", file=sys.stderr)
448 449

    def remove_deleted(self):
450
        """Move deleted keys to the deleted folder."""
451 452 453 454 455 456
        deleted_path = os.path.join(self._path, "deleted")
        try:
            os.mkdir(deleted_path)
        except OSError as error:
            if error.errno != 17:  # File exists
                raise
457
        now = datetime.datetime.utcnow()
458
        for key in self.ZSK + self.KSK:
459
            key.remove_deleted(deleted_path, now=now)
460

461
    def ds(self, algorithm=None):
462
        """Display the DS of the KSK of the zone."""
Valentin Samir's avatar
Valentin Samir committed
463
        for ksk in self.KSK:
464 465 466 467 468
            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
469

470
    def key(self, show_ksk=False, show_zsk=False):
471
        """Display the public keys of the KSK and/or ZSK."""
472 473
        if show_ksk:
            for ksk in self.KSK:
474
                print(ksk)
475 476
        if show_zsk:
            for zsk in self.ZSK:
477
                print(zsk)
478 479

    @staticmethod
480
    def _key_table_format(znl, show_all=False):
Valentin Samir's avatar
Valentin Samir committed
481
        format_string = "|{!s:^%d}|{}|{!s:>5}|" % znl
482
        if show_all:
483
            format_string += "{algorithm!s:^19}|"
484 485
            format_string += "{created!s:^19}|"
        format_string += "{!s:^19}|{!s:^19}|{!s:^19}|{!s:^19}|"
486
        separator = ("+" + "-" * znl + "+-+-----+" + ("-" * 19 + "+") * (6 if show_all else 4))
487
        return format_string, separator
488 489

    @classmethod
490 491
    def _key_table_header(cls, znl, show_all=False):
        (format_string, separator) = cls._key_table_format(znl, show_all)
492 493
        print(separator)
        print(format_string.format(
Valentin Samir's avatar
Valentin Samir committed
494
            "Zone name", "T", "KeyId", "Publish", "Activate",
495
            "Inactive", "Delete", created="Created", algorithm="Algorithm"
496
        ))
497
        print(separator)
498

499
    def _key_table_body(self, znl, show_all=False):
500
        format_string = self._key_table_format(znl, show_all)[0]
501
        for ksk in self.KSK:
502
            print(format_string.format(
503 504
                ksk.zone_name,
                "K",
Valentin Samir's avatar
Valentin Samir committed
505
                ksk.keyid,
506 507 508 509 510
                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",
511
                algorithm=ksk.algorithm or "N/A",
512 513
            ))
        for zsk in self.ZSK:
514
            print(format_string.format(
515 516
                zsk.zone_name,
                "Z",
Valentin Samir's avatar
Valentin Samir committed
517
                zsk.keyid,
518 519 520 521 522
                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",
523
                algorithm=zsk.algorithm or "N/A",
524 525 526
            ))

    @classmethod
527
    def _key_table_footer(cls, znl, show_all=False):
528
        separator = cls._key_table_format(znl, show_all)[1]
529
        print(separator)
530

531 532
    @classmethod
    def key_table(cls, zones, show_all=False):
533
        """Show meta data for the zone keys in a table."""
534 535 536
        znl = max(9, *[len(zone.name) for zone in zones])
        cls._key_table_header(znl, show_all)
        for zone in zones:
537
            # noinspection PyProtectedMember
538 539
            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
540

541
    def __init__(self, name, config=None):
542
        """Read every keys attached to the zone. If not keys is found, generate new ones."""
543 544 545 546 547
        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
548
        if not os.path.isdir(path):
Valentin Samir's avatar
Valentin Samir committed
549
            raise ValueError("%s is not a directory" % path)
Valentin Samir's avatar
Valentin Samir committed
550 551 552 553
        self.name = name
        self._path = path
        self.ZSK = []
        self.KSK = []
554 555
        for file_ in os.listdir(path):
            file_path = os.path.join(path, file_)
556
            if os.path.isfile(file_path) and file_path.endswith(".private"):
Valentin Samir's avatar
Valentin Samir committed
557
                try:
558
                    key = Key(file_path, config=self._cfg)
Valentin Samir's avatar
Valentin Samir committed
559 560 561 562 563 564 565
                    if key.type == "ZSK":
                        self.ZSK.append(key)
                    elif key.type == "KSK":
                        self.KSK.append(key)
                    else:
                        raise RuntimeError("impossible")
                except ValueError as error:
566
                    print("%s" % error, file=sys.stderr)
Valentin Samir's avatar
Valentin Samir committed
567 568 569
        self.ZSK.sort()
        self.KSK.sort()
        if not self.ZSK:
570
            self.ZSK.append(Key.create("ZSK", name, config=self._cfg))
571
            self.do_zsk()
Valentin Samir's avatar
Valentin Samir committed
572
        if not self.KSK:
573
            self.KSK.append(Key.create("KSK", name, config=self._cfg))
574
            self.do_ksk()
Valentin Samir's avatar
Valentin Samir committed
575

Valentin Samir's avatar
Valentin Samir committed
576

Valentin Samir's avatar
Valentin Samir committed
577 578
@total_ordering
class Key(object):
579 580
    """Allow to manage a specific dnssec key."""

581
    # pylint: disable=locally-disabled,too-many-instance-attributes
Valentin Samir's avatar
Valentin Samir committed
582 583 584 585 586 587 588
    _created = None
    _publish = None
    _activate = None
    _inactive = None
    _delete = None
    _data = None
    _path = None
589
    _cfg = None
Valentin Samir's avatar
Valentin Samir committed
590 591
    type = None
    keyid = None
592 593
    flag = None
    zone_name = None
594
    algorithm = None
Valentin Samir's avatar
Valentin Samir committed
595 596

    def __str__(self):
597
        """Verbatim content of the key file."""
Valentin Samir's avatar
Valentin Samir committed
598 599 600
        return self._data

    def __repr__(self):
601
        """Path to the key file."""
Valentin Samir's avatar
Valentin Samir committed
602 603
        r = os.path.basename(self._path)
        return r
Valentin Samir's avatar
Valentin Samir committed
604

605 606
    @staticmethod
    def _date_from_key(date):
Valentin Samir's avatar
Valentin Samir committed
607 608
        if date is not None:
            return datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
Valentin Samir's avatar
Valentin Samir committed
609

610 611
    @staticmethod
    def _date_to_key(date):
612 613 614 615
        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
616

617 618 619 620 621 622 623 624 625
    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
626
                    )
627 628 629 630
                )

    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
631 632 633 634 635
            value_name,
            needed_date_name,
            self.keyid,
            self.zone_name
        )
636 637 638 639 640 641
        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
642
    @classmethod
643
    def create(cls, typ, name, options=None, config=None):
644
        """
645
        Create a new dnssec key.
646 647 648 649

        :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.
650
        :param Config config: A :class:`Config` object
651
        """
652 653
        if config is None:
            config = Config()
654 655
        if options is None:
            options = []
656 657
        path = os.path.join(config.BASE, name)
        cmd = [config.DNSSEC_KEYGEN, "-a", config.ALGORITHM]
Valentin Samir's avatar
Valentin Samir committed
658
        if typ == "KSK":
659
            cmd.extend(["-b", config.KSK_SIZE, "-f", "KSK"])
Valentin Samir's avatar
Valentin Samir committed
660
        elif typ == "ZSK":
661
            cmd.extend(["-b", config.ZSK_SIZE])
662 663
        else:
            raise ValueError("typ must be KSK or ZSK")
664
        cmd.extend(options)
665
        cmd.extend(["-K", path, name])
Valentin Samir's avatar
Valentin Samir committed
666 667 668
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
        p.wait()
        if p.returncode != 0:
Valentin Samir's avatar
Valentin Samir committed
669
            raise ValueError("The key creation has failed")
670
        keyname = p.communicate()[0].strip().decode()
Valentin Samir's avatar
Valentin Samir committed
671
        bind_chown(path)
672
        return cls(os.path.join(path, "%s.private" % keyname), config=config)
Valentin Samir's avatar
Valentin Samir committed
673

Valentin Samir's avatar
Valentin Samir committed
674
    def gen_successor(self):
675 676 677 678 679
        """
        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.
680
        The publication date will be set to the activation date minus the pre-publication interval.
681
        """
Valentin Samir's avatar
Valentin Samir committed
682
        cmd = [
683
            self._cfg.DNSSEC_KEYGEN, "-i", str(int(self._cfg.INTERVAL.total_seconds())),
Valentin Samir's avatar
Valentin Samir committed
684 685
            "-S", self._path, "-K", os.path.dirname(self._path)
        ]
Valentin Samir's avatar
Valentin Samir committed
686
        p = subprocess.Popen(cmd, stderr=subprocess.PIPE)
687
        err = p.communicate()[1].decode()
Valentin Samir's avatar
Valentin Samir committed
688 689 690
        if p.returncode != 0:
            raise ValueError("err %s: %s" % (p.returncode, err))
        if err:
691
            print(err, file=sys.stderr)
Valentin Samir's avatar
Valentin Samir committed
692 693
        bind_chown(os.path.dirname(self._path))

694 695 696
    def settime(self, flag, date):
        """Set the time of the flag ``flag`` for the key to ``date``."""
        cmd = [
697 698
            self._cfg.DNSSEC_SETTIME,
            "-i", str(int(self._cfg.INTERVAL.total_seconds())),
699 700 701 702 703 704 705 706 707
            "-%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
708 709
    @property
    def created(self):
710
        """Date of creation of the key."""
711 712
        if self._created is not None:
            return self._date_from_key(self._created)
Valentin Samir's avatar
Valentin Samir committed
713 714 715

    @property
    def publish(self):
716
        """Date of publication of the key."""
717 718
        if self._publish is not None:
            return self._date_from_key(self._publish)
Valentin Samir's avatar
Valentin Samir committed
719

Valentin Samir's avatar
Valentin Samir committed
720 721
    @publish.setter
    def publish(self, value):
722 723
        self._date_check(value, self.created, "publish", "created")
        self._date_check2(value, self.activate, "publish", "activate")
Valentin Samir's avatar
Valentin Samir committed
724 725
        date = self._date_to_key(value)
        if date != self._publish:
726
            self.settime('P', date)
Valentin Samir's avatar
Valentin Samir committed
727
            self._publish = date
Valentin Samir's avatar
Valentin Samir committed
728
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
729 730 731 732
                self._data = f.read()

    @property
    def activate(self):
733
        """Date of activation of the key."""
734 735
        if self._activate is not None:
            return self._date_from_key(self._activate)
Valentin Samir's avatar
Valentin Samir committed
736

Valentin Samir's avatar
Valentin Samir committed
737 738
    @activate.setter
    def activate(self, value):
739 740
        self._date_check(value, self.publish, "active", "publish")
        self._date_check2(value, self.inactive, "activate", "inactive")
Valentin Samir's avatar
Valentin Samir committed
741 742
        date = self._date_to_key(value)
        if date != self._activate:
743
            self.settime('A', date)
Valentin Samir's avatar
Valentin Samir committed
744
            self._activate = date
Valentin Samir's avatar
Valentin Samir committed
745
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
746 747 748 749
                self._data = f.read()

    @property
    def inactive(self):
750
        """Date of inactivation of the key."""
751 752
        if self._inactive is not None:
            return self._date_from_key(self._inactive)
Valentin Samir's avatar
Valentin Samir committed
753

Valentin Samir's avatar
Valentin Samir committed
754 755
    @inactive.setter
    def inactive(self, value):
756 757
        self._date_check(value, self.activate, "inactive", "activate")
        self._date_check2(value, self.delete, "inactive", "delete")
Valentin Samir's avatar
Valentin Samir committed
758 759
        date = self._date_to_key(value)
        if date != self._inactive:
760
            self.settime('I', date)
Valentin Samir's avatar
Valentin Samir committed
761
            self._inactive = date
Valentin Samir's avatar
Valentin Samir committed
762
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
763 764 765 766
                self._data = f.read()

    @property
    def delete(self):
767
        """Date of deletion of the key."""
768 769
        if self._delete:
            return self._date_from_key(self._delete)
Valentin Samir's avatar
Valentin Samir committed
770

Valentin Samir's avatar
Valentin Samir committed
771 772
    @delete.setter
    def delete(self, value):
773
        self._date_check(value, self.inactive, "delete", "inactive")
Valentin Samir's avatar
Valentin Samir committed
774 775
        date = self._date_to_key(value)
        if date != self._delete:
776
            self.settime('D', date)
Valentin Samir's avatar
Valentin Samir committed
777
            self._delete = date
Valentin Samir's avatar
Valentin Samir committed
778
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
779 780
                self._data = f.read()

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

786 787
    @property
    def is_activate(self):
788
        """``True``if the key is activated."""
789
        return self.activate is not None and self.activate <= datetime.datetime.utcnow()
Valentin Samir's avatar
Valentin Samir committed
790

791 792
    @property
    def is_inactive(self):
793
        """``True``if the key is inactivated."""
794
        return self.inactive is not None and self.inactive <= datetime.datetime.utcnow()
Valentin Samir's avatar
Valentin Samir committed
795

796 797
    @property
    def is_delete(self):
798
        """``True``if the key is deleted."""
799 800 801 802
        return self.delete is not None and self.delete <= datetime.datetime.utcnow()

    @property
    def need_renew(self):
803
        """``True`` is the current key needs to be renewed."""
804
        if self.type == "KSK":
805 806
            return (
                self.activate is not None and
807 808 809 810
                (
                    (self.activate + self._cfg.KSK_VALIDITY) <=
                    (datetime.datetime.utcnow() + self._cfg.INTERVAL)
                )
811
            )
812
        elif self.type == "ZSK":
813
            return (
814 815 816 817 818
                self.activate is not None and
                (
                    (self.activate + self._cfg.ZSK_VALIDITY) <=
                    (datetime.datetime.utcnow() + self._cfg.INTERVAL)
                )
819
            )
820 821 822
        else:
            raise RuntimeError("impossible")

823
    def ds(self, algorithm=None):
824
        """Display the DS of the key."""
825 826 827 828 829 830
        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()
831
        p.wait()
832
        if err:
833
            print(err.decode('utf-8').strip(), file=sys.stderr)
834 835 836
        return out.decode('utf-8')

    def ds_digest(self, algorithm):
837
        """Return raw DS digest of the key computed with ``algorithm``."""
838 839
        ds = self.ds(algorithm)
        return binascii.a2b_hex(ds.split()[-1])
840 841 842 843 844 845 846 847 848 849 850 851

    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):
852
        """Parse the dnssec key file ``path``."""
853 854 855 856
        if config is None:
            self._cfg = Config()
        else:
            self._cfg = config
857
        if not path.endswith(".private"):
Valentin Samir's avatar
Valentin Samir committed
858
            raise ValueError("%s is not a valid private key (should ends with .private)" % path)
859
        if not os.path.isfile(path):
Valentin Samir's avatar
Valentin Samir committed
860
            raise ValueError("%s do not exists" % path)
861 862 863
        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))
864 865 866 867 868 869 870 871 872 873 874
        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)" % (
875
                    self._path,
876 877 878 879 880 881
                    self.flag
                )
            )

    def _parse_public_key(self):
        with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
882
            self._data = f.read()
883 884 885 886 887 888
        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
889
                raise ValueError(
890
                    "The public key %s should have at least 7 fields: %r" % (self._path, line)
Valentin Samir's avatar
Valentin Samir committed
891
                )
892
            if not line[0].endswith('.'):
Valentin Samir's avatar
Valentin Samir committed
893 894
                raise ValueError(
                    (
Valentin Samir's avatar
Valentin Samir committed
895
                        "The public key %s should begin with the zone fqdn (ending with a .)"
896
                    ) % self._path
Valentin Samir's avatar
Valentin Samir committed
897
                )
898 899 900 901
            self.zone_name = line[0][:-1]
            try:
                self.flag = int(line[3])
            except ValueError:
Valentin Samir's avatar
Valentin Samir committed
902
                raise ValueError(
903
                    "The flag %s of the public key %s should be an integer" % (line[3], self._path)
Valentin Samir's avatar
Valentin Samir committed
904
                )
905 906 907

    def _parse_private_key(self):
        keyid = self._path_private.split('.')[-2].split('+')[-1]
908 909 910
        try:
            self.keyid = int(keyid)
        except ValueError:
911 912 913 914 915
            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()
916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931
        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)
932 933
            elif line.startswith("Algorithm:"):
                algorithm = int(line[11:13].strip())
934 935 936 937
                self.algorithm = self._cfg.SUPPORTED_ALGORITHMS.get(
                    algorithm,
                    "Unknown (%d)" % algorithm
                )
938
        if self.created is None:
939 940 941
            raise ValueError(
                "The key %s must have as list its Created field defined" % self._path_private
            )
Valentin Samir's avatar
Valentin Samir committed
942 943

    def __lt__(self, y):
944 945 946 947 948 949 950
        """
        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
951 952 953 954 955 956 957 958
        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
959 960

    def __eq__(self, y):
961 962 963 964 965
        """
        Allow to check if two key instances are equals.

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

Valentin Samir's avatar
Valentin Samir committed
970

971
def parse_arguments(config):
972
    """Parse command line arguments."""
973 974 975 976 977 978 979
    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
980
        )
981 982 983 984 985 986 987 988 989 990 991 992 993
    )
    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',
994
        choices=list(config.DS_ALGORITHMS.values()) + ['all'],
995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013
        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. '
1014
            'If will check that the KSK DS appear on each servers of the parent '
1015
            'zone, except if called with --no-check.'
Valentin Samir's avatar
Valentin Samir committed
1016
        )
1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028
    )
    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. '
1029
            'Check that the KSK DS appear on each servers of the parent zone. '
1030
        )
1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045
    )
    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
1046
    """Run functions based on command line arguments."""
1047
    config = Config()
1048 1049
    parser = parse_arguments(config)
    args = parser.parse_args()
1050 1051 1052 1053 1054 1055
    zones = args.zone
    if args.show_config:
        config.show()
    if args.make:
        for zone in zones:
            Zone.create(zone, config=config)
1056
    zones = get_zones(zones if zones else None, config=config)
1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076
    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:
1077
            zone.ds(args.ds)
1078 1079 1080 1081 1082 1083 1084
    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
1085
            args.make, args.cron, args.ds, args.key, args.ds_seen, args.nsec3,
1086
            args.show_config, args.key_table, args.ds_check
1087 1088 1089 1090 1091 1092 1093
    ]):
        parser.print_help()


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