From: Brian Wellington Date: Wed, 24 Feb 2021 18:15:46 +0000 (-0800) Subject: Checkpoint ZONEMD support. X-Git-Tag: v2.2.0rc1~103^2~3 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=41056eeb7430e54f42d67fd5019a76fe97df049c;p=thirdparty%2Fdnspython.git Checkpoint ZONEMD support. --- diff --git a/dns/rdatatype.py b/dns/rdatatype.py index 65da6d4e..b7b095c2 100644 --- a/dns/rdatatype.py +++ b/dns/rdatatype.py @@ -79,6 +79,7 @@ class RdataType(dns.enum.IntEnum): CDNSKEY = 60 OPENPGPKEY = 61 CSYNC = 62 + ZONEMD = 63 SVCB = 64 HTTPS = 65 SPF = 99 @@ -280,6 +281,7 @@ CDS = RdataType.CDS CDNSKEY = RdataType.CDNSKEY OPENPGPKEY = RdataType.OPENPGPKEY CSYNC = RdataType.CSYNC +ZONEMD = RdataType.ZONEMD SVCB = RdataType.SVCB HTTPS = RdataType.HTTPS SPF = RdataType.SPF diff --git a/dns/rdtypes/ANY/ZONEMD.py b/dns/rdtypes/ANY/ZONEMD.py new file mode 100644 index 00000000..0d9eedc6 --- /dev/null +++ b/dns/rdtypes/ANY/ZONEMD.py @@ -0,0 +1,66 @@ +# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license + +import hashlib +import struct +import binascii + +import dns.immutable +import dns.rdata +import dns.rdatatype +import dns.zone + + +@dns.immutable.immutable +class ZONEMD(dns.rdata.Rdata): + + """ZONEMD record""" + + # See RFC 8976 + + __slots__ = ['serial', 'scheme', 'hash_algorithm', 'digest'] + + def __init__(self, rdclass, rdtype, serial, scheme, hash_algorithm, digest): + super().__init__(rdclass, rdtype) + self.serial = self._as_uint32(serial) + self.scheme = dns.zone.DigestScheme.make(scheme) + self.hash_algorithm = dns.zone.DigestHashAlgorithm.make(hash_algorithm) + self.digest = self._as_bytes(digest) + + if self.scheme == 0: # reserved, RFC 8976 Sec. 5.2 + raise ValueError('scheme 0 is reserved') + if self.hash_algorithm == 0: # reserved, RFC 8976 Sec. 5.3 + raise ValueError('hash_algorithm 0 is reserved') + + hasher = dns.zone._digest_hashers.get(self.hash_algorithm) + if hasher and hasher().digest_size != len(self.digest): + raise ValueError('digest length inconsistent with hash algorithm') + + def to_text(self, origin=None, relativize=True, **kw): + kw = kw.copy() + chunksize = kw.pop('chunksize', 128) + return '%d %d %d %s' % (self.serial, self.scheme, self.hash_algorithm, + dns.rdata._hexify(self.digest, + chunksize=chunksize, + **kw)) + + @classmethod + def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True, + relativize_to=None): + serial = tok.get_uint32() + scheme = tok.get_uint8() + hash_algorithm = tok.get_uint8() + digest = tok.concatenate_remaining_identifiers().encode() + digest = binascii.unhexlify(digest) + return cls(rdclass, rdtype, serial, scheme, hash_algorithm, digest) + + def _to_wire(self, file, compress=None, origin=None, canonicalize=False): + header = struct.pack("!IBB", self.serial, self.scheme, + self.hash_algorithm) + file.write(header) + file.write(self.digest) + + @classmethod + def from_wire_parser(cls, rdclass, rdtype, parser, origin=None): + header = parser.get_struct("!IBB") + digest = parser.get_remaining() + return cls(rdclass, rdtype, header[0], header[1], header[2], digest) diff --git a/dns/rdtypes/ANY/__init__.py b/dns/rdtypes/ANY/__init__.py index 5d672137..6c56baff 100644 --- a/dns/rdtypes/ANY/__init__.py +++ b/dns/rdtypes/ANY/__init__.py @@ -60,4 +60,5 @@ __all__ = [ 'TXT', 'URI', 'X25', + 'ZONEMD', ] diff --git a/dns/zone.py b/dns/zone.py index ac957638..b9fad66d 100644 --- a/dns/zone.py +++ b/dns/zone.py @@ -18,8 +18,10 @@ """DNS Zones.""" import contextlib +import hashlib import io import os +import struct import dns.exception import dns.name @@ -28,6 +30,7 @@ import dns.rdataclass import dns.rdatatype import dns.rdata import dns.rdtypes.ANY.SOA +import dns.rdtypes.ANY.ZONEMD import dns.rrset import dns.tokenizer import dns.transaction @@ -56,6 +59,33 @@ class UnknownOrigin(BadZone): """The DNS zone's origin is unknown.""" +class DigestScheme(dns.enum.IntEnum): + """ZONEMD Scheme""" + + SIMPLE = 1 + + @classmethod + def _maximum(cls): + return 255 + + +class DigestHashAlgorithm(dns.enum.IntEnum): + """ZONEMD Hash Algorithm""" + + SHA384 = 1 + SHA512 = 2 + + @classmethod + def _maximum(cls): + return 255 + + +_digest_hashers = { + DigestHashAlgorithm.SHA384: hashlib.sha384, + DigestHashAlgorithm.SHA512 : hashlib.sha512 +} + + class Zone(dns.transaction.TransactionManager): """A DNS zone. @@ -643,6 +673,53 @@ class Zone(dns.transaction.TransactionManager): if self.get_rdataset(name, dns.rdatatype.NS) is None: raise NoNS + def _compute_digest(self, hash_algorithm, scheme=DigestScheme.SIMPLE): + hashinfo = _digest_hashers.get(hash_algorithm) + if not hashinfo: + raise ValueError("unknown digest hash algorithm") + if scheme != DigestScheme.SIMPLE: + raise ValueError("unknown digest scheme") + + hasher = hashinfo() + for (name, node) in sorted(self.items()): + rrnamebuf = name.to_digestable(self.origin) + for rdataset in sorted(node, + key=lambda rds: (rds.rdtype, rds.covers)): + if name in (self.origin, dns.name.empty) and \ + dns.rdatatype.ZONEMD in (rdataset.rdtype, rdataset.covers): + continue + rrfixed = struct.pack('!HHI', rdataset.rdtype, + rdataset.rdclass, rdataset.ttl) + for rr in sorted(rdataset): + rrdata = rr.to_digestable(self.origin) + rrlen = struct.pack('!H', len(rrdata)) + hasher.update(rrnamebuf + rrfixed + rrlen + rrdata) + return hasher.digest() + + def compute_digest(self, hash_algorithm, scheme=DigestScheme.SIMPLE): + digest = self._compute_digest(hash_algorithm, scheme) + return dns.rdtypes.ANY.ZONEMD.ZONEMD(self.rdclass, + dns.rdatatype.ZONEMD, + serial, scheme, hash_algorithm, + digest) + + def verify_digest(self, zonemd=None): + if zonemd: + digests = [zonemd] + else: + digests = self.get_rdataset(self.origin, dns.rdatatype.ZONEMD) + if digests is None: + raise ValueError("no ZONEMD records found") + for digest in digests: + try: + computed = self._compute_digest(digest.hash_algorithm, + digest.scheme) + if computed == digest.digest: + return + except Exception as e: + pass + raise ValueError("no digests verified") + # TransactionManager methods def reader(self): diff --git a/tests/example b/tests/example index b9a802bf..86af9dd8 100644 --- a/tests/example +++ b/tests/example @@ -236,6 +236,10 @@ amtrelay04 AMTRELAY 10 0 2 2001:db8::15 amtrelay05 AMTRELAY 128 1 3 amtrelays.example.com. csync0 CSYNC 12345 0 A MX RRSIG NSEC TYPE1234 avc01 AVC "app-name:WOLFGANG|app-class:OAM|business=yes" +zonemd01 ZONEMD 2018031900 1 1 62e6cf51b02e54b9 b5f967d547ce4313 6792901f9f88e637 493daaf401c92c27 9dd10f0edb1c56f8 080211f8480ee306 +zonemd02 ZONEMD 2018031900 1 2 08cfa1115c7b948c 4163a901270395ea 226a930cd2cbcf2f a9a5e6eb85f37c8a 4e114d884e66f176 eab121cb02db7d65 2e0cc4827e7a3204 f166b47e5613fd27 +zonemd03 ZONEMD 2018031900 1 240 e2d523f654b9422a 96c5a8f44607bbee +zonemd04 ZONEMD 2018031900 241 1 e1846540e33a9e41 89792d18d5d131f6 05fc283e aaaaaaaa aaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaa aaaaaaaaaaaaaaa svcb01 SVCB ( 100 foo.com. mandatory="alpn,port" alpn="h2,h3" no-default-alpn port="12345" echconfig="abcd" ipv4hint=1.2.3.4,4.3.2.1 ipv6hint=1::2,3::4 key12345="foo" diff --git a/tests/example1.good b/tests/example1.good index 03fd08dc..c1ddfd49 100644 --- a/tests/example1.good +++ b/tests/example1.good @@ -147,3 +147,7 @@ wks01 3600 IN WKS 10.0.0.1 6 0 1 2 21 23 wks02 3600 IN WKS 10.0.0.1 17 0 1 2 53 wks03 3600 IN WKS 10.0.0.2 6 65535 x2501 3600 IN X25 "123456789" +zonemd01 3600 IN ZONEMD 2018031900 1 1 62e6cf51b02e54b9b5f967d547ce43136792901f9f88e637493daaf401c92c279dd10f0edb1c56f8080211f8480ee306 +zonemd02 3600 IN ZONEMD 2018031900 1 2 08cfa1115c7b948c4163a901270395ea226a930cd2cbcf2fa9a5e6eb85f37c8a4e114d884e66f176eab121cb02db7d652e0cc4827e7a3204f166b47e5613fd27 +zonemd03 3600 IN ZONEMD 2018031900 1 240 e2d523f654b9422a96c5a8f44607bbee +zonemd04 3600 IN ZONEMD 2018031900 241 1 e1846540e33a9e4189792d18d5d131f605fc283eaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/tests/example2.good b/tests/example2.good index 64696ce9..ac14e202 100644 --- a/tests/example2.good +++ b/tests/example2.good @@ -147,3 +147,7 @@ wks01.example. 3600 IN WKS 10.0.0.1 6 0 1 2 21 23 wks02.example. 3600 IN WKS 10.0.0.1 17 0 1 2 53 wks03.example. 3600 IN WKS 10.0.0.2 6 65535 x2501.example. 3600 IN X25 "123456789" +zonemd01.example. 3600 IN ZONEMD 2018031900 1 1 62e6cf51b02e54b9b5f967d547ce43136792901f9f88e637493daaf401c92c279dd10f0edb1c56f8080211f8480ee306 +zonemd02.example. 3600 IN ZONEMD 2018031900 1 2 08cfa1115c7b948c4163a901270395ea226a930cd2cbcf2fa9a5e6eb85f37c8a4e114d884e66f176eab121cb02db7d652e0cc4827e7a3204f166b47e5613fd27 +zonemd03.example. 3600 IN ZONEMD 2018031900 1 240 e2d523f654b9422a96c5a8f44607bbee +zonemd04.example. 3600 IN ZONEMD 2018031900 241 1 e1846540e33a9e4189792d18d5d131f605fc283eaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/tests/example3.good b/tests/example3.good index 03fd08dc..c1ddfd49 100644 --- a/tests/example3.good +++ b/tests/example3.good @@ -147,3 +147,7 @@ wks01 3600 IN WKS 10.0.0.1 6 0 1 2 21 23 wks02 3600 IN WKS 10.0.0.1 17 0 1 2 53 wks03 3600 IN WKS 10.0.0.2 6 65535 x2501 3600 IN X25 "123456789" +zonemd01 3600 IN ZONEMD 2018031900 1 1 62e6cf51b02e54b9b5f967d547ce43136792901f9f88e637493daaf401c92c279dd10f0edb1c56f8080211f8480ee306 +zonemd02 3600 IN ZONEMD 2018031900 1 2 08cfa1115c7b948c4163a901270395ea226a930cd2cbcf2fa9a5e6eb85f37c8a4e114d884e66f176eab121cb02db7d652e0cc4827e7a3204f166b47e5613fd27 +zonemd03 3600 IN ZONEMD 2018031900 1 240 e2d523f654b9422a96c5a8f44607bbee +zonemd04 3600 IN ZONEMD 2018031900 241 1 e1846540e33a9e4189792d18d5d131f605fc283eaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/tests/test_zonedigest.py b/tests/test_zonedigest.py new file mode 100644 index 00000000..47629c53 --- /dev/null +++ b/tests/test_zonedigest.py @@ -0,0 +1,147 @@ +# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license + +import io +import textwrap +import unittest + +import dns.rdata +import dns.rrset +import dns.zone + +class ZoneDigestTestCase(unittest.TestCase): + # Examples from RFC 8976, fixed per errata. + + simple_example = textwrap.dedent(''' + example. 86400 IN SOA ns1 admin 2018031900 ( + 1800 900 604800 86400 ) + 86400 IN NS ns1 + 86400 IN NS ns2 + 86400 IN ZONEMD 2018031900 1 1 ( + c68090d90a7aed71 + 6bc459f9340e3d7c + 1370d4d24b7e2fc3 + a1ddc0b9a87153b9 + a9713b3c9ae5cc27 + 777f98b8e730044c ) + ns1 3600 IN A 203.0.113.63 + ns2 3600 IN AAAA 2001:db8::63 + ''') + + complex_example = textwrap.dedent(''' + example. 86400 IN SOA ns1 admin 2018031900 ( + 1800 900 604800 86400 ) + 86400 IN NS ns1 + 86400 IN NS ns2 + 86400 IN ZONEMD 2018031900 1 1 ( + a3b69bad980a3504 + e1cffcb0fd6397f9 + 3848071c93151f55 + 2ae2f6b1711d4bd2 + d8b39808226d7b9d + b71e34b72077f8fe ) + ns1 3600 IN A 203.0.113.63 + NS2 3600 IN AAAA 2001:db8::63 + occluded.sub 7200 IN TXT "I'm occluded but must be digested" + sub 7200 IN NS ns1 + duplicate 300 IN TXT "I must be digested just once" + duplicate 300 IN TXT "I must be digested just once" + foo.test. 555 IN TXT "out-of-zone data must be excluded" + UPPERCASE 3600 IN TXT "canonicalize uppercase owner names" + * 777 IN PTR dont-forget-about-wildcards + mail 3600 IN MX 20 MAIL1 + mail 3600 IN MX 10 Mail2.Example. + sortme 3600 IN AAAA 2001:db8::5:61 + sortme 3600 IN AAAA 2001:db8::3:62 + sortme 3600 IN AAAA 2001:db8::4:63 + sortme 3600 IN AAAA 2001:db8::1:65 + sortme 3600 IN AAAA 2001:db8::2:64 + non-apex 900 IN ZONEMD 2018031900 1 1 ( + 616c6c6f77656420 + 6275742069676e6f + 7265642e20616c6c + 6f77656420627574 + 2069676e6f726564 + 2e20616c6c6f7765 ) + ''') + + multiple_digests_example = textwrap.dedent(''' + example. 86400 IN SOA ns1 admin 2018031900 ( + 1800 900 604800 86400 ) + example. 86400 IN NS ns1.example. + example. 86400 IN NS ns2.example. + example. 86400 IN ZONEMD 2018031900 1 1 ( + 62e6cf51b02e54b9 + b5f967d547ce4313 + 6792901f9f88e637 + 493daaf401c92c27 + 9dd10f0edb1c56f8 + 080211f8480ee306 ) + example. 86400 IN ZONEMD 2018031900 1 2 ( + 08cfa1115c7b948c + 4163a901270395ea + 226a930cd2cbcf2f + a9a5e6eb85f37c8a + 4e114d884e66f176 + eab121cb02db7d65 + 2e0cc4827e7a3204 + f166b47e5613fd27 ) + example. 86400 IN ZONEMD 2018031900 1 240 ( + e2d523f654b9422a + 96c5a8f44607bbee ) + example. 86400 IN ZONEMD 2018031900 241 1 ( + e1846540e33a9e41 + 89792d18d5d131f6 + 05fc283eaaaaaaaa + aaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaa) + ns1.example. 3600 IN A 203.0.113.63 + ns2.example. 86400 IN TXT "This example has multiple digests" + NS2.EXAMPLE. 3600 IN AAAA 2001:db8::63 + ''') + + def test_zonemd_simple(self): + zone = dns.zone.from_text(self.simple_example, origin='example') + zone.verify_digest() + + def test_zonemd_complex(self): + zone = dns.zone.from_text(self.complex_example, origin='example') + zone.verify_digest() + + def test_zonemd_multiple_digests(self): + zone = dns.zone.from_text(self.multiple_digests_example, + origin='example') + zone.verify_digest() + + zonemd = zone.get_rdataset(zone.origin, 'ZONEMD') + for rr in zonemd: + if rr.scheme == 1 and rr.hash_algorithm in (1, 2): + zone.verify_digest(rr) + else: + with self.assertRaises(ValueError): + zone.verify_digest(rr) + + sha384_hash = 'ab' * 48 + sha512_hash = 'ab' * 64 + + def test_zonemd_parse_rdata(self): + dns.rdata.from_text('IN', 'ZONEMD', '100 1 1 ' + self.sha384_hash) + dns.rdata.from_text('IN', 'ZONEMD', '100 1 2 ' + self.sha512_hash) + dns.rdata.from_text('IN', 'ZONEMD', '100 100 1 ' + self.sha384_hash) + dns.rdata.from_text('IN', 'ZONEMD', '100 1 100 abcd') + + def test_zonemd_unknown_scheme(self): + zone = dns.zone.from_text(self.simple_example, origin='example') + with self.assertRaises(ValueError): + zone.compute_digest(dns.zone.DigestHashAlgorithm.SHA384, 2) + + def test_zonemd_unknown_hash_algorithm(self): + zone = dns.zone.from_text(self.simple_example, origin='example') + with self.assertRaises(ValueError): + zone.compute_digest(5) + + def test_zonemd_invalid_digest_length(self): + with self.assertRaises(dns.exception.SyntaxError): + dns.rdata.from_text('IN', 'ZONEMD', '100 1 2 ' + self.sha384_hash) + with self.assertRaises(dns.exception.SyntaxError): + dns.rdata.from_text('IN', 'ZONEMD', '100 2 1 ' + self.sha512_hash)