Skip to content

Commit 178c587

Browse files
committed
Migrate to the ECDSAP256SHA256 (13) DNSSEC algorithm
* Stop generating RSASHA1-NSEC3-SHA1 keys on new installs since it is no longer recommended, but preserve the key on existing installs so that we continue to sign zones with existing keys to retain the chain of trust with existing DS records. * Start generating ECDSAP256SHA256 keys during setup, the current best practice (in addition to RSASHA256 which is also ok). See https://www.iana.org/assignments/dns-sec-alg-numbers/dns-sec-alg-numbers.xhtml#dns-sec-alg-numbers-1 and https://www.cloudflare.com/dns/dnssec/ecdsa-and-dnssec/. * Sign zones using all available keys rather than choosing just one based on the TLD to enable rotation/migration to the new key and to give the user some options since not every registrar/TLD supports every algorithm. * Allow a user to drop a key from signing specific domains using DOMAINS= in our key configuration file. Signing the zones with extraneous keys may increase the size of DNS responses, which isn't ideal, although I don't know if this is a problem in practice. (Although a user can delete the RSASHA1-NSEC3-SHA1 key file, the other keys will be re-generated on upgrade.) * When generating zonefiles, add a hash of all of the DNSSEC signing keys so that when the keys change the zone is definitely regenerated and re-signed. * In status checks, if DNSSEC is not active (or not valid), offer to use all of the keys that have been generated (for RSASHA1-NSEC3-SHA1 on existing installs, RSASHA256, and now ECDSAP256SHA256) with all digest types, since not all registers support everything, but list them in an order that guides users to the best practice. * In status checks, if the deployed DS record doesn't use a ECDSAP256SHA256 key, prompt the user to update their DS record. * In status checks, if multiple DS records are set, only fail if none are valid. If some use ECDSAP256SHA256 and some don't, remind the user to delete the DS records that don't. * Don't fail if the DS record uses the SHA384 digest (by pre-generating a DS record with that digest type) but don't recommend it because it is not in the IANA mandatory list yet (https://www.iana.org/assignments/ds-rr-types/ds-rr-types.xhtml). See #1953
1 parent 34569d2 commit 178c587

File tree

4 files changed

+210
-126
lines changed

4 files changed

+210
-126
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
In Development
5+
--------------
6+
7+
* Migrate to the ECDSAP256SHA256 DNSSEC algorithm. If a DS record is set for any of your domain names that have DNS hosted on your box, you will be prompted by status checks to update the DS record.
8+
49
v0.53 (April 12, 2021)
510
----------------------
611

management/dns_update.py

Lines changed: 94 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ def build_sshfp_records():
429429
# to the zone file (that trigger bumping the serial number). However,
430430
# if SSH has been configured to listen on a nonstandard port, we must
431431
# specify that port to sshkeyscan.
432+
432433
port = 22
433434
with open('/etc/ssh/sshd_config', 'r') as f:
434435
for line in f:
@@ -439,8 +440,11 @@ def build_sshfp_records():
439440
except ValueError:
440441
pass
441442
break
443+
442444
keys = shell("check_output", ["ssh-keyscan", "-t", "rsa,dsa,ecdsa,ed25519", "-p", str(port), "localhost"])
443-
for key in sorted(keys.split("\n")):
445+
keys = sorted(keys.split("\n"))
446+
447+
for key in keys:
444448
if key.strip() == "" or key[0] == "#": continue
445449
try:
446450
host, keytype, pubkey = key.split(" ")
@@ -460,13 +464,16 @@ def write_nsd_zone(domain, zonefile, records, env, force):
460464
# On the $ORIGIN line, there's typically a ';' comment at the end explaining
461465
# what the $ORIGIN line does. Any further data after the domain confuses
462466
# ldns-signzone, however. It used to say '; default zone domain'.
463-
467+
#
464468
# The SOA contact address for all of the domains on this system is hostmaster
465469
# @ the PRIMARY_HOSTNAME. Hopefully that's legit.
466-
470+
#
467471
# For the refresh through TTL fields, a good reference is:
468472
# http://www.peerwisdom.org/2013/05/15/dns-understanding-the-soa-record/
469-
473+
#
474+
# A hash of the available DNSSEC keys are added in a comment so that when
475+
# the keys change we force a re-generation of the zone which triggers
476+
# re-signing it.
470477

471478
zone = """
472479
$ORIGIN {domain}.
@@ -502,6 +509,9 @@ def write_nsd_zone(domain, zonefile, records, env, force):
502509
value = v2
503510
zone += value + "\n"
504511

512+
# Append a stable hash of DNSSEC signing keys in a comment.
513+
zone += "\n; DNSSEC signing keys hash: {}\n".format(hash_dnssec_keys(domain, env))
514+
505515
# DNSSEC requires re-signing a zone periodically. That requires
506516
# bumping the serial number even if no other records have changed.
507517
# We don't see the DNSSEC records yet, so we have to figure out
@@ -612,53 +622,77 @@ def write_nsd_conf(zonefiles, additional_records, env):
612622

613623
########################################################################
614624

615-
def dnssec_choose_algo(domain, env):
616-
if '.' in domain and domain.rsplit('.')[-1] in \
617-
("email", "guide", "fund", "be", "lv"):
618-
# At GoDaddy, RSASHA256 is the only algorithm supported
619-
# for .email and .guide.
620-
# A variety of algorithms are supported for .fund. This
621-
# is preferred.
622-
# Gandi tells me that .be does not support RSASHA1-NSEC3-SHA1
623-
# Nic.lv does not support RSASHA1-NSEC3-SHA1 for .lv tld's
624-
return "RSASHA256"
625-
626-
# For any domain we were able to sign before, don't change the algorithm
627-
# on existing users. We'll probably want to migrate to SHA256 later.
628-
return "RSASHA1-NSEC3-SHA1"
625+
def find_dnssec_signing_keys(domain, env):
626+
# For key that we generated (one per algorithm)...
627+
d = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec')
628+
keyconfs = [f for f in os.listdir(d) if f.endswith(".conf")]
629+
for keyconf in keyconfs:
630+
# Load the file holding the KSK and ZSK key filenames.
631+
keyconf_fn = os.path.join(d, keyconf)
632+
keyinfo = load_env_vars_from_file(keyconf_fn)
633+
634+
# Skip this key if the conf file has a setting named DOMAINS,
635+
# holding a comma-separated list of domain names, and if this
636+
# domain is not in the list. This allows easily disabling a
637+
# key by setting "DOMAINS=" or "DOMAINS=none", other than
638+
# deleting the key's .conf file, which might result in the key
639+
# being regenerated next upgrade. Keys should be disabled if
640+
# they are not needed to reduce the DNSSEC query response size.
641+
if "DOMAINS" in keyinfo and domain not in [dd.strip() for dd in keyinfo["DOMAINS"].split(",")]:
642+
continue
643+
644+
for keytype in ("KSK", "ZSK"):
645+
yield keytype, keyinfo[keytype]
646+
647+
def hash_dnssec_keys(domain, env):
648+
# Create a stable (by sorting the items) hash of all of the private keys
649+
# that will be used to sign this domain.
650+
keydata = []
651+
for keytype, keyfn in sorted(find_dnssec_signing_keys(domain, env)):
652+
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ".private")
653+
keydata.append(keytype)
654+
keydata.append(keyfn)
655+
with open(oldkeyfn, "r") as fr:
656+
keydata.append( fr.read() )
657+
keydata = "".join(keydata).encode("utf8")
658+
return hashlib.sha1(keydata).hexdigest()
629659

630660
def sign_zone(domain, zonefile, env):
631-
algo = dnssec_choose_algo(domain, env)
632-
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % algo))
661+
# Sign the zone with all of the keys that were generated during
662+
# setup so that the user can choose which to use in their DS record at
663+
# their registrar, and also to support migration to newer algorithms.
633664

634-
# In order to use the same keys for all domains, we have to generate
635-
# a new .key file with a DNSSEC record for the specific domain. We
636-
# can reuse the same key, but it won't validate without a DNSSEC
637-
# record specifically for the domain.
638-
#
639-
# Copy the .key and .private files to /tmp to patch them up.
665+
# In order to use the key files generated at setup which are for
666+
# the domain _domain_, we have to re-write the files and place
667+
# the actual domain name in it, so that ldns-signzone works.
640668
#
641-
# Use os.umask and open().write() to securely create a copy that only
642-
# we (root) can read.
643-
files_to_kill = []
644-
for key in ("KSK", "ZSK"):
645-
if dnssec_keys.get(key, "").strip() == "": raise Exception("DNSSEC is not properly set up.")
646-
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys[key])
647-
newkeyfn = '/tmp/' + dnssec_keys[key].replace("_domain_", domain)
648-
dnssec_keys[key] = newkeyfn
669+
# Patch each key, storing the patched version in /tmp for now.
670+
# Each key has a .key and .private file. Collect a list of filenames
671+
# for all of the keys (and separately just the key-signing keys).
672+
all_keys = []
673+
ksk_keys = []
674+
for keytype, keyfn in find_dnssec_signing_keys(domain, env):
675+
newkeyfn = '/tmp/' + keyfn.replace("_domain_", domain)
676+
649677
for ext in (".private", ".key"):
650-
if not os.path.exists(oldkeyfn + ext): raise Exception("DNSSEC is not properly set up.")
651-
with open(oldkeyfn + ext, "r") as fr:
678+
# Copy the .key and .private files to /tmp to patch them up.
679+
#
680+
# Use os.umask and open().write() to securely create a copy that only
681+
# we (root) can read.
682+
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ext)
683+
with open(oldkeyfn, "r") as fr:
652684
keydata = fr.read()
653-
keydata = keydata.replace("_domain_", domain) # trick ldns-signkey into letting our generic key be used by this zone
654-
fn = newkeyfn + ext
685+
keydata = keydata.replace("_domain_", domain)
655686
prev_umask = os.umask(0o77) # ensure written file is not world-readable
656687
try:
657-
with open(fn, "w") as fw:
688+
with open(newkeyfn + ext, "w") as fw:
658689
fw.write(keydata)
659690
finally:
660691
os.umask(prev_umask) # other files we write should be world-readable
661-
files_to_kill.append(fn)
692+
693+
# Put the patched key filename base (without extension) into the list of keys we'll sign with.
694+
all_keys.append(newkeyfn)
695+
if keytype == "KSK": ksk_keys.append(newkeyfn)
662696

663697
# Do the signing.
664698
expiry_date = (datetime.datetime.now() + datetime.timedelta(days=30)).strftime("%Y%m%d")
@@ -671,32 +705,34 @@ def sign_zone(domain, zonefile, env):
671705

672706
# zonefile to sign
673707
"/etc/nsd/zones/" + zonefile,
674-
708+
]
675709
# keys to sign with (order doesn't matter -- it'll figure it out)
676-
dnssec_keys["KSK"],
677-
dnssec_keys["ZSK"],
678-
])
710+
+ all_keys
711+
)
679712

680713
# Create a DS record based on the patched-up key files. The DS record is specific to the
681714
# zone being signed, so we can't use the .ds files generated when we created the keys.
682715
# The DS record points to the KSK only. Write this next to the zone file so we can
683716
# get it later to give to the user with instructions on what to do with it.
684717
#
685-
# We want to be able to validate DS records too, but multiple forms may be valid depending
686-
# on the digest type. So we'll write all (both) valid records. Only one DS record should
687-
# actually be deployed. Preferebly the first.
718+
# Generate a DS record for each key. There are also several possible hash algorithms that may
719+
# be used, so we'll pre-generate all for each key. One DS record per line. Only one
720+
# needs to actually be deployed at the registrar. We'll select the preferred one
721+
# in the status checks.
688722
with open("/etc/nsd/zones/" + zonefile + ".ds", "w") as f:
689-
for digest_type in ('2', '1'):
690-
rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds",
691-
"-n", # output to stdout
692-
"-" + digest_type, # 1=SHA1, 2=SHA256
693-
dnssec_keys["KSK"] + ".key"
694-
])
695-
f.write(rr_ds)
696-
697-
# Remove our temporary file.
698-
for fn in files_to_kill:
699-
os.unlink(fn)
723+
for key in ksk_keys:
724+
for digest_type in ('1', '2', '4'):
725+
rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds",
726+
"-n", # output to stdout
727+
"-" + digest_type, # 1=SHA1, 2=SHA256, 4=SHA384
728+
key + ".key"
729+
])
730+
f.write(rr_ds)
731+
732+
# Remove the temporary patched key files.
733+
for fn in all_keys:
734+
os.unlink(fn + ".private")
735+
os.unlink(fn + ".key")
700736

701737
########################################################################
702738

0 commit comments

Comments
 (0)