1#!/usr/bin/python3
2
3# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
4#
5# SPDX-License-Identifier: MPL-2.0
6#
7# This Source Code Form is subject to the terms of the Mozilla Public
8# License, v. 2.0.  If a copy of the MPL was not distributed with this
9# file, you can obtain one at https://mozilla.org/MPL/2.0/.
10#
11# See the COPYRIGHT file distributed with this work for additional
12# information regarding copyright ownership.
13
14import mmap
15import os
16import subprocess
17import sys
18import time
19
20import pytest
21
22pytest.importorskip("dns", minversion="2.0.0")
23import dns.exception
24import dns.message
25import dns.name
26import dns.query
27import dns.rcode
28import dns.rdataclass
29import dns.rdatatype
30import dns.resolver
31
32
33pytestmark = pytest.mark.skipif(
34    sys.version_info < (3, 7), reason="Python >= 3.7 required [GL #3001]"
35)
36
37
38def has_signed_apex_nsec(zone, response):
39    has_nsec = False
40    has_rrsig = False
41
42    ttl = 300
43    nextname = "a."
44    types = "NS SOA RRSIG NSEC DNSKEY"
45    match = "{0} {1} IN NSEC {2}{0} {3}".format(zone, ttl, nextname, types)
46    sig = "{0} {1} IN RRSIG NSEC 13 2 300".format(zone, ttl)
47
48    for rr in response.answer:
49        if match in rr.to_text():
50            has_nsec = True
51        if sig in rr.to_text():
52            has_rrsig = True
53
54    if not has_nsec:
55        print("error: missing apex NSEC record in response")
56    if not has_rrsig:
57        print("error: missing NSEC signature in response")
58
59    return has_nsec and has_rrsig
60
61
62def do_query(server, qname, qtype, tcp=False):
63    query = dns.message.make_query(qname, qtype, use_edns=True, want_dnssec=True)
64    try:
65        if tcp:
66            response = dns.query.tcp(
67                query, server.nameservers[0], timeout=3, port=server.port
68            )
69        else:
70            response = dns.query.udp(
71                query, server.nameservers[0], timeout=3, port=server.port
72            )
73    except dns.exception.Timeout:
74        print(
75            "error: query timeout for query {} {} to {}".format(
76                qname, qtype, server.nameservers[0]
77            )
78        )
79        return None
80
81    return response
82
83
84def verify_zone(zone, transfer):
85    verify = os.getenv("VERIFY")
86    assert verify is not None
87
88    filename = "{}out".format(zone)
89    with open(filename, "w", encoding="utf-8") as file:
90        for rr in transfer.answer:
91            file.write(rr.to_text())
92            file.write("\n")
93
94    # dnssec-verify command with default arguments.
95    verify_cmd = [verify, "-z", "-o", zone, filename]
96
97    verifier = subprocess.run(verify_cmd, capture_output=True, check=True)
98
99    if verifier.returncode != 0:
100        print("error: dnssec-verify {} failed".format(zone))
101        sys.stderr.buffer.write(verifier.stderr)
102
103    return verifier.returncode == 0
104
105
106def read_statefile(server, zone):
107    addr = server.nameservers[0]
108    count = 0
109    keyid = 0
110    state = {}
111
112    response = do_query(server, zone, "DS", tcp=True)
113    if not isinstance(response, dns.message.Message):
114        print("error: no response for {} DS from {}".format(zone, addr))
115        return {}
116
117    if response.rcode() == dns.rcode.NOERROR:
118        # fetch key id from response.
119        for rr in response.answer:
120            if rr.match(
121                dns.name.from_text(zone),
122                dns.rdataclass.IN,
123                dns.rdatatype.DS,
124                dns.rdatatype.NONE,
125            ):
126                if count == 0:
127                    keyid = list(dict(rr.items).items())[0][0].key_tag
128                count += 1
129
130        if count != 1:
131            print(
132                "error: expected a single DS in response for {} from {},"
133                "got {}".format(zone, addr, count)
134            )
135            return {}
136    else:
137        print(
138            "error: {} response for {} DNSKEY from {}".format(
139                dns.rcode.to_text(response.rcode()), zone, addr
140            )
141        )
142        return {}
143
144    filename = "ns9/K{}+013+{:05d}.state".format(zone, keyid)
145    print("read state file {}".format(filename))
146
147    try:
148        with open(filename, "r", encoding="utf-8") as file:
149            for line in file:
150                if line.startswith(";"):
151                    continue
152                key, val = line.strip().split(":", 1)
153                state[key.strip()] = val.strip()
154
155    except FileNotFoundError:
156        # file may not be written just yet.
157        return {}
158
159    return state
160
161
162def zone_check(server, zone):
163    addr = server.nameservers[0]
164
165    # wait until zone is fully signed.
166    signed = False
167    for _ in range(10):
168        response = do_query(server, zone, "NSEC")
169        if not isinstance(response, dns.message.Message):
170            print("error: no response for {} NSEC from {}".format(zone, addr))
171        elif response.rcode() == dns.rcode.NOERROR:
172            signed = has_signed_apex_nsec(zone, response)
173        else:
174            print(
175                "error: {} response for {} NSEC from {}".format(
176                    dns.rcode.to_text(response.rcode()), zone, addr
177                )
178            )
179
180        if signed:
181            break
182
183        time.sleep(1)
184
185    assert signed
186
187    # check if zone if DNSSEC valid.
188    verified = False
189    transfer = do_query(server, zone, "AXFR", tcp=True)
190    if not isinstance(transfer, dns.message.Message):
191        print("error: no response for {} AXFR from {}".format(zone, addr))
192    elif transfer.rcode() == dns.rcode.NOERROR:
193        verified = verify_zone(zone, transfer)
194    else:
195        print(
196            "error: {} response for {} AXFR from {}".format(
197                dns.rcode.to_text(transfer.rcode()), zone, addr
198            )
199        )
200
201    assert verified
202
203
204def keystate_check(server, zone, key):
205    val = 0
206    deny = False
207
208    search = key
209    if key.startswith("!"):
210        deny = True
211        search = key[1:]
212
213    for _ in range(10):
214        state = read_statefile(server, zone)
215        try:
216            val = state[search]
217        except KeyError:
218            pass
219
220        if not deny and val != 0:
221            break
222        if deny and val == 0:
223            break
224
225        time.sleep(1)
226
227    if deny:
228        assert val == 0
229    else:
230        assert val != 0
231
232
233def wait_for_log(filename, log):
234    found = False
235
236    for _ in range(10):
237        print("read log file {}".format(filename))
238
239        try:
240            with open(filename, "r", encoding="utf-8") as file:
241                s = mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_READ)
242                if s.find(bytes(log, "ascii")) != -1:
243                    found = True
244        except FileNotFoundError:
245            print("file not found {}".format(filename))
246
247        if found:
248            break
249
250        print("sleep")
251        time.sleep(1)
252
253    assert found
254
255
256def test_checkds_dspublished(named_port):
257    # We create resolver instances that will be used to send queries.
258    server = dns.resolver.Resolver()
259    server.nameservers = ["10.53.0.9"]
260    server.port = named_port
261
262    parent = dns.resolver.Resolver()
263    parent.nameservers = ["10.53.0.2"]
264    parent.port = named_port
265
266    # DS correctly published in parent.
267    zone_check(server, "dspublished.checkds.")
268    wait_for_log(
269        "ns9/named.run",
270        "zone dspublished.checkds/IN (signed): checkds: DS response from 10.53.0.2",
271    )
272    keystate_check(parent, "dspublished.checkds.", "DSPublish")
273
274    # DS correctly published in parent (reference to parental-agent).
275    zone_check(server, "reference.checkds.")
276    wait_for_log(
277        "ns9/named.run",
278        "zone reference.checkds/IN (signed): checkds: DS response from 10.53.0.2",
279    )
280    keystate_check(parent, "reference.checkds.", "DSPublish")
281
282    # DS not published in parent.
283    zone_check(server, "missing-dspublished.checkds.")
284    wait_for_log(
285        "ns9/named.run",
286        "zone missing-dspublished.checkds/IN (signed): checkds: "
287        "empty DS response from 10.53.0.5",
288    )
289    keystate_check(parent, "missing-dspublished.checkds.", "!DSPublish")
290
291    # Badly configured parent.
292    zone_check(server, "bad-dspublished.checkds.")
293    wait_for_log(
294        "ns9/named.run",
295        "zone bad-dspublished.checkds/IN (signed): checkds: "
296        "bad DS response from 10.53.0.6",
297    )
298    keystate_check(parent, "bad-dspublished.checkds.", "!DSPublish")
299
300    # TBD: DS published in parent, but bogus signature.
301
302    # DS correctly published in all parents.
303    zone_check(server, "multiple-dspublished.checkds.")
304    wait_for_log(
305        "ns9/named.run",
306        "zone multiple-dspublished.checkds/IN (signed): checkds: "
307        "DS response from 10.53.0.2",
308    )
309    wait_for_log(
310        "ns9/named.run",
311        "zone multiple-dspublished.checkds/IN (signed): checkds: "
312        "DS response from 10.53.0.4",
313    )
314    keystate_check(parent, "multiple-dspublished.checkds.", "DSPublish")
315
316    # DS published in only one of multiple parents.
317    zone_check(server, "incomplete-dspublished.checkds.")
318    wait_for_log(
319        "ns9/named.run",
320        "zone incomplete-dspublished.checkds/IN (signed): checkds: "
321        "DS response from 10.53.0.2",
322    )
323    wait_for_log(
324        "ns9/named.run",
325        "zone incomplete-dspublished.checkds/IN (signed): checkds: "
326        "DS response from 10.53.0.4",
327    )
328    wait_for_log(
329        "ns9/named.run",
330        "zone incomplete-dspublished.checkds/IN (signed): checkds: "
331        "empty DS response from 10.53.0.5",
332    )
333    keystate_check(parent, "incomplete-dspublished.checkds.", "!DSPublish")
334
335    # One of the parents is badly configured.
336    zone_check(server, "bad2-dswithdrawn.checkds.")
337    wait_for_log(
338        "ns9/named.run",
339        "zone bad2-dspublished.checkds/IN (signed): checkds: "
340        "DS response from 10.53.0.2",
341    )
342    wait_for_log(
343        "ns9/named.run",
344        "zone bad2-dspublished.checkds/IN (signed): checkds: "
345        "DS response from 10.53.0.4",
346    )
347    wait_for_log(
348        "ns9/named.run",
349        "zone bad2-dspublished.checkds/IN (signed): checkds: "
350        "bad DS response from 10.53.0.6",
351    )
352    keystate_check(parent, "bad2-dspublished.checkds.", "!DSPublish")
353
354    # Check with resolver parental-agent.
355    zone_check(server, "resolver-dspublished.checkds.")
356    wait_for_log(
357        "ns9/named.run",
358        "zone resolver-dspublished.checkds/IN (signed): checkds: "
359        "DS response from 10.53.0.3",
360    )
361    keystate_check(parent, "resolver-dspublished.checkds.", "DSPublish")
362
363    # TBD: DS published in all parents, but one has bogus signature.
364
365    # TBD: Check with TSIG
366
367    # TBD: Check with TLS
368
369
370def test_checkds_dswithdrawn(named_port):
371    # We create resolver instances that will be used to send queries.
372    server = dns.resolver.Resolver()
373    server.nameservers = ["10.53.0.9"]
374    server.port = named_port
375
376    parent = dns.resolver.Resolver()
377    parent.nameservers = ["10.53.0.2"]
378    parent.port = named_port
379
380    # DS correctly published in single parent.
381    zone_check(server, "dswithdrawn.checkds.")
382    wait_for_log(
383        "ns9/named.run",
384        "zone dswithdrawn.checkds/IN (signed): checkds: "
385        "empty DS response from 10.53.0.5",
386    )
387    keystate_check(parent, "dswithdrawn.checkds.", "DSRemoved")
388
389    # DS not withdrawn from parent.
390    zone_check(server, "missing-dswithdrawn.checkds.")
391    wait_for_log(
392        "ns9/named.run",
393        "zone missing-dswithdrawn.checkds/IN (signed): checkds: "
394        "DS response from 10.53.0.2",
395    )
396    keystate_check(parent, "missing-dswithdrawn.checkds.", "!DSRemoved")
397
398    # Badly configured parent.
399    zone_check(server, "bad-dswithdrawn.checkds.")
400    wait_for_log(
401        "ns9/named.run",
402        "zone bad-dswithdrawn.checkds/IN (signed): checkds: "
403        "bad DS response from 10.53.0.6",
404    )
405    keystate_check(parent, "bad-dswithdrawn.checkds.", "!DSRemoved")
406
407    # TBD: DS published in parent, but bogus signature.
408
409    # DS correctly withdrawn from all parents.
410    zone_check(server, "multiple-dswithdrawn.checkds.")
411    wait_for_log(
412        "ns9/named.run",
413        "zone multiple-dswithdrawn.checkds/IN (signed): checkds: "
414        "empty DS response from 10.53.0.5",
415    )
416    wait_for_log(
417        "ns9/named.run",
418        "zone multiple-dswithdrawn.checkds/IN (signed): checkds: "
419        "empty DS response from 10.53.0.7",
420    )
421    keystate_check(parent, "multiple-dswithdrawn.checkds.", "DSRemoved")
422
423    # DS withdrawn from only one of multiple parents.
424    zone_check(server, "incomplete-dswithdrawn.checkds.")
425    wait_for_log(
426        "ns9/named.run",
427        "zone incomplete-dswithdrawn.checkds/IN (signed): checkds: "
428        "DS response from 10.53.0.2",
429    )
430    wait_for_log(
431        "ns9/named.run",
432        "zone incomplete-dswithdrawn.checkds/IN (signed): checkds: "
433        "empty DS response from 10.53.0.5",
434    )
435    wait_for_log(
436        "ns9/named.run",
437        "zone incomplete-dswithdrawn.checkds/IN (signed): checkds: "
438        "empty DS response from 10.53.0.7",
439    )
440    keystate_check(parent, "incomplete-dswithdrawn.checkds.", "!DSRemoved")
441
442    # One of the parents is badly configured.
443    zone_check(server, "bad2-dswithdrawn.checkds.")
444    wait_for_log(
445        "ns9/named.run",
446        "zone bad2-dswithdrawn.checkds/IN (signed): checkds: "
447        "empty DS response from 10.53.0.5",
448    )
449    wait_for_log(
450        "ns9/named.run",
451        "zone bad2-dswithdrawn.checkds/IN (signed): checkds: "
452        "empty DS response from 10.53.0.7",
453    )
454    wait_for_log(
455        "ns9/named.run",
456        "zone bad2-dswithdrawn.checkds/IN (signed): checkds: "
457        "bad DS response from 10.53.0.6",
458    )
459    keystate_check(parent, "bad2-dswithdrawn.checkds.", "!DSRemoved")
460
461    # Check with resolver parental-agent.
462    zone_check(server, "resolver-dswithdrawn.checkds.")
463    wait_for_log(
464        "ns9/named.run",
465        "zone resolver-dswithdrawn.checkds/IN (signed): checkds: "
466        "empty DS response from 10.53.0.8",
467    )
468    keystate_check(parent, "resolver-dswithdrawn.checkds.", "DSRemoved")
469
470    # TBD: DS withdrawn from all parents, but one has bogus signature.
471