From ef5e9582527253965727f94281e56fa5b686a66b Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Tue, 1 Sep 2026 18:35:51 +0200 Subject: [PATCH 1/5] DNSSEC fixes after large scale testing --- CHANGELOG.md | 87 +++ CONTRIBUTING.md | 17 +- Makefile | 26 +- README.md | 30 +- SECURITY.md | 11 +- TESTING.md | 468 +++++++++++++ THIRD-PARTY.md | 4 +- pyproject.toml | 2 +- scripts/audit.py | 177 +++++ scripts/cassette.py | 436 ++++++++++++ scripts/collect_domains_adversarial.py | 263 +++++++ scripts/expand_corpus.py | 197 ++++++ scripts/mutation_check.py | 787 +++++++++++++++++++++ scripts/verdict_harness.py | 445 ++++++++++++ src/recursive_resolver/__init__.py | 4 +- src/recursive_resolver/addresses.py | 28 +- src/recursive_resolver/cache.py | 78 ++- src/recursive_resolver/cli.py | 16 +- src/recursive_resolver/dnssec.py | 631 ++++++++++++++--- src/recursive_resolver/exceptions.py | 20 + src/recursive_resolver/resolver.py | 787 ++++++++++++++++++--- tests/conftest.py | 2 +- tests/signed_zone.py | 36 +- tests/test_cache.py | 97 ++- tests/test_cli.py | 5 + tests/test_dnssec.py | 932 +++++++++++++++++++++++-- tests/test_dnssec_chain.py | 652 ++++++++++++++++- tests/test_dnssec_signed.py | 903 +++++++++++++++++++++++- tests/test_internals.py | 96 ++- tests/test_protocol_harness.py | 123 ++++ tests/test_resolver.py | 370 ++++++++++ tests/test_security.py | 192 ++++- 32 files changed, 7554 insertions(+), 368 deletions(-) create mode 100644 TESTING.md create mode 100644 scripts/audit.py create mode 100644 scripts/cassette.py create mode 100644 scripts/collect_domains_adversarial.py create mode 100644 scripts/expand_corpus.py create mode 100644 scripts/mutation_check.py create mode 100644 scripts/verdict_harness.py create mode 100644 tests/test_protocol_harness.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 614f262..399392a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,93 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-09-01 + +A security release. Four defects in DNSSEC validation allowed forged data to be +accepted as authenticated; the most direct required only that the attacker own +a signed domain of their own. Upgrade before relying on a `SECURE` verdict from +0.1.0. `TESTING.md` describes how they were found. + +### Security + +- An RRSIG's signer was never required to contain the RRset it covers + (RFC 4035 §5.3.1), so any signed zone could sign for any name. +- A wildcard at any ancestor was accepted where only the one at the closest + encloser can have answered (RFC 4035 §5.3.4/§5.4). Three variants: forged + NXDOMAIN, forged NODATA against a nested wildcard, and a replayed wildcard + RRset authenticated under a name a closer wildcard governs. +- Denials served from the parent side of a zone cut were accepted as proof + about the child, and a child's own SOA-bearing record could deny its DS + (RFC 6840 §4.1, RFC 4035 §5.2). Likewise a parent-side NSEC3 as the closest + encloser (RFC 5155 §8.3). +- An opt-out NSEC3 proved NODATA for any type; it only ever proves a missing + DS (RFC 5155 §8.5, §8.6), and even then unauthenticated (§9.2). +- The DNSKEY RRset was authenticated against every published key rather than + the one the parent's DS matched (RFC 4035 §5.2), so a compromised + zone-signing key was enough to introduce a new one. +- A DNSKEY with the REVOKE bit was still used to verify signatures + (RFC 5011 §2.1). +- Three checks read a field from the first RRSIG rdata rather than the + signature that verified, which an attacker orders. Each was exploitable with + a decoy. +- An authenticated RRset could be cached past its signature's expiry + (RFC 4035 §5.3.3), for up to 24 hours; a proven denial for up to an hour. +- An NXDOMAIN or NODATA resting on an opt-out cover was reported + authenticated, and `require_dnssec=True` did not apply to negative answers + at all, cached or fresh. +- NSEC3 records with reserved flag bits were read as Opt-Out (RFC 5155 §8.2). + +### Fixed + +- One unreachable or out-of-sync nameserver no longer condemns a zone. DNSKEY + and DS fetches, bare referrals and failed denials all sweep the NS set + (RFC 4035 §5.5); a stale cached delegation re-resolves its NS names. +- A single spoofed UDP packet no longer retires a healthy nameserver + (RFC 5452 §9). +- DNAME is implemented: CNAME synthesis when the server omits it, and the + redirection inherits the DNAME's verdict rather than reading the unsigned + synthesized CNAME as a stripped signature (RFC 6672 §3.3, §3.4, §8). +- `ANY` is refused rather than answered as NODATA and cached as a denial. +- An NS set that points sideways or upwards is an error, not a denial. +- The SOA marking a negative answer must be class IN and belong to the zone + queried. +- Glue is cached under its own TTL, not the NS set's. +- `AAAA` is queried for glueless NS targets when IPv6 is enabled. +- A CNAME chain reports the minimum TTL over its hops. +- Empty non-terminals and wildcard-synthesised NODATA are no longer BOGUS + (RFC 4035 §3.1.3.4.1, §5.4; RFC 5155 §8.7), nor is a NODATA whose only proof + is an opt-out gap. The bit says the range holds no *signed* delegation, not + that it holds no names, so the answer is returned unauthenticated rather than + refused (RFC 5155 §8.6, §9.2). Ten zones in a 4000-name corpus needed this. +- RRSIG validity uses RFC 1982 serial arithmetic, allows configurable slack on + inception (`clock_skew`, 60s) for signers whose clock runs ahead, and is + checked before any cryptography. +- The IPv6 transition prefixes (6to4, Teredo, NAT64, IPv4-compatible) are + refused as nameserver addresses by name. Each carries an IPv4 address + inside it, and CVE-2024-4032 moved where the standard library draws that + line, so the control no longer depends on the interpreter's patch level. +- `edns_payload` outside 16 bits, and a cache `min_ttl` above its `max_ttl`, + are rejected at construction rather than misbehaving later. +- `--json` with `--no-dnssec` reports `"dnssec": "disabled"`. It said + `"insecure"`, which claims a zone was checked and found unsigned. +- A resolution that runs out of budget while resolving a referral's + nameservers reports a timeout, not a server failure. + +### Changed + +- A high NSEC3 iteration count downgrades to insecure rather than failing, + matching the major implementations (RFC 9276 §3.2 permits either). +- `min_ttl` no longer floors authenticated answers or delegations, whose TTL + is already capped by their signature. + +### Added + +- `DNSSECMaterialUnavailableError`, raised when a DNSKEY or DS could not be + retrieved. Deliberately not a `DNSSECError`, so "refuse to use this data" + handling stays correct while retrieval failures fall through to retries. +- `TESTING.md` and the harnesses it describes, including a mutation catalogue + of 50 reintroduced defects that confirms the suite still catches each one. + ## [0.1.0] - 2026-08-09 First public release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03c04df..5fd99b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,9 +46,9 @@ author, the committer and any `Co-authored-by:` trailer. This package is MIT and every runtime dependency is permissive. Before copying anything in, check [THIRD-PARTY.md](THIRD-PARTY.md) and note that several -reference resolvers are **copyleft**: PowerDNS Recursor and Knot Resolver are -GPL, BIND 9 is MPL-2.0. Their *behaviour* is fair to cite: parameter names and -default values are facts, and recording them lets a reader check our choices +widely-used resolver implementations are **copyleft** (GPL or MPL). Their +*behaviour* is fair to cite: parameter names and default values are facts, and +recording them lets a reader check our choices against the state of the art. Their code, documentation text and curated data (prefix lists, tables) are not, and must not be pasted in. @@ -68,7 +68,12 @@ the file header and add a row to `THIRD-PARTY.md`. acceptable in the query path: it is what previously turned malformed input into phantom network timeouts. - Unit tests must not touch the network. Use the builders in `tests/conftest.py` - and patch `_send_query`. + and patch `_send_query`, or `_query_once` when the sweep across a zone's + nameservers is itself what is being tested. +- **A DNSSEC verdict needs retrieved material.** `DNSSECValidationError` means + signed data was obtained and failed to verify. Anything that could not be + fetched, whether a lame server, a SERVFAIL or no answer at all, is a + `DNSSECMaterialUnavailableError`, which is deliberately not a `DNSSECError`. - Integration tests go behind the `integration` marker. ## Differential testing @@ -77,6 +82,10 @@ Beyond the unit and integration suites, `scripts/diff_harness.py` compares this resolver against reference resolvers across a large corpus, broken down by record type, corpus category and resolver configuration. +It compares record values, not DNSSEC verdicts, and runs each name once. The +layers that cover those, driven by `make test-protocol`, are described in +[TESTING.md](TESTING.md). + ```bash # Build a deliberately awkward corpus (~4k names). python scripts/collect_domains_diverse.py -o domains.csv diff --git a/Makefile b/Makefile index 9b8a983..5653547 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,8 @@ CSV ?= domains.csv SOURCES := src/ tests/ scripts/ .PHONY: help install test test-integration test-from-csv coverage coverage-all lint format typecheck \ - check check-all build clean docker-shell release release-check + check check-all build clean docker-shell release release-check \ + test-corpus test-verdicts test-record test-offline test-protocol help: ## Show this help message @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' @@ -47,6 +48,29 @@ check-all: lint typecheck ## Everything, including live DNS and the coverage gat build: ## Build sdist and wheel uv build +# ── Real-world testing protocol (TESTING.md) ────────────────────────── + +CORPUS ?= corpus.csv +ADVERSARIAL ?= adversarial.csv +CASSETTES ?= cassettes.jsonl + +test-corpus: ## Build the adversarial corpus (network, ~10 min) + uv run python scripts/collect_domains_diverse.py -o $(CORPUS) --limit 30000 + uv run python scripts/collect_domains_adversarial.py --csv $(CORPUS) -o $(ADVERSARIAL) + +test-verdicts: ## Verdict differential and flap detection (network, ~1h) + uv run python scripts/verdict_harness.py --csv $(ADVERSARIAL) -o verdicts.csv --escalate 8 + +test-record: ## Record cassettes for offline replay (network) + uv run python scripts/cassette.py record --csv $(ADVERSARIAL) -o $(CASSETTES) --types A,MX + +test-offline: ## Replay cassettes under every order and fault, then mutate (no network) + uv run python scripts/cassette.py replay --cassettes $(CASSETTES) + uv run python scripts/cassette.py perturb --cassettes $(CASSETTES) + uv run python scripts/mutation_check.py --cassettes $(CASSETTES) + +test-protocol: test-corpus test-verdicts test-record test-offline ## The whole protocol, before a release + docker-shell: ## Drop into a Docker shell with uv, make, dig and deps installed docker build -t recursive-resolver . docker run --rm -it -v $(CURDIR):/app -w /app recursive-resolver diff --git a/README.md b/README.md index 7580c3b..e045c97 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Validation is **on by default** and chains from the IANA root trust anchors | State | Meaning | Behaviour | |---|---|---| | `SECURE` | Signed and validated back to the root | Returned | -| `INSECURE` | Provably unsigned (an ancestor proved no DS exists), or signed only with an algorithm this build cannot verify | Returned | +| `INSECURE` | Provably unsigned (an ancestor proved no DS exists), signed only with an algorithm this build cannot verify, or proven only by an opt-out NSEC3 or an iteration count this build will not compute | Returned | | `BOGUS` | Claims to be signed but does not validate | **`DNSSECValidationError`** | Only `BOGUS` is an error: most of the internet is legitimately unsigned, so @@ -147,6 +147,13 @@ This library is designed to be pointed at names an attacker controls, which is exactly what happens when you verify DKIM on inbound mail. - **Nameserver address filtering.** Glue records are attacker-controlled data. An address must be globally routable and none of loopback, link-local, multicast, reserved or private: classification that follows the IANA special-purpose registries rather than a hand-maintained CIDR list. A short explicit list then adds the ranges that classification still calls routable, notably Azure's `168.63.129.16`. So a hostile zone cannot steer the resolver at `127.0.0.1` or at a cloud metadata endpoint. Opt out with `allow_private_addresses=True` only for split-horizon DNS you trust. + + The filter applies to **nameserver** addresses, not to answers. A zone is + entitled to publish `A 127.0.0.1` for its own name, and this library returns + it, as other implementations do: deciding whether an answer is safe to + connect to is the application's business, not the resolver's. If you are + guarding against DNS rebinding, filter the addresses you get back before you + use them. - **Query budget.** A shared per-resolution budget bounds total queries (64), failed NS-hostname lookups (5), referrals followed (130) and NS names chased per referral (13, randomly sampled). This is the NXNSAttack / Non-Responsive-Delegation control (CVE-2020-8616, CVE-2020-12662, CVE-2022-3204); without it a hostile zone can provoke tens of thousands of upstream queries from one call. - **Strict downward progress.** A referral must name a zone *strictly* below the one queried, and the qname must lie at or below it. Sideways and upward referrals are rejected rather than followed in circles. - **Bailiwick from the query, not the response.** Glue is judged against the zone we asked, never against a zone name the responder supplied. @@ -218,7 +225,12 @@ Every failure is a `ResolverError`. Nothing from dnspython escapes. | `UnsupportedRdtypeError` | Unknown or unqueryable record type | | `QueryBudgetExceededError` | The work budget was exhausted (likely an attack) | | `DNSSECValidationError` | Signed data failed validation: **do not use it** | -| `DNSSECInsecureError` | `require_dnssec=True` and the zone is unsigned | +| `DNSSECInsecureError` | `require_dnssec=True` and the answer, or the denial, was not authenticated | +| `DNSSECMaterialUnavailableError` | A DNSKEY or DS could not be fetched, so no verdict was reached | + +`DNSSECMaterialUnavailableError` is deliberately **not** a `DNSSECError`: a lame +nameserver says nothing about the zone, so it must not read as tampering. Code +that treats `DNSSECError` as "refuse to proceed" keeps working unchanged. ## Configuration @@ -240,6 +252,7 @@ RecursiveResolver( extra_blocked_networks=None, # further CIDRs to refuse, added to the built-ins idna_codec=None, # defaults to IDNA 2008 (practical) trust_anchors=None, # defaults to the IANA root anchors + clock_skew=60, # slack on an RRSIG's inception, in seconds ) ``` @@ -262,7 +275,7 @@ RecursiveResolver(limits=Limits( )) ``` -The defaults are the values Unbound and PowerDNS ship. Raise them only +The defaults match what the major implementations ship. Raise them only against a measured legitimate name that needs the headroom. ## How it works @@ -272,15 +285,16 @@ against a measured legitimate name that needs the headroom. 3. **Follow referrals downward**, verifying at each step that the delegation descends and stays in bailiwick. 4. **Resolve glueless NS hostnames** when a referral carries no usable glue, under the shared budget. 5. **Validate DNSSEC** at every zone cut: DS against the parent's keys, DNSKEY against the DS, answers against the DNSKEY, and NSEC/NSEC3 proofs for negative answers. -6. **Chase CNAMEs**, using target records already present in the response when the server supplied them. +6. **Chase CNAMEs**, using target records already present in the response when the server supplied them, and perform the CNAME synthesis a DNAME implies (RFC 6672) when the server did not. 7. **Cache** answers, negatives (NXDOMAIN by name per RFC 2308/8020) and delegations, with TTLs from the wire and negative TTLs from the SOA. ## Testing -The suite is 520 tests at 100% coverage: 483 unit tests with mocked DNS and 37 -integration tests against live DNS, including deliberately-bogus DNSSEC test -domains (`dnssec-failed.org`, `rhybar.cz`, `bogus.nlnetlabs.nl`) that must be -rejected, and real DKIM selectors that must round-trip byte-exactly. +The suite is 723 tests at 100% statement and branch coverage: 686 unit tests +with mocked DNS and 37 integration tests against live DNS, including +deliberately-bogus DNSSEC test domains (`dnssec-failed.org`, `rhybar.cz`, +`bogus.nlnetlabs.nl`) that must be rejected, and real DKIM selectors that must +round-trip byte-exactly. `tests/test_security.py` is a regression test per defect found in the pre-release audit: SSRF via glue, NXNS amplification, referral ping-pong, diff --git a/SECURITY.md b/SECURITY.md index db4aa39..a139641 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,7 +18,7 @@ This library is designed to be pointed at domain names chosen by an attacker. Th | Attack | Control | |---|---| -| **DNS-driven SSRF**: glue pointing at `127.0.0.1`, RFC1918, or `169.254.169.254` to probe internal networks or a cloud metadata endpoint | Every candidate nameserver address must be globally routable and none of loopback, link-local, multicast, reserved or private, per the IANA special-purpose registries as tracked by Python's `ipaddress`. A short explicit list adds ranges that classification still calls routable: Azure's `168.63.129.16`, 6to4 relay anycast, ORCHIDv2 and deprecated site-local. IPv4-mapped IPv6 is unwrapped first so it cannot bypass the v4 rules | +| **DNS-driven SSRF**: glue pointing at `127.0.0.1`, RFC1918, or `169.254.169.254` to probe internal networks or a cloud metadata endpoint | Every candidate nameserver address must be globally routable and none of loopback, link-local, multicast, reserved or private, per the IANA special-purpose registries as tracked by Python's `ipaddress`. A short explicit list adds ranges classification does not refuse, or did not always refuse: Azure's `168.63.129.16`, 6to4 relay anycast, ORCHIDv2, deprecated site-local, and the IPv6 transition prefixes that carry an IPv4 address inside them (6to4, Teredo, NAT64, IPv4-compatible), whose stdlib classification changed with CVE-2024-4032. IPv4-mapped IPv6 is unwrapped first so it cannot bypass the v4 rules | | **NXNSAttack / Non-Responsive Delegation amplification** (CVE-2020-8616, CVE-2020-12662, CVE-2022-3204): a zone that answers every query with a glueless referral to many fresh NS names, using the resolver as a DDoS amplifier | A budget shared across the whole resolution and all sub-resolutions: 64 queries, 5 failed NS targets, 130 referrals, 13 NS names per referral (randomly sampled, not a prefix) | | **Referral loops and lame delegation** | A referral must name a zone strictly below the zone queried; sideways and upward referrals are rejected | | **Cache poisoning via out-of-bailiwick glue** | Bailiwick is evaluated against the zone we queried, never against a zone name supplied in the response | @@ -30,7 +30,12 @@ This library is designed to be pointed at domain names chosen by an attacker. Th | **Random-subdomain (water torture) floods** | NXDOMAIN is cached by name and suppresses everything below it (RFC 8020) | | **IDN homograph confusion** | IDNA 2008 is used, so `straße.de` is not silently resolved as `strasse.de` | | **KeyTrap** (CVE-2023-50387, CVE-2023-50868): colliding key tags forcing quadratic signature verification | At most 2 keys and 2 signatures per (tag, algorithm), 8 signatures per RRset, 8 DS records per zone; per-resolution budget of 96 signature verifications and 600 NSEC3 hashes | -| **NSEC3 CPU exhaustion** | NSEC3 iteration counts above 100 are rejected (RFC 9276 requires zones to publish 0) | +| **NSEC3 CPU exhaustion** | NSEC3 iteration counts above 100 are not computed. The proof is downgraded to insecure rather than refused, which is what the major implementations do (RFC 9276 §3.2 permits either) | +| **Signature forgery by a zone that owns one** | An RRSIG counts only if the zone it names as signer contains the RRset it covers (RFC 4035 §5.3.1), and only a key the parent's DS matched authenticates the DNSKEY RRset (§5.2). A revoked key is never used (RFC 5011 §2.1) | +| **Forged denial of existence** | A denial must come from the zone holding the name, not the parent side of its cut (RFC 6840 §4.1, RFC 5155 §8.3); only the wildcard at the closest encloser can have answered; an opt-out cover proves a missing DS and nothing else | +| **Decisions taken from attacker-ordered data** | An RRSIG RRset may carry several rdata, only one of which verified. Every check reads the signature that validated; the signing zone comes from the keyring, never the wire | +| **Authenticated data outliving its signature** | A validated RRset's cache TTL is capped by the time left on its RRSIG (RFC 4035 §5.3.3) | +| **One broken nameserver condemning a zone** | Validation material and unproven denials are re-sought from the zone's other nameservers before any BOGUS verdict (RFC 4035 §5.5), with no relaxation of what is then checked | | **Unbounded resolution time** | A hard wall-clock deadline applies to the whole call, including all sub-resolutions | ### What is not defended against @@ -38,6 +43,8 @@ This library is designed to be pointed at domain names chosen by an attacker. Th - **A full on-path attacker**, unless the target zone is DNSSEC-signed and you use `require_dnssec=True`. An unsigned zone offers no cryptographic assurance at all: that is a property of DNS, not of this library. - **Traffic analysis.** QNAME minimisation (RFC 9156) is not implemented, so the full query name is visible to root and TLD servers. - **Resource exhaustion from your own call volume.** The budget bounds a single `resolve()`; rate-limiting your callers is your responsibility. +- **DNS rebinding.** The address filter applies to nameserver addresses, not to answers: a zone may legitimately publish `A 127.0.0.1` for its own name, and this library returns it, as other implementations do. Filter the addresses you get back before connecting to them. +- **An unsigned answer from a zone whose other nameservers serve it signed.** RFC 4035 §5.5 says to try another server before concluding an answer is forged. Denials do this; answers do not, because the retry would have to replace the returned RRset. Such a zone yields an intermittent `DNSSECValidationError`. - **Malicious `allow_private_addresses=True`.** That flag disables the SSRF control by design. Only enable it for split-horizon DNS you fully control. ## Supported versions diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..a675ffa --- /dev/null +++ b/TESTING.md @@ -0,0 +1,468 @@ +# Real-world testing protocol + +0.1.0 shipped with 210 DNSSEC unit tests, a real-crypto signed-zone fixture, +and a record-level differential harness. All of it passed. It still shipped a +validator that could be made to accept forged data by anyone who owned a signed +domain, and that returned a false `DNSSECValidationError` on ordinary names. + +This document is the protocol built in response: what to run, against what +corpus, and what has to hold. Some of it exists and has been run; some of it is +built but unexercised; some is a proposal. Each section says which. + +## Why none of the existing testing found it + +Each reason is a requirement further down. + +1. **The live differential never exercised DNSSEC.** Every named config in + `diff_harness.py` except `default` sets `dnssec: False`, and `--configs` + defaults to `no-dnssec`. The validation paths had unit tests, and good ones, + but nothing compared their verdicts against real DNS. +2. **The verdict was never compared, only the data.** The harness diffs record + sets. "We say BOGUS, three public validators say AD" is not a record + difference, so no report could contain it. +3. **One sample per name.** The retrieval failures were non-deterministic: they + depended on which nameserver the shuffle picked first. A single run has a + 7-in-8 chance of missing a 1-in-8 flap and no way to notice it was luck. +4. **The corpus was chosen for popularity, not for stress.** Tranco and + CT-derived names are served by well-run infrastructure. The retrieval bugs + need a zone with a lame server in its NS set, which nobody was selecting for. +5. **No fault injection.** Lame delegations, SERVFAIL, EDNS-hostile middleboxes + and stripped OPT records all exist, but mostly on the long tail a + popularity-ranked corpus omits. +6. **Nothing generated hostile input.** This is the big one, and no amount of + corpus work fixes it. The forgeries needed a signed zone under an attacker's + control, a decoy RRSIG rdata, or a genuine public record used out of place. + The honest internet never sends those, so no live differential can find + them however large it gets. They were found by reading the RFCs against the + code, and by comparing behaviour against other implementations. + +## The pieces + +| Script | Layer | What it does | +|---|---|---| +| `scripts/audit.py` | 0 | Invariants checked on every lookup the other layers make | +| `scripts/collect_domains_adversarial.py` | 1 | Probes NS sets and keeps the zones that misbehave | +| `scripts/verdict_harness.py` | 2, 3 | Verdict differential against public validators, plus flap detection | +| `scripts/cassette.py` | 4 | Record real traffic, replay offline under every order and fault | +| `scripts/mutation_check.py` | 6 | Reintroduces each known defect and reports which layer catches it | +| `scripts/diff_harness.py` | 2 | The pre-existing record-level differential | + +All of them honour `RR_SRC`, which points the import at a different copy of the +package. That is what lets `mutation_check.py` run the harnesses against a +deliberately broken build without touching the working tree. + +## Layer 0: invariants that hold for every lookup + +Everything below is a way of generating inputs; this is what makes an input a +*test*. `scripts/audit.py` wraps a live resolver's own +methods, so it observes real calls and cannot drift from them, and records a +per-resolution ledger. Production code carries no test hooks. + +| # | Invariant | +|---|---| +| I1 | A DNSSEC verdict requires retrieved material: if any fetch of validation material came back empty-handed, the resolution must end in `DNSSECMaterialUnavailableError`, never a `DNSSECError`. | +| I2 | Every query sent while validating carries EDNS0, so it can carry DO (RFC 4035 §3.2.1). | +| I3 | No zone is judged on one server's word: a failed material fetch must have asked every usable address in the NS set. | +| I4 | Nothing is accepted from a response the resolver itself classified as unusable (no AA, wrong class, out of bailiwick). | +| I5 | `DNSSECMaterialUnavailableError` is only raised when a fetch really did return nothing, so it cannot become a way to hide a validation failure. | + +I1 alone catches this release's first bug the moment it fires, on any corpus, +without a reference resolver. Write the invariants once; every layer below +reuses them. + +## Layer 1: an adversarial corpus + +Replace "popular names" with "names selected because they stress a path". +`collect_domains_diverse.py` supplies the candidates (Tranco bands, every IANA +TLD, the Public Suffix List, curated pathological cases); +`collect_domains_adversarial.py` then probes **every nameserver of every zone** +and tags the ones that misbehave: + +```bash +python scripts/collect_domains_diverse.py -o corpus.csv --limit 30000 +python scripts/collect_domains_adversarial.py --csv corpus.csv -o adversarial.csv +``` + +| Tag | What it selected for | +|---|---| +| `lame-dnskey` | One server answers DNSKEY with NOERROR and an empty answer while a sibling serves it. The class that broke 0.1.0. | +| `no-aa-dnskey` | A server answers the DNSKEY query without AA: a parent-side server returning a referral. | +| `unreliable-ns`, `servfail-ns`, `formerr-edns`, `all-ns-down` | Servers that time out, fail, or cannot cope with an OPT record. | +| `rrsig-missing` | DNSKEY served with no RRSIG: what a DO-stripping middlebox looks like. | +| `single-ns` | One address for the zone, so there is no sibling to fall back on. | +| `signed` / `unsigned` | Whether the zone serves a signed DNSKEY at all. | + +The probe rate-limits itself to one query per address per 250 ms. Pin the +output with its collection date: a corpus that silently drifts is not a +regression test. + +Still worth adding: parent-served-child detection (a signed zone whose servers +answer authoritatively for an unsigned child), NSEC vs NSEC3 and algorithm +tagging, and answer-shape tags (large answers, TCP-only, wildcards, empty +non-terminals). + +## Layers 2 and 3: verdict differential and flap detection + +```bash +python scripts/verdict_harness.py --csv adversarial.csv -o verdicts.csv \ + --types A,MX,SOA,DNSKEY --runs 1 --escalate 8 +``` + +Our `ValidationState` is compared with what public validators say, and each +suspicious name is re-resolved with a fresh resolver until the escalation count +is reached. The references do not expose a verdict, so each is asked twice: +`SERVFAIL` that becomes an answer under CD=1 is bogus, `NOERROR` with AD is +secure, `NOERROR` without AD is insecure. + +Rules, each a failure rather than a report line: + +| Ours | Required of the references | +|---|---| +| `SECURE` | at least one says secure | +| `INSECURE` | none says secure. The dangerous direction is a signature we failed to notice | +| `BOGUS` | all but at most one refuse (SERVFAIL without CD, data with CD) | +| unavailable / failed | flagged when the references all resolved it | + +Every resolution runs under the Layer 0 audit, and any name that produces more +than one distinct outcome across its runs is a failure whatever the individual +outcomes look like: a verdict that depends on which server answered first is +not a verdict. This release's original failures were 1-in-8 to 3-in-8, which is +exactly what escalation is sized for. + +Still worth adding: a local validating resolver of another implementation as +ground truth (public resolvers only expose AD and SERVFAIL), and running the whole matrix with DNSSEC on *and* +off rather than DNSSEC off in every config but one. + +## Layer 4: cassette replay with systematic perturbation + +Built and unit-tested, but not exercised against a recorded corpus for this +release: every mutation run below reports `cassettes skipped`. What follows is +what it does, not a result it has produced. + +```bash +python scripts/cassette.py record --csv adversarial.csv -o cassettes.jsonl --types A,MX +python scripts/cassette.py replay --cassettes cassettes.jsonl # reproduces offline +python scripts/cassette.py perturb --cassettes cassettes.jsonl # order x fault matrix +``` + +`record` resolves each name for real, capturing every response, then **completes +the mesh**: it asks every nameserver that was offered for every question the +resolution asked, not only the one that happened to be picked. That is what +makes ordering replayable. `perturb` then runs, per cassette: + +- one replay per server, with that server forced first; +- one replay per (server, fault) pair, faults being `timeout`, `servfail`, + `formerr` (EDNS queries only), `empty-answer`, `no-aa`, `strip-rrsig` and + `strip-dnssec`. + +What must hold, checked on every replay along with the Layer 0 invariants: + +- an **availability** fault must never change the DNSSEC verdict: the + resolution either reproduces it from a sibling, or reports the material + unavailable; +- a **signature-removing** fault may additionally produce BOGUS, because data + that should be signed and is not is what tampering looks like; it must never + produce a weaker verdict than the baseline. + +The retrieval bugs 0.2.0 fixes are the shape this enumerates: each depends on +which server answered first, which is exactly what forcing every order removes. +That is an argument for running it, not a report that it was. The forgeries are +out of its reach, since a cassette records what the internet said and the +internet does not send those. + +Still worth adding: two simultaneous faults on distinct servers, truncation and +delay faults, and committing dated cassettes so a replay is a fixed regression +rather than a fresh recording. + +## Layer 5, proposed: differential on identical inputs + +Not implemented. Feed the same cassette to another implementation through a +fake authoritative server, so +both implementations see byte-identical inputs. This removes internet +nondeterminism from the comparison entirely: a disagreement here is +unambiguously a bug in one of the two, and can be filed as such. + +## Layer 6: coverage attribution and mutation + +```bash +python scripts/mutation_check.py --cassettes cassettes.jsonl +``` + +Each known defect is reintroduced into a temporary copy of the package and both +suites are re-run against it, reporting which layer catches it. A mutant that +survives every layer is a hole in the protocol. The catalogue is explicit source +rewrites, so each entry names the defect it reproduces and a rewrite that no +longer applies fails loudly instead of silently passing. + +Per-layer coverage attribution is **not implemented**. The intent: a branch in +`resolver.py` or `dnssec.py` reached only by unit tests is a branch whose +real-world behaviour is unknown, and either the corpus gains a name that +reaches it or Layer 4 gains a perturbation that does. Whole-suite coverage is +enforced at 100%, which is a weaker statement than it sounds: it says every +branch runs, not that anything real ever drove it. + +## Layer 7, proposed: continuous canary + +Not implemented. Nightly, over a rotating 2k-name sample plus the full `disagreeing-ns-set` tag: +Layers 2 and 3 only. Track and alert on: + +- verdict disagreements per 10k lookups (release gate: **0**); +- flap rate per 10k lookups (release gate: **0**); +- new `DNSSECMaterialUnavailableError` rate (informational: it is the honest + reporting of a broken zone, but a jump means something changed here); +- p50/p99 queries per resolution and wall-clock (the fixes add queries; this is + where that cost shows up). + +Keep results as an append-only history so any regression is bisectable, and so +"the internet changed" can be distinguished from "we changed". + +## Being a good netizen at this scale + +- Rate-limit per authoritative server, not globally; back off on SERVFAIL. +- Prefer cassette replay for anything run more than once a day. Layers 4 and 5 + cost the network nothing. +- Never point the fault-injection layers at real servers; they run on + cassettes. +- Cache aggressively between layers within a run; the point of Layer 3's cold + cache is server *ordering*, which can be re-randomised without re-querying if + the cassette is complete. + +Be a good netizen on your own machine too. `--workers` is the whole story: at +64 the harness saturates a two-core box and its uplink, which is fine on a +dedicated runner and antisocial on a laptop you are also using. Measured on +two cores: 64 workers sustained about 8 lookups a second, 4 workers about 2.4, +for a load average of 0.1 instead of a wedged machine. Start at 4 and raise it +only where nothing else is competing: + +```bash +python scripts/verdict_harness.py --csv corpus.csv -o verdicts.csv \ + --sample 400 --workers 4 --references 8.8.8.8,1.1.1.1 +``` + +Each name also queries every reference resolver, so the reference list +multiplies the traffic; trim it before you raise the worker count. + +## Running it before a release + +```bash +make check # unit suite, lint, types, 100% coverage gate +make test-corpus # build the adversarial corpus (network) +make test-verdicts # verdict differential and flap detection (network) +make test-record # record cassettes (network) +make test-offline # replay under every order and fault, then mutate +``` + +`make test-protocol` runs all four in order. Each step's underlying command is +in the Makefile if you need to vary it; `test-verdicts` in particular takes +`--workers` and `--references`, and the defaults are sized for a dedicated +machine. + +Gates, all of which must hold: + +| Gate | Threshold | +|---|---| +| Layer 0 invariant violations, live and replayed | 0 | +| Verdict disagreements with the public validators | 0 unexplained | +| Names flapping between outcomes | 0 | +| Perturbed replays changing the verdict | 0 | +| Surviving mutants | 0 | +| Statement and branch coverage | 100% | + +"0 unexplained" is the one piece of judgement in the list: the internet +supplies genuine oddities, and a disagreement is cleared either by fixing the +resolver or by writing down, in the release notes, why the reference is the one +that is wrong. Nothing is waved through silently. + +## The 0.2.0 run + +Three things ran, and they found different classes of defect. That is the whole +argument for doing all three. + +| Method | Found | +|---|---| +| Live verdict differential against five public validators | 7 defects, all of one family | +| Clause-by-clause reading of the RFCs | 11 defects, four of them forgeries | +| Behavioural comparison against other DNS implementations | 8 defects, plus an advisory sweep that came up clean | + +### The live differential + +A corpus of 28,550 names over 1,444 TLDs - 12,865 gTLD, 8,143 ccTLD, 2,886 +deep and service-label, 1,438 TLD apexes, 400 public-suffix, 213 reverse-tree, +167 IDN - queried across A, MX, TXT, NS, SOA, DNSKEY, DS, SRV, TLSA and CAA and +across eight resolver configurations, with every anomalous name re-resolved six +times to separate a verdict from a coin toss. + +It found seven defects, all the same shape: **one bad server in an NS set +condemning a whole zone**. A parent answering a DS query with the SOA alone, a +referral carrying no denial, a server serving an old unsigned copy - each read +as evidence the zone was forged rather than as a server failing to answer. +Because NS sets are shuffled, the verdict flapped, which is exactly why +single-shot testing never caught them and why flap detection did. + +The run was executed in chunks against a frozen tree, discarding any chunk that +overlapped an edit. The largest coherent aggregation was 40,500 lookups with +zero flaps, zero invariant violations and two explained disagreements. Later +passes were smaller and targeted: several sweeps of 400-640 lookups across +150-200 TLDs covering answers, denials, NS sets and service labels, all clean. + +### What the differential could not find + +Nothing it did could have found the four forgeries. A resolver that agrees with +every public DNS resolver on every name on the internet can still +be trivially forgeable, because none of those resolvers will ever send it the +packet that proves it. The forgeries needed a signed zone under the auditor's +control, a decoy RRSIG rdata, or a genuine public record used out of place - +inputs the honest internet does not produce. + +### The spec audit + +Read RFC 4033/4034/4035 §5, RFC 5011, RFC 5155 §8, RFC 6672, RFC 6840 §4-5, +RFC 8198, RFC 9077 and RFC 9276 §3.2 against the code, clause by clause, then +had three independent auditors do it again with instructions to assume the code +was wrong and the comments lied. Several comments did - one asserted that +exploiting a weakness required the zone's private key when it required two +public records, another described a Secure Entry Point check the code did not +perform. + +The four forgeries it found are in the changelog. Two patterns are worth +recording for whoever maintains this next: + +**Reading attacker-ordered data.** An RRSIG RRset carries several rdata in +whatever order the sender chose, and only one verified. Three separate checks +read a field off `rrsig[0]` rather than off the signature that validated, and +each was independently exploitable with a decoy. Any decision taken from a +response must come from the part of it that was verified. + +**Fixing one instance of a class and not the rest.** The "wildcard at any +ancestor rather than the closest encloser" error appeared in four places: +the NXDOMAIN wildcard denial, NSEC wildcard NODATA, NSEC3 wildcard NODATA, and +the positive wildcard proof. Each was found separately, in three different +review rounds. Three of the defects introduced during this release were +introduced *by* its own fixes. + +### The reference comparison + +Compared against the major open-source resolver implementations, condition by +condition: their denial proofs, signature validation, iteration state machines +and response sanitisers. Eight further defects, +all in the changelog, and a long list of confirmed-conforming items that is +just as useful: the RRSIG sanity matrix, DNSKEY-to-DS handling, chain descent, +canonical ordering, NSEC3 hashing and base32hex rendering, the KeyTrap caps +(numerically in line: our 8 signature attempts per RRset is the same figure +another implementation settled on), RFC 2181 §5.4.1 trust ranking, bailiwick rules, +and the NXNSAttack and RFC 8020 defences. + +One hypothesis was raised and disproved, which is worth knowing: SHA-1 DS +digests do still validate here, because `make_ds` is called with +`validating=True`. Without that argument every SHA-1-DS zone would silently +become insecure. + +A separate sweep enumerated every published security advisory for the major +implementations, grouped them by mechanism, and tried each against this code. +No vulnerability. Every resource-exhaustion class is bounded, with numbers: + +| Attack | Result | +|---|---| +| tsuNAME cyclic glueless NS | 21 queries, 3 ms, budget exceeded | +| NXNS 50-way glueless fan-out | 21 queries, 4 ms | +| Infinite downward referral with valid glue | 42 queries, 8 ms | +| Cross-zone CNAME chain | 11 queries, loop detected | +| Water torture, 1000 subdomains under an NXDOMAIN parent | 0 upstream queries | +| Ghost domain | delegation TTL clamped to 86400 s, not extendable | + +The bulk of those advisories do not apply: C memory-safety bugs and +DNSCrypt/DoQ/DoT/proxy-protocol paths that do not exist in a pure-Python Do53 +library. + +### What the gentle re-run found + +A 600-lookup pass over 300 names at four workers - deliberately small, and +costing a load average of 0.1 rather than saturating the machine - turned up +one verdict flap that the large runs had missed: a signed IDN TLD whose seven +nameservers included one answering from an NSEC3 chain predating the last +re-signing. Six servers proved the denial, one could not, and the verdict +followed whichever the shuffle picked. The two reference resolvers disagreed +with each other on it, which is the signature of this class. + +That is the same family as the seven the original differential found, on the +one path that had not been swept yet, and it argues for small frequent runs +over occasional enormous ones: the flap detector needs repetition on the same +name, not breadth. Re-running the identical pass after the fix: zero flaps, +zero disagreements, zero invariant violations. + +### What the release run found + +15,932 lookups over 3,983 names, four types each, eight resolutions for +anything anomalous. It found one defect the spec audit and the reference +comparison had both missed: ten zones under two TLDs answer NOERROR/NODATA +off the parent whose only proof is an **opt-out** NSEC3 gap, with no wildcard +record and no matching NSEC3. Read strictly, RFC 5155 §8.5 proves nothing +there, and the resolver refused the answer outright. But the Opt-Out bit says +the range holds no *signed* delegation, not that it holds no names: the name +may exist, unsigned, and answer for itself. The proof is absent, not +contradicted, so §9.2 makes it insecure rather than forged. Every reference +validator returned the answer unauthenticated; we were alone in refusing it. + +The shape matters more than the count. A deterministic false BOGUS on ten +live zones is the same user-visible failure as the retrieval bug 0.2.0 was +opened for, and it survived a full line-by-line audit of the RFC that +describes it, because reading §8.5 alone gives the wrong answer. Only a live +corpus put the case in front of us. + +Re-running the whole pass after the fix: zero verdict flaps, zero +disagreements, zero invariant violations. Ten BOGUS verdicts remain, all of +them the deliberately-broken test zones, all agreed by every reference. + +### Disagreements left standing + +- **A zone whose NS set mixes signed and unsigned copies.** RFC 4035 §5.5 says + a validator with other servers to try SHOULD try one before concluding an + answer is forged. This resolver does not: the retry would have to replace the + *answer*, handing the caller the RRset from the server that did sign, which + is a change to the answer path rather than to a verdict. The one zone in the + corpus that shows it has 3 of its 4 nameservers unreachable, and the public + validators disagreed with themselves on it within a single run. +- **A CNAME chain load balanced across a signed and an unsigned target.** A + 14-hop chain whose last hop varies per query. The verdict follows the branch + the query got, correctly - the chain state is the weakest link over every + hop. One of the five references agreed with us; four got the other branch. + +### Known deviations, deliberate + +- **RFC 8198** (aggressive use of DNSSEC-validated cache) is a SHOULD and is + not implemented: NSEC and NSEC3 records are not cached, so negative answers + are never synthesised. This costs queries; it cannot cost correctness. +- **RFC 9156** QNAME minimisation is not implemented. The major implementations + do it by default. A privacy gap, not a correctness one. +- **No per-server infrastructure cache.** The references remember round-trip + times, EDNS capability and recent failures per address; this resolver + re-probes on every resolution. Costs queries against dead servers. +- **No 0x20 query-name case randomisation** (optional under RFC 5452). The + randomised query ID and source port with strict response matching meet the + baseline. +- **Answer records are not address-filtered.** The filter covers nameserver + addresses; a zone publishing `A 127.0.0.1` for its own name gets that + returned, as other implementations do. Judging whether an answer is safe to + connect to is the application's business. +- **A signature window straddling the 2106 serial wrap is refused.** The + validity check uses RFC 1982 arithmetic as RFC 4034 §3.1.5 specifies, but + dnspython's own comparison underneath cannot be made to accept a wrapped + window. No such signature exists, and the horizon is around 2094. + +### On the method + +Every defect the spec audit found was a MUST in a document already on the +shelf. Every defect the reference comparison found was a check four other +implementations already performed. Neither set was reachable by the live +differential, and the live differential found a family of seven the other two +would have missed, plus the opt-out NODATA above, because only real DNS is +broken in the ways real DNS is broken. A release wants all three, and the +audit wants repeating whenever this code changes. + +The mutation catalogue in `scripts/mutation_check.py` now holds 50 entries, one +per defect found across all three methods. Each reintroduces the original bug +into a copy of the package and confirms the suite still fails. Every one is +currently caught. That is the only evidence that the tests written alongside a +fix actually test it - and it caught one case where a mutation preserved the +file's byte length, landed in the same mtime second, and let Python reuse stale +bytecode so the mutant never ran at all. diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md index dfd7b2b..fe36163 100644 --- a/THIRD-PARTY.md +++ b/THIRD-PARTY.md @@ -35,8 +35,8 @@ Français, **MIT licensed**. Both carry the notice in their header: ## Reference values, not copied code -`budget.py`, `dnssec.py` and `resolver.py` cite the defaults that BIND 9, -Unbound, PowerDNS Recursor and Knot Resolver use for the same protections +`budget.py` and `resolver.py` cite the defaults that other widely-used resolver +implementations use for the same protections (`max-recursion-queries`, `MAX_TARGET_COUNT`, `max-ns-per-resolve`, and so on). These are parameter names and numeric values: facts about how those resolvers behave, recorded so a reader can check our choices against the state of the art. diff --git a/pyproject.toml b/pyproject.toml index 38a266b..e41be3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "hatchling.build" [project] name = "recursive-resolver" -version = "0.1.0" +version = "0.2.0" description = "Pure-Python iterative DNS resolver: resolves from the root servers with DNSSEC validation" readme = "README.md" license = "MIT" diff --git a/scripts/audit.py b/scripts/audit.py new file mode 100644 index 0000000..5a69142 --- /dev/null +++ b/scripts/audit.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Layer 0 of TESTING.md: invariants that must hold for every lookup. + +Attaches to a live :class:`RecursiveResolver` instance, records what it did +during one resolution, and checks the properties that make a DNSSEC verdict +trustworthy. Every other layer in the protocol is a way of generating inputs; +this is what turns an input into a test. + +The instrumentation is a wrapper around instance methods, not a change to the +resolver: production code carries no test hooks, and the audit cannot drift +from what the resolver actually does because it observes the real calls. + +Invariants +---------- +I1 A DNSSEC verdict requires retrieved material. If any fetch of validation + material came back empty-handed, the resolution must not end in a + ``DNSSECError``; it must end in ``DNSSECMaterialUnavailableError``. +I2 Every query sent while validating carries EDNS0, so it can carry the DO + bit (RFC 4035 §3.2.1). Without it the answer arrives unsigned and the + validator can only read that as forged. +I3 No zone is judged on one server's word. A fetch of validation material + that failed must have asked every usable address in the NS set. +I4 Nothing is accepted from a response the resolver itself classified as + unusable (non-authoritative answer, wrong class, out of bailiwick). +I5 ``DNSSECMaterialUnavailableError`` is only raised when a fetch really did + return nothing: it must not become a way to hide a validation failure. +""" + +from __future__ import annotations + +import os +import sys +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path + +import dns.flags +import dns.rdatatype + +# RR_SRC points the harness at a different copy of the package, which is how +# the mutation runs check that these tests actually bite. +sys.path.insert(0, os.environ.get("RR_SRC", str(Path(__file__).resolve().parent.parent / "src"))) + +from recursive_resolver import DNSSECError, RecursiveResolver # noqa: E402 +from recursive_resolver.exceptions import DNSSECMaterialUnavailableError # noqa: E402 + +# Fetches of validation material, as opposed to ordinary data queries. +MATERIAL_TYPES = frozenset({dns.rdatatype.DNSKEY, dns.rdatatype.DS}) + + +@dataclass +class Fetch: + qname: str + rdtype: str + material: bool + offered: set[str] + asked: set[str] = field(default_factory=set) + got_response: bool = False + # The resolution ran out of budget or wall-clock during this fetch, so the + # servers it did not reach were not servers it chose to ignore. + exhausted: bool = False + + +@dataclass +class Ledger: + fetches: list[Fetch] = field(default_factory=list) + queries: list[tuple[str, str, str, int | None, bool]] = field(default_factory=list) + classifications: list[tuple[str, bool, str]] = field(default_factory=list) + + def failed_material_fetches(self) -> list[Fetch]: + return [f for f in self.fetches if f.material and not f.got_response] + + def violations(self, outcome: BaseException | None) -> list[str]: + """Check every invariant against what was recorded.""" + out: list[str] = [] + failed = self.failed_material_fetches() + + # I1 + if isinstance(outcome, DNSSECError) and failed: + names = ", ".join(f"{f.qname}/{f.rdtype}" for f in failed) + out.append(f"I1: {type(outcome).__name__} raised while material was unavailable for {names}") + + # I2 + for qname, rdtype, server, payload, dnssec in self.queries: + if dnssec and payload is None: + out.append(f"I2: {qname}/{rdtype} sent to {server} without EDNS while validating") + + # I3 + for fetch in failed: + unasked = [s for s in fetch.offered if s not in fetch.asked] + if unasked and not fetch.exhausted: + out.append( + f"I3: gave up on {fetch.qname}/{fetch.rdtype} without asking {', '.join(unasked)}" + f" (asked {len(fetch.asked)} of {len(fetch.offered)})" + ) + + # I4 + for qname, authoritative, kind in self.classifications: + if kind in ("answer", "nxdomain", "nodata") and not authoritative: + out.append(f"I4: accepted a {kind} for {qname} without the AA bit") + + # I5 + if isinstance(outcome, DNSSECMaterialUnavailableError) and not failed: + out.append("I5: material reported unavailable, but every fetch returned a response") + + return out + + +@contextmanager +def audited(resolver: RecursiveResolver): + """Record one resolver's activity. Yields the :class:`Ledger`.""" + ledger = Ledger() + real_send = resolver._send_query + real_query = resolver._query_once + real_classify = resolver._classify_response + + def send_query(qname, rdtype, nameservers, ctx, usable=None): + # What the resolver itself would consider, not the raw NS set. I3 asks + # whether every *usable* address was tried, and it is the resolver's own + # ordering that decides which those are: the address filter drops some, + # and cassette replay deliberately restricts the set to the servers the + # recording actually reached. Comparing against the unrestricted list + # instead reports a violation for every server the replay was never + # going to use. + fetch = Fetch( + qname=str(qname), + rdtype=dns.rdatatype.to_text(rdtype), + material=rdtype in MATERIAL_TYPES and ctx.dnssec, + offered=set(resolver._order_servers(nameservers)), + ) + ledger.fetches.append(fetch) + try: + response, server = real_send(qname, rdtype, nameservers, ctx, usable=usable) + except Exception: + fetch.got_response = False + fetch.exhausted = True + raise + fetch.got_response = response is not None + fetch.exhausted = ctx.budget.expired() or ctx.budget.queries_sent >= ctx.budget.max_queries + return response, server + + def query_once(qname, rdtype, server, payload, timeout, ctx): + ledger.queries.append((str(qname), dns.rdatatype.to_text(rdtype), server, payload, ctx.dnssec)) + if ledger.fetches: + ledger.fetches[-1].asked.add(server) + return real_query(qname, rdtype, server, payload, timeout, ctx) + + def classify(response, qname, rdtype, current_zone): + result = real_classify(response, qname, rdtype, current_zone) + ledger.classifications.append((str(qname), bool(response.flags & dns.flags.AA), result["type"])) + return result + + # Overriding on the instance and deleting afterwards, rather than assigning + # the bound methods back: a leftover instance attribute would shadow the + # class for the rest of the resolver's life, including any later patching. + wrapped = {"_send_query": send_query, "_query_once": query_once, "_classify_response": classify} + previous = {name: resolver.__dict__.get(name) for name in wrapped} + for name, function in wrapped.items(): + setattr(resolver, name, function) + try: + yield ledger + finally: + for name, original in previous.items(): + if original is None: + resolver.__dict__.pop(name, None) + else: + setattr(resolver, name, original) + + +def resolve_audited(resolver: RecursiveResolver, qname: str, rdtype: str): + """Resolve, returning ``(answer, exception, violations)``.""" + with audited(resolver) as ledger: + try: + answer = resolver.resolve_answer(qname, rdtype) + except Exception as exc: # noqa: BLE001 - the harness classifies it + return None, exc, ledger.violations(exc) + return answer, None, ledger.violations(None) diff --git a/scripts/cassette.py b/scripts/cassette.py new file mode 100644 index 0000000..200298c --- /dev/null +++ b/scripts/cassette.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +"""Layer 4 of TESTING.md: record real DNS traffic, then replay it under every +server order and every server fault. + +Live testing can only observe the internet as it happened to be at that moment, +and the resolver picks a nameserver at random, so a defect that depends on +which server answers first shows up as a coin flip. This records every +nameserver's answer for every query a resolution made, then replays the +resolution offline with the order fixed and with faults injected one at a time. +"Which server answered first" stops being luck and becomes an enumerated +variable. + +Three commands: + +``record`` + Resolve each name for real, capturing every (server, qname, type) response. + Then complete the mesh: ask *every* nameserver that was offered for each + query, not only the one that happened to be picked, so a replay can put any + of them first. Then close it: replay under each order, record whatever the + replay could not answer, and repeat, because a different first server makes + the resolver ask questions the original resolution never had to. +``replay`` + Re-run each recorded resolution offline and check it reproduces. +``perturb`` + Re-run each resolution once per server order and once per (server, fault) + pair, checking the Layer 0 invariants and the transition rules below. + +What must hold under a fault affecting one server: + +* An **availability** fault (timeout, SERVFAIL, FORMERR, empty answer, no AA) + must never change the DNSSEC verdict. The resolution either produces the same + outcome from a sibling, or fails to retrieve - never a different verdict. +* A **signature-removing** fault may additionally produce BOGUS: an answer that + should be signed and is not is exactly what tampering looks like. It must + never produce a *weaker* verdict than the baseline, because that would be a + silent downgrade. + +Usage: + python scripts/cassette.py record --csv corpus.csv -o cassettes.jsonl --sample 300 + python scripts/cassette.py replay --cassettes cassettes.jsonl + python scripts/cassette.py perturb --cassettes cassettes.jsonl +""" + +from __future__ import annotations + +import argparse +import base64 +import collections +import csv +import json +import os +import random +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import dns.flags +import dns.message +import dns.name +import dns.query +import dns.rcode +import dns.rdatatype + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, os.environ.get("RR_SRC", str(Path(__file__).resolve().parent.parent / "src"))) + +from audit import audited # noqa: E402 + +from recursive_resolver import ( # noqa: E402 + DNSSECError, + RecursiveResolver, + ResolverError, +) +from recursive_resolver.exceptions import ( # noqa: E402 + DNSSECMaterialUnavailableError, + DNSSECValidationError, + NoAnswerError, + NXDOMAINError, + QueryBudgetExceededError, + ResolutionTimeoutError, + ServfailError, +) +from recursive_resolver.resolver import _RetryableError # noqa: E402 + +TIMEOUT = "timeout" +MESH_LIMIT = 6 # addresses probed per (qname, type); keeps root fan-out bounded +CLOSURE_ROUNDS = 3 # passes of "replay, then record what the replay could not answer" + +AVAILABILITY_FAULTS = ("timeout", "servfail", "formerr", "empty-answer", "no-aa") +SIGNATURE_FAULTS = ("strip-rrsig", "strip-dnssec") +FAULTS = AVAILABILITY_FAULTS + SIGNATURE_FAULTS + +# Outcomes that mean "could not get there from here" rather than a verdict. +RETRIEVAL_FAILURES = ( + DNSSECMaterialUnavailableError, + ResolutionTimeoutError, + ServfailError, + QueryBudgetExceededError, +) + + +def outcome_of(answer, exc) -> str: + if exc is None: + return f"ok/{answer.dnssec.value}" + if isinstance(exc, DNSSECValidationError): + return "bogus" + if isinstance(exc, RETRIEVAL_FAILURES): + return "unavailable" + if isinstance(exc, NXDOMAINError): + return "nxdomain" + if isinstance(exc, NoAnswerError): + return "nodata" + if isinstance(exc, DNSSECError): + return "bogus" + return f"error/{type(exc).__name__}" + + +def key_of(qname, rdtype, server: str) -> str: + return f"{qname}|{dns.rdatatype.to_text(rdtype)}|{server}" + + +# ── record ────────────────────────────────────────────────────────────────── + + +def record_case(domain: str, rdtype: str, timeout: float) -> dict | None: + resolver = RecursiveResolver(timeout=timeout, max_resolution_time=25.0, cache_enabled=False) + entries: dict[str, str] = {} + offers: list[tuple[str, int, list[str]]] = [] + + real_send = resolver._send_query + real_query = resolver._query_once + + def send_query(qname, rdtype_, nameservers, ctx, usable=None): + offers.append((str(qname), int(rdtype_), resolver.address_filter.filter(nameservers))) + return real_send(qname, rdtype_, nameservers, ctx, usable=usable) + + def query_once(qname, rdtype_, server, payload, timeout_, ctx): + response = real_query(qname, rdtype_, server, payload, timeout_, ctx) + entries.setdefault(key_of(qname, rdtype_, server), base64.b64encode(response.to_wire()).decode("ascii")) + return response + + resolver._send_query = send_query # type: ignore[method-assign] + resolver._query_once = query_once # type: ignore[method-assign] + try: + answer = resolver.resolve_answer(domain, rdtype) + baseline = outcome_of(answer, None) + records = sorted(answer.records) + except ResolverError as exc: + baseline = outcome_of(None, exc) + records = [] + except Exception: # noqa: BLE001 - anything else is not a recordable case + return None + + # Complete the mesh: every server that was offered, for every question, so + # a replay can put any of them first. + for qname_text, rdtype_int, servers in offers: + for server in servers[:MESH_LIMIT]: + fetch_into(entries, key_of(qname_text, rdtype_int, server), timeout) + + case = { + "domain": domain, + "rdtype": rdtype, + "baseline": baseline, + "records": records, + "offers": [[q, t, s[:MESH_LIMIT]] for q, t, s in offers], + "entries": entries, + } + + # Close the mesh. Putting a different server first makes the resolver ask + # questions the original resolution never had to - a server whose NODATA + # carries no signature sends it looking for a DS that the signed one made + # unnecessary - and a cassette that cannot answer those would report a + # perturbation as "unavailable" when the resolver did nothing wrong. + for _ in range(CLOSURE_ROUNDS): + gaps: set[str] = set() + servers = {key.rsplit("|", 1)[1] for key in entries} + replay(case, missing=gaps) + for server in sorted(servers): + replay(case, order=server, missing=gaps) + if not gaps: + break + for key in gaps: + fetch_into(entries, key, timeout) + return case + + +def fetch_into(entries: dict[str, str], key: str, timeout: float) -> None: + """Record one (question, server) pair, or mark it as unanswered.""" + if key in entries: + return + qname_text, rdtype_text, server = key.split("|") + query = dns.message.make_query(qname_text, rdtype_text, use_edns=0, payload=1232, want_dnssec=True) + query.flags &= ~dns.flags.RD + try: + response, _tcp = dns.query.udp_with_fallback(query, server, timeout=timeout) + entries[key] = base64.b64encode(response.to_wire()).decode("ascii") + except Exception: # noqa: BLE001 - a server that will not answer is recorded as such + entries[key] = TIMEOUT + + +# ── replay ────────────────────────────────────────────────────────────────── + + +def replay( + case: dict, + *, + order: str | None = None, + fault: tuple[str, str] | None = None, + missing: set[str] | None = None, +): + """Replay one case offline. Returns ``(outcome, violations)``. + + ``missing`` collects the (question, server) pairs the cassette could not + answer, which is how ``record`` closes the mesh: a different server order + asks questions the original resolution never had to. + """ + entries = case["entries"] + faulty_server, fault_kind = fault if fault else ("", "") + + def query_once(qname, rdtype, server, payload, timeout, ctx): + key = key_of(qname, rdtype, server) + raw = entries.get(key, TIMEOUT) + if raw == TIMEOUT: + if missing is not None and key not in entries: + missing.add(key) + raise _RetryableError("no recorded response") + response = dns.message.from_wire(base64.b64decode(raw)) + if server == faulty_server: + response = apply_fault(response, fault_kind, payload) + return response + + recorded = {key.rsplit("|", 1)[1] for key in entries} + + def order_servers(nameservers): + servers = resolver.address_filter.filter(nameservers) + # A server the recording never reached is one the cassette knows + # nothing about. Leaving it in makes the replay depend on the shuffle + # again - the very thing this layer exists to remove - so it is set + # aside unless nothing else is left. + known = [s for s in servers if s in recorded] or servers + if order is None or order not in known: + return known + return [order] + [s for s in known if s != order] + + resolver = RecursiveResolver(timeout=1.0, max_resolution_time=25.0, cache_enabled=False) + resolver._query_once = query_once # type: ignore[method-assign] + resolver._order_servers = order_servers # type: ignore[method-assign] + + with audited(resolver) as ledger: + try: + answer = resolver.resolve_answer(case["domain"], case["rdtype"]) + return outcome_of(answer, None), ledger.violations(None) + except Exception as exc: # noqa: BLE001 - the outcome is what is under test + return outcome_of(None, exc), ledger.violations(exc) + + +def apply_fault(response: dns.message.Message, kind: str, payload: int | None) -> dns.message.Message: + if kind == "timeout": + raise _RetryableError("injected timeout") + if kind == "servfail": + broken = dns.message.Message() + broken.flags = dns.flags.QR + broken.set_rcode(dns.rcode.SERVFAIL) + return broken + if kind == "formerr": + if payload is None: + return response + broken = dns.message.Message() + broken.flags = dns.flags.QR + broken.set_rcode(dns.rcode.FORMERR) + return broken + if kind == "empty-answer": + response.answer = [] + return response + if kind == "no-aa": + response.flags &= ~dns.flags.AA + return response + if kind == "strip-rrsig": + response.answer = [r for r in response.answer if r.rdtype != dns.rdatatype.RRSIG] + response.authority = [r for r in response.authority if r.rdtype != dns.rdatatype.RRSIG] + return response + if kind == "strip-dnssec": + drop = (dns.rdatatype.RRSIG, dns.rdatatype.NSEC, dns.rdatatype.NSEC3, dns.rdatatype.DS) + response.answer = [r for r in response.answer if r.rdtype not in drop] + response.authority = [r for r in response.authority if r.rdtype not in drop] + return response + return response + + +def transition_allowed(baseline: str, observed: str, fault_kind: str) -> bool: + """Is this a legitimate consequence of the injected fault?""" + if observed == baseline: + return True + if observed == "unavailable": + # Losing a server can always cost us the material; it must not cost us + # the truth. + return True + if fault_kind in SIGNATURE_FAULTS and observed == "bogus": + # Data that should carry signatures and does not is tampering. + return True + if baseline.startswith("error/") or observed.startswith("error/"): + return True + # NODATA/NXDOMAIN swaps under a fault mean a different server had different + # contents; the recorded mesh is not always self-consistent across servers. + return {baseline, observed} <= {"nodata", "nxdomain"} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command", required=True) + + rec = sub.add_parser("record", help="Record cassettes from live DNS") + rec.add_argument("--csv", required=True) + rec.add_argument("-o", "--output", default="cassettes.jsonl") + rec.add_argument("--types", default="A,MX") + rec.add_argument("--sample", type=int, default=200) + rec.add_argument("--seed", type=int, default=20260830) + rec.add_argument("--workers", type=int, default=12) + rec.add_argument("--timeout", type=float, default=3.0) + + rep = sub.add_parser("replay", help="Replay cassettes offline") + rep.add_argument("--cassettes", required=True) + + per = sub.add_parser("perturb", help="Replay under every order and fault") + per.add_argument("--cassettes", required=True) + per.add_argument("--max-servers", type=int, default=6, help="Servers to enumerate per case") + per.add_argument("--workers", type=int, default=4) + + args = parser.parse_args() + + if args.command == "record": + with open(args.csv, newline="", encoding="utf-8") as handle: + corpus = [row["domain"] for row in csv.DictReader(handle)] + if args.sample and args.sample < len(corpus): + corpus = random.Random(args.seed).sample(corpus, args.sample) + types = [t.strip().upper() for t in args.types.split(",") if t.strip()] + items = [(d, t) for d in corpus for t in types] + started = time.time() + done = 0 + lock = threading.Lock() + + def work(item): + nonlocal done + case = record_case(item[0], item[1], args.timeout) + with lock: + done += 1 + if done % 50 == 0: + print(f" recorded {done}/{len(items)} in {time.time() - started:.0f}s", file=sys.stderr) + return case + + with ThreadPoolExecutor(max_workers=args.workers) as pool: + cases = [c for c in pool.map(work, items) if c] + with open(args.output, "w", encoding="utf-8") as handle: + for case in cases: + handle.write(json.dumps(case) + "\n") + sizes = sum(len(c["entries"]) for c in cases) + print(f"\n{len(cases)} cassettes, {sizes} recorded responses, {time.time() - started:.0f}s", file=sys.stderr) + return 0 + + cases = [json.loads(line) for line in Path(args.cassettes).read_text(encoding="utf-8").splitlines() if line] + + if args.command == "replay": + mismatches = [] + for case in cases: + observed, violations = replay(case) + if observed != case["baseline"] or violations: + mismatches.append((case["domain"], case["rdtype"], case["baseline"], observed, violations)) + print(f"{len(cases)} cassettes replayed, {len(mismatches)} did not reproduce", file=sys.stderr) + for row in mismatches[:40]: + print(f" {row[0]}/{row[1]}: recorded {row[2]}, replayed {row[3]} {row[4]}", file=sys.stderr) + return 1 if mismatches else 0 + + # perturb + failures: list[str] = [] + counts: collections.Counter[str] = collections.Counter() + started = time.time() + lock = threading.Lock() + + def scenarios(case: dict): + servers: list[str] = [] + for _qname, _rdtype, offered in case["offers"]: + for server in offered: + if server not in servers: + servers.append(server) + servers = servers[: args.max_servers] + yield ("order", None, None) + for server in servers: + yield ("order", server, None) + for server in servers: + for fault in FAULTS: + yield ("fault", server, fault) + + def run_case(case: dict) -> tuple[int, list[str]]: + local: list[str] = [] + ran = 0 + # A cassette that does not reproduce cannot judge a perturbation. + base, base_violations = replay(case) + if base_violations: + local.append(f"{case['domain']}/{case['rdtype']} baseline: {base_violations}") + for kind, server, fault in scenarios(case): + ran += 1 + if kind == "order": + observed, violations = replay(case, order=server) + allowed = transition_allowed(base, observed, "") + label = f"order={server or 'recorded'}" + else: + observed, violations = replay(case, fault=(server, fault)) + allowed = transition_allowed(base, observed, fault) + label = f"{fault}@{server}" + with lock: + counts[observed] += 1 + if not allowed: + local.append(f"{case['domain']}/{case['rdtype']} [{label}] {base} -> {observed}") + for violation in violations: + local.append(f"{case['domain']}/{case['rdtype']} [{label}] {violation}") + return ran, local + + with ThreadPoolExecutor(max_workers=args.workers) as pool: + results = list(pool.map(run_case, cases)) + total = sum(r[0] for r in results) + for _ran, local in results: + failures.extend(local) + + print(f"\n{len(cases)} cassettes, {total} perturbed replays in {time.time() - started:.0f}s", file=sys.stderr) + for outcome, count in counts.most_common(): + print(f" {outcome:22s} {count:7d}", file=sys.stderr) + print(f" failures: {len(failures)}", file=sys.stderr) + for failure in failures[:60]: + print(f" {failure}", file=sys.stderr) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/collect_domains_adversarial.py b/scripts/collect_domains_adversarial.py new file mode 100644 index 0000000..210ed83 --- /dev/null +++ b/scripts/collect_domains_adversarial.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Layer 1 of TESTING.md: pick names because they stress a path, not because +they are popular. + +A popularity-ranked corpus is served by well-run infrastructure, so it +exercises the happy path and little else. The failures that matter live in +zones whose nameservers disagree with each other, time out, SERVFAIL, or choke +on EDNS. This probes every nameserver of every candidate zone and keeps the +ones that misbehave, tagging each with the property that got it selected so a +later failure is attributable. + +The tags are the ones that map to real defects: + +``lame-dnskey`` + At least one server answers the zone's DNSKEY query with NOERROR and an + empty answer section while a sibling serves the real thing. One of these in + an NS set was enough to make 0.1.0 report the zone BOGUS. +``no-aa-dnskey`` + A server answers the DNSKEY query without the AA bit: a parent-side server + returning a referral where an answer was asked for. +``unreliable-ns`` / ``servfail-ns`` / ``formerr-edns`` + A server that times out, fails, or cannot cope with an OPT record while its + siblings are healthy. Each of these is a chance to conclude something false + about the zone. +``rrsig-missing`` + A server returns the DNSKEY RRset with no RRSIG over it: what a DO-stripping + middlebox looks like from here. +``single-ns`` + One address for the whole zone, so there is no sibling to fall back to. +``signed`` / ``unsigned`` + Whether any server served a signed DNSKEY RRset. + +Usage: + python scripts/collect_domains_adversarial.py --csv domains.csv -o adversarial.csv + python scripts/collect_domains_adversarial.py --csv domains.csv --sample 2000 +""" + +from __future__ import annotations + +import argparse +import collections +import csv +import random +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from pathlib import Path + +import dns.flags +import dns.message +import dns.name +import dns.query +import dns.rcode +import dns.rdatatype + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from recursive_resolver import RecursiveResolver, ResolverError # noqa: E402 + +MAX_ADDRESSES = 8 +PER_SERVER_INTERVAL = 0.25 # seconds between queries to the same address + + +class RateLimiter: + """One query per address per interval: probing is not a licence to flood.""" + + def __init__(self, interval: float) -> None: + self.interval = interval + self._last: dict[str, float] = {} + self._lock = threading.Lock() + + def wait(self, key: str) -> None: + while True: + with self._lock: + now = time.monotonic() + last = self._last.get(key, 0.0) + if now - last >= self.interval: + self._last[key] = now + return + delay = self.interval - (now - last) + time.sleep(delay) + + +@dataclass +class Probe: + address: str + rcode: str = "TIMEOUT" + authoritative: bool = False + has_dnskey: bool = False + has_rrsig: bool = False + edns_only_failure: bool = False + + +@dataclass +class ZoneReport: + zone: str + probes: list[Probe] = field(default_factory=list) + + def tags(self) -> list[str]: + tags: list[str] = [] + answered = [p for p in self.probes if p.rcode != "TIMEOUT"] + with_key = [p for p in answered if p.has_dnskey] + empty = [p for p in answered if p.rcode == "NOERROR" and not p.has_dnskey] + + if not self.probes: + return ["no-nameservers"] + if with_key and empty: + tags.append("lame-dnskey") + if any(p.has_dnskey and not p.authoritative for p in answered) or any( + p.rcode == "NOERROR" and not p.authoritative and not p.has_dnskey for p in answered + ): + tags.append("no-aa-dnskey") + if len(answered) < len(self.probes) and answered: + tags.append("unreliable-ns") + if not answered: + tags.append("all-ns-down") + if any(p.rcode == "SERVFAIL" for p in self.probes): + tags.append("servfail-ns") + if any(p.edns_only_failure for p in self.probes): + tags.append("formerr-edns") + if any(p.has_dnskey and not p.has_rrsig for p in self.probes): + tags.append("rrsig-missing") + if len(self.probes) == 1: + tags.append("single-ns") + tags.append("signed" if any(p.has_rrsig for p in self.probes) else "unsigned") + return tags + + +def zone_of(resolver: RecursiveResolver, name: str) -> str | None: + """The closest enclosing zone that answers an NS query.""" + labels = dns.name.from_text(name) + for _ in range(4): + try: + resolver.resolve(str(labels), "NS") + return str(labels) + except ResolverError: + if labels == dns.name.root: + return None + labels = labels.parent() + return None + + +def addresses_for(resolver: RecursiveResolver, zone: str) -> list[str]: + try: + ns_names = resolver.resolve(zone, "NS") + except ResolverError: + return [] + out: list[str] = [] + for ns in sorted(ns_names): + if len(out) >= MAX_ADDRESSES: + break + try: + out.extend(resolver.resolve(ns.rstrip("."), "A")) + except ResolverError: + continue + return list(dict.fromkeys(resolver.address_filter.filter(out)))[:MAX_ADDRESSES] + + +def probe(zone: str, address: str, limiter: RateLimiter, timeout: float) -> Probe: + result = Probe(address=address) + limiter.wait(address) + query = dns.message.make_query(zone, dns.rdatatype.DNSKEY, use_edns=0, payload=1232, want_dnssec=True) + query.flags &= ~dns.flags.RD + try: + response = dns.query.udp(query, address, timeout=timeout, raise_on_truncation=True) + except dns.message.Truncated: + result.rcode = "TRUNCATED" + return result + except Exception: # noqa: BLE001 - any failure is "this server did not answer" + return result + + result.rcode = dns.rcode.to_text(response.rcode()) + result.authoritative = bool(response.flags & dns.flags.AA) + zone_name = dns.name.from_text(zone) + result.has_dnskey = any( + rrset.rdtype == dns.rdatatype.DNSKEY and rrset.name == zone_name for rrset in response.answer + ) + result.has_rrsig = any( + rrset.rdtype == dns.rdatatype.RRSIG and any(rr.type_covered == dns.rdatatype.DNSKEY for rr in rrset) + for rrset in response.answer + ) + + if response.rcode() in (dns.rcode.FORMERR, dns.rcode.NOTIMP): + # Confirm it is the OPT record the server dislikes, not the question. + limiter.wait(address) + plain = dns.message.make_query(zone, dns.rdatatype.DNSKEY, use_edns=False) + plain.flags &= ~dns.flags.RD + try: + second = dns.query.udp(plain, address, timeout=timeout) + result.edns_only_failure = second.rcode() == dns.rcode.NOERROR + except Exception: # noqa: BLE001 + result.edns_only_failure = False + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--csv", required=True, help="Input corpus CSV (domain,category)") + parser.add_argument("-o", "--output", default="adversarial.csv", help="Output CSV path") + parser.add_argument("--sample", type=int, default=0, help="Probe a random sample of N names (0 = all)") + parser.add_argument("--seed", type=int, default=20260830, help="Sampling seed") + parser.add_argument("--workers", type=int, default=24, help="Concurrent zone probes") + parser.add_argument("--timeout", type=float, default=3.0, help="Per-query timeout") + args = parser.parse_args() + + with open(args.csv, newline="", encoding="utf-8") as handle: + rows = [(row["domain"], row.get("category", "")) for row in csv.DictReader(handle)] + if args.sample and args.sample < len(rows): + rows = random.Random(args.seed).sample(rows, args.sample) + + limiter = RateLimiter(PER_SERVER_INTERVAL) + reports: dict[str, ZoneReport] = {} + zone_lock = threading.Lock() + done = 0 + + def work(item: tuple[str, str]) -> tuple[str, str, str] | None: + nonlocal done + domain, category = item + resolver = RecursiveResolver(timeout=args.timeout, max_resolution_time=20.0, dnssec=False) + try: + zone = zone_of(resolver, domain) + if zone is None: + return None + with zone_lock: + cached = reports.get(zone) + if cached is None: + report = ZoneReport(zone=zone) + for address in addresses_for(resolver, zone): + report.probes.append(probe(zone, address, limiter, args.timeout)) + with zone_lock: + reports.setdefault(zone, report) + cached = reports[zone] + return domain, category, " ".join(cached.tags()) + except Exception as exc: # noqa: BLE001 - a probe failure is data, not a crash + print(f" {domain}: {type(exc).__name__}: {exc}", file=sys.stderr) + return None + finally: + done += 1 + if done % 100 == 0: + print(f" probed {done}/{len(rows)}", file=sys.stderr, flush=True) + + started = time.time() + with ThreadPoolExecutor(max_workers=args.workers) as pool: + results = [r for r in pool.map(work, rows) if r is not None] + + with open(args.output, "w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(["domain", "category", "tags"]) + writer.writerows(sorted(results)) + + counts: collections.Counter[str] = collections.Counter() + for _domain, _category, tags in results: + counts.update(tags.split()) + print(f"\n{len(results)} names over {len(reports)} zones in {time.time() - started:.0f}s", file=sys.stderr) + for tag, count in counts.most_common(): + print(f" {tag:20s} {count:6d}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/expand_corpus.py b/scripts/expand_corpus.py new file mode 100644 index 0000000..68a5815 --- /dev/null +++ b/scripts/expand_corpus.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Widen a corpus until it looks like the DNS, not like a top-sites list. + +`collect_domains_diverse.py` samples popularity bands, every TLD and the Public +Suffix List, which is a good spread but a small one. The failures this project +has actually shipped came from the parts of the namespace a top-sites list +barely touches: ccTLD registries with their own idea of how to answer, empty +non-terminals under service labels, deep names that exist only as ancestors, +and the reverse tree. + +So this fans the corpus out along the axes that matter: + +**Registry diversity** + Tranco bucketed by TLD, taking a bounded number from *each* registry rather + than the head of the list. A ccTLD run by a small registry is where lame + servers, unusual denial proofs and split-brain NS sets live, and there are + a couple of hundred of them. + +**Depth** + Service and mail labels (`_dmarc`, `_25._tcp`, `selector1._domainkey`), + which are the names real applications look up, plus names that exist only + as ancestors of other names, plus names several labels deep that do not + exist at all. + +**The reverse tree** + `in-addr.arpa` is signed, delegated in a shape nothing else shares, and + almost never covered by a corpus of web domains. + +Usage: + python scripts/expand_corpus.py --base base.csv -o extended.csv + python scripts/expand_corpus.py --base base.csv -o extended.csv --per-registry 60 +""" + +from __future__ import annotations + +import argparse +import collections +import csv +import io +import random +import sys +import zipfile +from urllib.request import Request, urlopen + +TRANCO_URL = "https://tranco-list.eu/top-1m.csv.zip" +USER_AGENT = "recursive-resolver-corpus/1.0" + +# Labels real applications look up, several of which create empty non-terminals +# on the way down. +SERVICE_LABELS = ( + "_dmarc", + "_domainkey", + "selector1._domainkey", + "google._domainkey", + "_acme-challenge", + "_mta-sts", + "_smtp._tls", + "_25._tcp", + "_443._tcp", + "_sip._tcp", + "_sips._tcp", + "_xmpp-server._tcp", + "_autodiscover._tcp", + "www", + "mail", + "smtp", + "autodiscover", + "ftp", + "ns1", +) + +# Public addresses spread across registries, for the reverse tree. +REVERSE_SEEDS = ( + "8.8.8.8", + "1.1.1.1", + "9.9.9.9", + "208.67.222.222", + "193.0.14.129", + "199.7.83.42", + "192.36.148.17", + "128.63.2.53", + "202.12.27.33", + "196.216.168.23", + "200.3.14.10", + "2.16.0.1", + "13.107.236.5", + "151.101.1.140", + "104.16.132.229", + "185.199.108.153", +) + + +def fetch_tranco() -> list[str]: + print(f"Downloading Tranco from {TRANCO_URL} ...", file=sys.stderr) + request = Request(TRANCO_URL, headers={"User-Agent": USER_AGENT}) + with urlopen(request, timeout=120) as response: # noqa: S310 - fixed https URL + archive = zipfile.ZipFile(io.BytesIO(response.read())) + text = archive.read(archive.namelist()[0]).decode("utf-8", "replace") + return [row[1].strip().lower() for row in csv.reader(io.StringIO(text)) if len(row) >= 2] + + +def registry_of(domain: str) -> str: + return domain.rsplit(".", 1)[-1] if "." in domain else domain + + +def by_registry(domains: list[str], per_registry: int, rng: random.Random) -> list[tuple[str, str]]: + """Up to ``per_registry`` names from every TLD, ccTLDs marked as such.""" + buckets: dict[str, list[str]] = collections.defaultdict(list) + for domain in domains: + buckets[registry_of(domain)].append(domain) + out: list[tuple[str, str]] = [] + for tld, names in buckets.items(): + # Two-letter TLDs are the country-code ones, bar a handful of legacy + # exceptions that behave like gTLDs anyway. + kind = "cctld" if len(tld) == 2 else ("idn-tld" if tld.startswith("xn--") else "gtld") + picked = rng.sample(names, min(per_registry, len(names))) + out.extend((name, f"{kind}-{tld}") for name in picked) + return out + + +def derive_depth(apexes: list[str], count: int, rng: random.Random) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + for apex in rng.sample(apexes, min(count, len(apexes))): + label = rng.choice(SERVICE_LABELS) + kind = "deep-service-underscore" if "_" in label else "deep-service" + out.append((f"{label}.{apex}", kind)) + if rng.random() < 0.25: + # The parent of a service label usually exists only as an ancestor. + out.append((f"{label.split('.')[-1]}.{apex}", "empty-non-terminal-probe")) + if rng.random() < 0.15: + out.append((f"{rng.randrange(10**6)}.a.b.{apex}", "deep-nonexistent")) + return out + + +def reverse_names(rng: random.Random, extra: int = 200) -> list[tuple[str, str]]: + out = [(".".join(reversed(ip.split("."))) + ".in-addr.arpa", "reverse-v4") for ip in REVERSE_SEEDS] + for _ in range(extra): + octets = [rng.randrange(1, 224), rng.randrange(256), rng.randrange(256), rng.randrange(1, 255)] + if octets[0] in (10, 127, 169, 172, 192, 100): + continue + out.append((".".join(str(o) for o in reversed(octets)) + ".in-addr.arpa", "reverse-v4-random")) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--base", required=True, help="Corpus CSV to extend (domain,category[,tags])") + parser.add_argument("-o", "--output", default="extended.csv") + parser.add_argument("--per-registry", type=int, default=40, help="Names taken from each TLD") + parser.add_argument("--depth", type=int, default=2500, help="Apexes to layer service labels onto") + parser.add_argument("--seed", type=int, default=20260830) + parser.add_argument("--no-tranco", action="store_true") + args = parser.parse_args() + + rng = random.Random(args.seed) + with open(args.base, newline="", encoding="utf-8") as handle: + base = [(row["domain"], row.get("category", ""), row.get("tags", "")) for row in csv.DictReader(handle)] + + collected: list[tuple[str, str]] = [(d, c) for d, c, _t in base] + apexes = [d for d, c, _t in base if c.startswith("tranco")] + + if not args.no_tranco: + ranked = fetch_tranco() + spread = by_registry(ranked, args.per_registry, rng) + collected.extend(spread) + apexes.extend(name for name, _kind in spread) + registries = {kind.split("-", 1)[1] for _name, kind in spread} + print(f" registry spread: {len(spread)} names over {len(registries)} TLDs", file=sys.stderr) + + depth = derive_depth(apexes, args.depth, rng) + collected.extend(depth) + print(f" depth: {len(depth)} names", file=sys.stderr) + + reverse = reverse_names(rng) + collected.extend(reverse) + print(f" reverse: {len(reverse)} names", file=sys.stderr) + + tags = {domain: tag for domain, _category, tag in base if tag} + seen: dict[str, str] = {} + for domain, category in collected: + seen.setdefault(domain, category) + + with open(args.output, "w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(["domain", "category", "tags"]) + for domain, category in sorted(seen.items()): + writer.writerow([domain, category, tags.get(domain, "")]) + + counts = collections.Counter(category.split("-")[0] for category in seen.values()) + print(f"\n{len(seen)} names", file=sys.stderr) + for kind, count in counts.most_common(20): + print(f" {kind:26s} {count:6d}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/mutation_check.py b/scripts/mutation_check.py new file mode 100644 index 0000000..b856b4d --- /dev/null +++ b/scripts/mutation_check.py @@ -0,0 +1,787 @@ +#!/usr/bin/env python3 +"""Layer 6 of TESTING.md: check that the suites actually bite. + +A green test run proves nothing on its own: the tests that pass today would +also have passed against 0.1.0, which is how the DNSSEC failures shipped. This +reintroduces each known defect into a *copy* of the package, one at a time, and +reports which layer catches it. A mutant that survives every layer is a hole in +the protocol, not a curiosity. + +The catalogue is explicit source rewrites rather than a generic mutation tool, +for two reasons: each entry documents the real defect it reproduces, and a +rewrite that no longer applies is reported loudly, so the catalogue cannot +quietly rot as the code moves. + +Nothing here touches the working tree: the package is copied to a temporary +directory and the harnesses are pointed at it with ``RR_SRC``. + +Usage: + python scripts/mutation_check.py --cassettes cassettes.jsonl + python scripts/mutation_check.py --cassettes cassettes.jsonl --only lame-dnskey +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# (label, description, [(old, new), ...]) - each entry is a defect that really +# existed, or a plausible weakening of the control that replaced it. +MUTATIONS: list[tuple[str, str, list[tuple[str, str]]]] = [ + ( + "lame-dnskey", + "0.1.0: an empty DNSKEY answer condemns the zone instead of sweeping on", + [ + ( + """ response, _server = self._send_query( + zone, dns.rdatatype.DNSKEY, nameservers, ctx, usable=self._usable_dnskey(zone) + ) + if response is None: + raise DNSSECMaterialUnavailableError(str(zone), "DNSKEY") + + dnskey_rrset = self._find_answer_rrset(response, zone, dns.rdatatype.DNSKEY) + if dnskey_rrset is None: # pragma: no cover - the predicate rules this out + raise DNSSECMaterialUnavailableError(str(zone), "DNSKEY")""", + """ response, _server = self._send_query(zone, dns.rdatatype.DNSKEY, nameservers, ctx) + if response is None: + raise ResolutionTimeoutError(str(zone), "DNSKEY") + + dnskey_rrset = self._find_answer_rrset(response, zone, dns.rdatatype.DNSKEY) + if dnskey_rrset is None: + return ZoneKeys(zone, None, ValidationState.BOGUS)""", + ) + ], + ), + ( + "no-do-bit", + "0.1.0: the last sweep of the EDNS ladder drops the OPT record", + [(" return min(512, self.edns_payload) if dnssec else None", " return None")], + ), + ( + "edns-incapable-retried", + "0.1.0: an EDNS-incapable server is re-queried without DO while validating", + [ + ( + """ if ctx.dnssec: + abandoned.add(server) + if last_resort is None: + last_resort = (response, server) + else: + no_edns.add(server)""", + " no_edns.add(server)", + ) + ], + ), + ( + "ds-fetch-no-sweep", + "0.1.0: the DS fetch believes the first server that answers", + [ + ( + """ response, _server = self._send_query( + next_zone, dns.rdatatype.DS, nameservers, ctx, usable=self._usable_ds(next_zone) + )""", + " response, _server = self._send_query(next_zone, dns.rdatatype.DS, nameservers, ctx)", + ) + ], + ), + ( + "insecure-child-bogus", + "0.1.0: an unsigned answer from a signed parent is BOGUS without checking the delegation", + [ + ( + """ if name == zone or not name.is_subdomain(zone): + return False""", + """ if name == zone or not name.is_subdomain(zone): + return False + return False""", + ) + ], + ), + ( + "optout-wildcard-secure", + "0.2.0-rc: an opt-out NSEC3 is accepted as proof for a wildcard expansion", + [ + ( + """ if any(not n[0].flags & NSEC3_OPT_OUT for n in covering): + return ValidationState.SECURE + return ValidationState.INSECURE""", + " return ValidationState.SECURE", + ) + ], + ), + ( + "aa-not-required", + "weakening: accept a DNSKEY answer from a server that is not authoritative", + [ + ( + """ if self.require_authoritative and not response.flags & dns.flags.AA: + return False + if self._find_answer_rrset(response, zone, dns.rdatatype.DNSKEY) is None:""", + """ if self._find_answer_rrset(response, zone, dns.rdatatype.DNSKEY) is None:""", + ) + ], + ), + ( + "ent-nodata-bogus", + "0.1.0: an empty non-terminal in an NSEC zone is BOGUS instead of NODATA", + [ + ( + """ following: dns.name.Name = nsec[0].next + if following != qname and following.is_subdomain(qname) and self._nsec_covers(nsec.name, following, qname): + return ValidationState.SECURE""", + " continue", + ) + ], + ), + ( + "wildcard-nodata-bogus", + "0.2.0-rc: a NODATA synthesised from a wildcard is BOGUS (NSEC3 form)", + [ + ( + """ if next_closer_covers: + wildcard = dns.name.Name((b"*",) + closest.labels) + for nsec3 in nsec3s: + if self._nsec3_owner(wildcard, nsec3.name, params, budget) != nsec3.name: + continue + types = _types_in_bitmap(nsec3[0]) + if rdtype not in types and dns.rdatatype.CNAME not in types: + if any(not n[0].flags & NSEC3_OPT_OUT for n in next_closer_covers): + return ValidationState.SECURE + break""", + " pass", + ) + ], + ), + ( + "wildcard-nodata-bogus-nsec", + "0.2.0-rc: the same, in an NSEC zone", + [ + ( + """ deniers = [n for n in nsecs if self._may_deny_below(n, qname, signer)] + for covering in deniers:""", + """ deniers = [] + for covering in deniers:""", + ) + ], + ), + ( + "optout-no-ds-at-the-child-only", + "0.2.0-rc: the no-DS opt-out proof looks for a cover of the delegation itself", + [ + ( + """ closest = self._closest_encloser_nsec3(child, nsec3s, params, budget) + if closest is None: + return False + next_closer = self._next_closer(child, closest) + if next_closer is None: + return False + for nsec3 in nsec3s: + if nsec3[0].flags & NSEC3_OPT_OUT and self._nsec3_covers(next_closer, nsec3, params, budget): + return True""", + """ for nsec3 in nsec3s: + if nsec3[0].flags & NSEC3_OPT_OUT and self._nsec3_covers(child, nsec3, nsec3[0], budget): + return True""", + ) + ], + ), + ( + "authenticated-ttl-not-capped", + "spec audit: cached authenticated data outlives its signature (RFC 4035 5.3.3)", + [ + ( + """ ttl = min( + rrset.ttl, + rrsig.ttl, + int(signature.original_ttl), + max(0, int(signature.expiration) - int(time.time())), + )""", + " ttl = rrset.ttl", + ) + ], + ), + ( + "spoofed-packet-retires-a-server", + "hardening: one wrong-ID datagram abandons a healthy nameserver (RFC 5452 9)", + [ + ( + "dns.query.udp_with_fallback(query, server, timeout=timeout, ignore_errors=True)", + "dns.query.udp_with_fallback(query, server, timeout=timeout)", + ) + ], + ), + ( + "require-dnssec-skips-denials", + "policy: require_dnssec accepts an unauthenticated 'no such name'", + [ + ( + """ if self.require_dnssec and state is not ValidationState.SECURE: + raise DNSSECInsecureError(str(qname), rdtype_text)""", + " return None", + ) + ], + ), + ( + "wildcard-trigger-reads-the-first-rrsig", + "spec audit: the RFC 4035 5.3.4 wildcard proof keys off rrsig[0], not the one that verified", + [ + ( + " if not rrset.name.is_wild() and signature.labels < len(rrset.name.labels) - 1:", + " if not rrset.name.is_wild() and rrsig[0].labels < len(rrset.name.labels) - 1:", + ) + ], + ), + ( + "cached-denials-skip-require-dnssec", + "policy: a cached denial is served without the strict-mode check", + [ + ( + """ self._require_proven_denial( + ValidationState.SECURE if nx.secure else ValidationState.INSECURE, + qname, + dns.rdatatype.to_text(rdtype), + ) +""", + "", + ) + ], + ), + ( + "cross-zone-signer-accepted", + "spec audit: any zone may sign for any name (RFC 4035 5.3.1) - full DNSSEC bypass", + [ + ( + """ if not rrset.name.is_subdomain(rrsig.signer): + logger.debug("RRSIG for %s claims signer %s, which does not contain it", rrset.name, rrsig.signer) + continue +""", + "", + ) + ], + ), + ( + "failed-denial-not-retried", + "RFC 4035 5.5: one out-of-sync server condemns a zone's denials", + [ + ( + """ fresh, _server = self._send_query( + qname, rdtype, nameservers, ctx, usable=self._usable_denial(qname, rdtype, keyring, ctx, negative) + ) + if fresh is not None:""", + """ fresh = None + if fresh is not None:""", + ) + ], + ), + ( + "any-silently-becomes-nodata", + "reference gap: an ANY query is accepted, matches nothing, and caches a denial", + [ + ( + ' raise UnsupportedRdtypeError(rdtype, "ANY cannot be queried; ask for specific types")', + " return value", + ) + ], + ), + ( + "dname-not-followed", + "reference gap: RFC 6672 CNAME synthesis is not performed", + [ + ( + """ dname_rrset = self._find_dname(response, qname, current_zone) + if dname_rrset is not None: + target = self._dname_target(qname, dname_rrset)""", + """ dname_rrset = None + if dname_rrset is not None: + target = self._dname_target(qname, dname_rrset)""", + ) + ], + ), + ( + "synthesized-cname-demands-a-signature", + "reference gap: the unsigned CNAME a DNAME implies is read as a stripped signature", + [ + ( + """ if rdtype == dns.rdatatype.CNAME and dname_rrset is not None: + return self._validate_answer( + response, dname_rrset, qname, dns.rdatatype.DNAME, ctx, zone, nameservers, state, ds + ) +""", + "", + ) + ], + ), + ( + "sideways-ns-set-read-as-nodata", + "reference gap: an NS set that does not delegate onward is cached as a denial", + [ + ( + " if ns_sets and not any(" + "self._delegates_towards(rrset.name, qname, current_zone) for rrset in ns_sets):\n" + ' return {"type": "error", "rcode": rcode, ' + '"detail": "NS set that does not delegate below this zone"}\n', + "", + ) + ], + ), + ( + "negative-soa-from-any-zone", + "reference gap: an SOA from above the zone marks a negative answer and sets its TTL", + [ + ( + """ and qname.is_subdomain(rrset.name) + and rrset.name.is_subdomain(current_zone)""", + " and qname.is_subdomain(rrset.name)", + ) + ], + ), + ( + "glue-outlives-its-own-ttl", + "reference gap: cached addresses expire with the NS set, not with the glue", + [ + ( + """ if glue_ttls: + ttl = min(ttl, *glue_ttls) +""", + "", + ) + ], + ), + ( + "glueless-ns-is-ipv4-only", + "reference gap: AAAA is never queried for a glueless NS target", + [ + ( + " wanted = [dns.rdatatype.A] if self.ipv4_only else [dns.rdatatype.A, dns.rdatatype.AAAA]", + " wanted = [dns.rdatatype.A]", + ) + ], + ), + ( + "high-nsec3-iterations-are-bogus", + "reference gap: a legacy iteration count is refused where the ecosystem downgrades", + [ + ( + """ if self.nsec3_beyond_our_limits(authority, parent_keys, budget): + return ValidationState.INSECURE, None +""", + "", + ) + ], + ), + ( + "positive-wildcard-ignores-the-labels-count", + "reference gap: the NSEC wildcard proof accepts any covering record, not the encloser's", + [ + ( + """ if self._closest_encloser_nsec(qname, nsec) == closest: + return ValidationState.SECURE""", + " return ValidationState.SECURE", + ) + ], + ), + ( + "child-may-deny-its-own-ds", + "reference gap: a SOA-bearing record denies a DS (RFC 4035 5.2)", + [ + ( + " if rdtype == dns.rdatatype.DS and dns.rdatatype.SOA in types " + "and qname != dns.name.root:\n continue\n", + "", + ) + ], + ), + ( + "no-clock-skew-on-inception", + "reference gap: no slack for a signer whose clock runs ahead", + [ + ( + " if not _serial_le(int(rrsig.inception), (stamp + self.clock_skew) & 0xFFFFFFFF):", + " if not _serial_le(int(rrsig.inception), stamp):", + ) + ], + ), + ( + "expired-signatures-still-cost-crypto", + "hardening: the validity window is checked after the crypto, not before", + [ + ( + """ if not _serial_le(stamp, int(rrsig.expiration)): + logger.debug("RRSIG for %s has expired", rrset.name) + continue +""", + "", + ) + ], + ), + ( + "cached-delegation-ns-names-dropped", + "reference gap: a dead cached delegation is not re-resolved from its NS names", + [ + ( + " pending_ns_names: list[dns.name.Name] = cached_ns_names", + " pending_ns_names: list[dns.name.Name] = []", + ) + ], + ), + ( + "nested-wildcard-nodata-at-any-ancestor", + "spec audit: wildcard NODATA takes any ancestor's wildcard, not the closest encloser's", + [ + ( + """ wildcard = dns.name.Name((b"*",) + self._closest_encloser_nsec(qname, covering).labels) + for nsec in deniers:""", + """ wildcard = dns.name.Name((b"*",) + qname.labels[1:]) + for nsec in deniers:""", + ) + ], + ), + ( + "opt-out-wildcard-cover-called-secure", + "spec audit: only the next-closer cover is checked for opt-out, not the wildcard's", + [ + ( + """ for covers in (covering, wildcard_covering): + if not any(not n[0].flags & NSEC3_OPT_OUT for n in covers): + return ValidationState.INSECURE + return ValidationState.SECURE""", + """ if any(not n[0].flags & NSEC3_OPT_OUT for n in covering): + return ValidationState.SECURE + return ValidationState.INSECURE""", + ) + ], + ), + ( + "wildcard-denial-at-any-ancestor", + "spec audit: NXDOMAIN accepted by denying a wildcard above the closest encloser", + [ + ( + """ wildcard = dns.name.Name((b"*",) + self._closest_encloser_nsec(qname, nsec).labels) + if any(self._nsec_covers(n.name, n[0].next, wildcard) for n in nsecs): + return True""", + """ for i in range(1, len(qname.labels)): + wildcard = dns.name.Name((b"*",) + qname.labels[i:]) + if any(self._nsec_covers(n.name, n[0].next, wildcard) for n in nsecs): + return True""", + ) + ], + ), + ( + "closest-encloser-from-the-parent-side", + "spec audit: a delegation or DNAME record is accepted as closest encloser (RFC 5155 8.3)", + [ + ( + """ types = _types_in_bitmap(nsec3[0]) + if dns.rdatatype.DNAME in types: + return None + if dns.rdatatype.NS in types and dns.rdatatype.SOA not in types: + return None + return current""", + " return current", + ) + ], + ), + ( + "matching-nsec3-ignores-the-bitmap", + "weakening: a matching NSEC3 denies the type without consulting its bitmap (RFC 5155 8.5)", + [ + ( + """ if rdtype not in types and dns.rdatatype.CNAME not in types: + return ValidationState.SECURE + + # Everything below needs the closest encloser""", + """ return ValidationState.SECURE + + # Everything below needs the closest encloser""", + ) + ], + ), + ( + "opt-out-nxdomain-called-secure", + "spec audit: an NXDOMAIN proven only by an opt-out cover is reported authenticated", + [ + ( + """ if any(not n[0].flags & NSEC3_OPT_OUT for n in covering): + return ValidationState.SECURE + return ValidationState.INSECURE""", + " return ValidationState.SECURE", + ) + ], + ), + ( + "ds-denied-by-the-parent-side-record-refused", + "spec audit: RFC 6840 4.1 exempts DS, so refusing it breaks every DS lookup", + [ + ( + " if rdtype != dns.rdatatype.DS and self._parent_side(nsec, qname, signer):", + " if self._parent_side(nsec, qname, signer):", + ) + ], + ), + ( + "any-key-may-sign-the-dnskey-rrset", + "spec audit: the DNSKEY RRset is checked against every published key, not the DS-matched one", + [ + ( + " if self.validate_rrset(dnskey_rrset, rrsig_rrset, {zone: entry_points}, budget=budget):", + " if self.validate_rrset(dnskey_rrset, rrsig_rrset, {zone: dnskey_rrset}, budget=budget):", + ) + ], + ), + ( + "revoked-keys-still-validate", + "spec audit: a DNSKEY with the REVOKE bit is still used to verify signatures (RFC 5011 2.1)", + [ + ( + """ if revoked: + logger.debug("Ignoring revoked DNSKEY for %s", name) + continue +""", + "", + ) + ], + ), + ( + "parent-side-denial-accepted", + "spec audit: an ancestor delegation record denies things below the cut (RFC 6840 4.1)", + [ + ( + """ types = _types_in_bitmap(nsec[0]) + if dns.rdatatype.NS not in types or dns.rdatatype.SOA in types: + return False + return zone is not None and len(zone) < len(owner)""", + " return False", + ) + ], + ), + ( + "dname-denial-accepted", + "spec audit: a DNAME record denies subdomains it rewrites (RFC 6840 4.1)", + [ + ( + """ if dns.rdatatype.DNAME in _types_in_bitmap(nsec[0]): + return False +""", + "", + ) + ], + ), + ( + "nsec3-reserved-flags-accepted", + "spec audit: NSEC3 records with reserved flag bits are used (RFC 5155 8.2)", + [ + ( + """ if rdtype == dns.rdatatype.NSEC3 and rrset[0].flags & ~NSEC3_OPT_OUT: + logger.debug("Ignoring NSEC3 at %s with reserved flags %#x", rrset.name, rrset[0].flags) + continue +""", + "", + ) + ], + ), + ( + "ent-in-the-chain-walk-condemned", + "0.2.0-rc: an intermediate label that is not a cut breaks the chain walk", + [ + ( + """ if state is ValidationState.BOGUS and self._validator.prove_no_delegation( + next_zone, records, keyring, budget=ctx.budget + ): + # Not a cut: an empty non-terminal, or a name with records but + # no NS. The zone in force is unchanged, so carry on down. + continue +""", + "", + ) + ], + ), + ( + "no-delegation-proof-ignores-the-bitmap", + "weakening: any matching NSEC counts as proof that a label is not a cut", + [ + ( + """ if nsec.name == name: + types = _types_in_bitmap(nsec[0]) + if dns.rdatatype.NS not in types and dns.rdatatype.DS not in types: + return True + continue + if not self._may_deny_below(nsec, name, signer): + continue""", + """ if nsec.name == name: + return True + if not self._may_deny_below(nsec, name, signer): + continue""", + ) + ], + ), + ( + "referral-to-a-higher-cut-accepted", + "0.2.0-rc: 'ask further down' is taken as an answer about the delegation", + [ + ( + """ if any( + rrset.rdtype == dns.rdatatype.NS and rrset.name != zone and zone.is_subdomain(rrset.name) + for rrset in records + ): + return False +""", + "", + ) + ], + ), + ( + "opt-out-nodata-called-bogus", + "0.2.0-rc: a NODATA whose only proof is an opt-out gap is refused outright", + [ + ( + """ if any(n[0].flags & NSEC3_OPT_OUT for n in next_closer_covers): + return ValidationState.INSECURE""", + """ if rdtype == dns.rdatatype.DS and any( + n[0].flags & NSEC3_OPT_OUT for n in next_closer_covers + ): + return ValidationState.INSECURE""", + ) + ], + ), + ( + "opt-out-ds-nodata-authenticated", + "0.2.0-rc: an opt-out cover is reported as an authenticated absent DS", + [ + ( + """ if any(n[0].flags & NSEC3_OPT_OUT for n in next_closer_covers): + return ValidationState.INSECURE + return ValidationState.BOGUS""", + """ if any(n[0].flags & NSEC3_OPT_OUT for n in next_closer_covers): + return ValidationState.SECURE + return ValidationState.BOGUS""", + ) + ], + ), + ( + "opt-out-wildcard-nodata-authenticated", + "0.2.0-rc: a wildcard NODATA whose next closer is only opt-out covered is authenticated", + [ + ( + """ if any(not n[0].flags & NSEC3_OPT_OUT for n in next_closer_covers): + return ValidationState.SECURE + break""", + """ return ValidationState.SECURE""", + ) + ], + ), + ( + "bare-referral-condemned", + "0.2.0-rc: a referral carrying no DS and no denial is BOGUS rather than walked", + [ + ( + " if not self._usable_ds(child_zone)(response):", + " if False:", + ) + ], + ), +] + + +def apply_mutation(target: Path, edits: list[tuple[str, str]]) -> None: + for name in ("resolver.py", "dnssec.py"): + path = target / "recursive_resolver" / name + text = path.read_text(encoding="utf-8") + changed = False + for old, new in edits: + # An empty search string matches anything, so the rewrite would + # land at offset 0 and break the file: caught by every layer, and + # a test of nothing. + if not old: + raise SystemExit("mutation has an empty search string") + if old in text: + text = text.replace(old, new, 1) + changed = True + if changed: + path.write_text(text, encoding="utf-8") + return + raise SystemExit(f"mutation no longer applies: {edits[0][0][:60]!r}") + + +def run(cmd: list[str], env_src: Path, cwd: Path) -> bool: + """True when the command passes (i.e. the mutant survived this layer).""" + import os + + env = dict(os.environ, RR_SRC=str(env_src), PYTHONPATH=str(env_src)) + result = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=str(cwd)) + return result.returncode == 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--cassettes", help="Cassette file for the Layer 4 check") + parser.add_argument("--only", help="Run a single mutation by label") + parser.add_argument("--python", default=sys.executable, help="Interpreter to run the suites with") + args = parser.parse_args() + + mutations = [m for m in MUTATIONS if not args.only or m[0] == args.only] + if not mutations: + raise SystemExit(f"no mutation named {args.only!r}") + + survivors: list[str] = [] + print(f"{'mutation':<44}{'unit suite':<14}{'cassettes':<14}caught by") + for label, description, edits in mutations: + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) + # Leave __pycache__ behind. A mutation that happens to preserve + # the file's byte length can otherwise land within the same + # mtime granularity as the copied .pyc, and Python reuses the + # stale bytecode: the mutant silently never runs, and the check + # reports it as caught. + ignore = shutil.ignore_patterns("__pycache__", "*.pyc") + shutil.copytree(REPO / "src" / "recursive_resolver", target / "recursive_resolver", ignore=ignore) + shutil.copytree(REPO / "tests", target / "tests", ignore=ignore) + apply_mutation(target, edits) + + unit_ok = run( + [ + args.python, + "-m", + "pytest", + str(target / "tests"), + "-q", + "-x", + "--tb=no", + "-p", + "no:cacheprovider", + "--ignore", + str(target / "tests/test_check_authors.py"), + ], + target, + target, + ) + cassette_ok = True + if args.cassettes: + cassette_ok = run( + [args.python, str(REPO / "scripts/cassette.py"), "perturb", "--cassettes", args.cassettes], + target, + REPO, + ) + + caught = [name for name, ok in (("unit", unit_ok), ("cassettes", cassette_ok)) if not ok] + if not caught: + survivors.append(f"{label}: {description}") + print( + f"{label:<44}{'survived' if unit_ok else 'caught':<14}" + f"{('survived' if cassette_ok else 'caught') if args.cassettes else 'skipped':<14}" + f"{','.join(caught) or 'NOTHING'}" + ) + + if survivors: + print("\nSurviving mutants - the protocol has a hole here:") + for survivor in survivors: + print(f" {survivor}") + return 1 if survivors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verdict_harness.py b/scripts/verdict_harness.py new file mode 100644 index 0000000..678a9e5 --- /dev/null +++ b/scripts/verdict_harness.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +"""Layers 2 and 3 of TESTING.md: differential testing on the *verdict*, and +flap detection by repetition. + +The record-level differential harness (``diff_harness.py``) compares answer +data. It cannot see the failure that shipped in 0.1.0, where the data was fine +and the DNSSEC verdict was wrong, on a name that resolved correctly seven times +out of eight. Two things are needed for that, and both are here: + +1. **Compare the verdict.** For every name, our ``ValidationState`` is checked + against what public validating resolvers say. They do not expose their + verdict directly, so each is asked twice: once normally, and once with CD=1. + ``SERVFAIL`` that turns into an answer under CD is bogus; ``NOERROR`` with + AD is secure; ``NOERROR`` without AD is insecure. +2. **Run it more than once.** Each name is resolved K times with a fresh + resolver, so nameserver ordering is re-randomised. Any name that produces + more than one distinct outcome is a failure in its own right, whatever the + individual outcomes look like: a verdict that depends on which server + answered first is not a verdict. + +Every resolution also runs under the Layer 0 audit (``audit.py``), so the +invariants are checked against live traffic rather than only against mocks. + +Usage: + python scripts/verdict_harness.py --csv corpus.csv -o results.csv + python scripts/verdict_harness.py --csv corpus.csv --types A,MX --runs 3 + python scripts/verdict_harness.py --csv corpus.csv --sample 500 --escalate 8 +""" + +from __future__ import annotations + +import argparse +import collections +import csv +import json +import os +import random +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import asdict, dataclass, field +from pathlib import Path + +import dns.flags +import dns.message +import dns.name +import dns.query +import dns.rcode +import dns.rdatatype + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, os.environ.get("RR_SRC", str(Path(__file__).resolve().parent.parent / "src"))) + +from audit import resolve_audited # noqa: E402 + +from recursive_resolver import ( # noqa: E402 + DNSSECError, + DNSSECInsecureError, + NoAnswerError, + NXDOMAINError, + RecursiveResolver, + ValidationState, +) +from recursive_resolver.exceptions import DNSSECMaterialUnavailableError # noqa: E402 + +# Validating resolvers run by independent operators. Each was verified to set +# AD on a signed name, SERVFAIL a deliberately broken one, and answer it under +# CD=1: without all three there is no way to read a verdict off the wire. +REFERENCES = ("8.8.8.8", "1.1.1.1", "9.9.9.9", "94.140.14.14", "149.112.121.10") +DEFAULT_TYPES = "A,MX,SOA,DNSKEY" + +# Resolver options worth running the corpus under. Each changes a decision the +# resolver makes about transport or trust, and none of them should change what +# a name means. +CONFIGS: dict[str, dict] = { + "default": {}, + "no-cache": {"cache_enabled": False}, + "small-edns": {"edns_payload": 512}, + "no-tcp": {"use_tcp_fallback": False}, + "lax-aa": {"require_authoritative": False}, + "no-retries": {"max_retries": 0}, + "require-dnssec": {"require_dnssec": True}, + "tld-cache-only": {"max_delegation_cache_depth": 1}, +} + +# Used only for its name normalisation, so the references are asked about the +# same domain we resolved. +_NORMALISER = RecursiveResolver(dnssec=False, cache_enabled=False) + +# Our outcome, reduced to something comparable with a reference resolver. +SECURE = "secure" +INSECURE = "insecure" +BOGUS = "bogus" +NXDOMAIN = "nxdomain" +NODATA = "nodata" +UNAVAILABLE = "unavailable" +FAILED = "failed" + +# Outcomes that say "we could not get there", not "here is what the data means". +# A name that alternates between a verdict and one of these is reporting the +# state of somebody's nameservers; a name that alternates between two verdicts +# is reporting a defect in here. +RETRIEVAL_KINDS = frozenset({UNAVAILABLE, FAILED}) + +# Disagreements that mean we read the same data differently, as opposed to not +# having got the data at all. Only these gate a release. +VERDICT_DISAGREEMENTS = frozenset( + { + "we-say-secure-they-do-not", + "we-say-insecure-they-say-secure", + "false-bogus", + "we-answered-they-refused", + } +) + + +@dataclass +class Outcome: + kind: str + detail: str = "" + records: tuple[str, ...] = () + chain: tuple[str, ...] = () + + def key(self) -> str: + return self.kind + + def fingerprint(self) -> tuple: + """What the resolution was actually about, so two runs can be compared. + + Some names answer differently from one lookup to the next by design: + a CNAME chain behind a traffic manager can land in a signed zone or an + insecurely delegated one depending on which server replies, and a + verdict that follows the data is not a flap. + """ + return (self.kind, self.records, self.chain) + + +@dataclass +class Row: + domain: str + rdtype: str + category: str + tags: str + ours: str + detail: str + runs: int + distinct: int + flapped: bool + verdict_flap: bool + unstable_data: bool + references: dict[str, str] + disagreement: str + violations: list[str] = field(default_factory=list) + + +def our_outcome(resolver: RecursiveResolver, domain: str, rdtype: str) -> tuple[Outcome, list[str]]: + answer, exc, violations = resolve_audited(resolver, domain, rdtype) + if exc is None and answer is not None: + kind = { + ValidationState.SECURE: SECURE, + ValidationState.INSECURE: INSECURE, + ValidationState.BOGUS: BOGUS, + }[answer.dnssec] + chain = tuple(str(name) for name in answer.cname_chain) + return Outcome(kind, "", tuple(sorted(answer.records)), chain), violations + if isinstance(exc, DNSSECMaterialUnavailableError): + return Outcome(UNAVAILABLE, str(exc)), violations + if isinstance(exc, DNSSECInsecureError): + # `require_dnssec=True` refusing an unsigned answer is the option doing + # its job, and says the same thing about the name as INSECURE does. + return Outcome(INSECURE, str(exc)), violations + if isinstance(exc, DNSSECError): + return Outcome(BOGUS, str(exc)), violations + if isinstance(exc, NXDOMAINError): + return Outcome(NXDOMAIN, str(exc)), violations + if isinstance(exc, NoAnswerError): + return Outcome(NODATA, str(exc)), violations + return Outcome(FAILED, f"{type(exc).__name__}: {exc}"), violations + + +def ascii_name(domain: str, rdtype: str) -> str: + """The name our resolver will actually query, in A-label form. + + dnspython's default IDNA codec is IDNA 2003, ours is IDNA 2008, and for + some internationalised names the two encode to *different domains*. + Comparing verdicts means comparing them for the same name. + """ + try: + return str(_NORMALISER._normalize_qname(domain, rdtype)) + except Exception: # noqa: BLE001 - an unencodable name is compared as given + return domain + + +def reference_verdict(domain: str, rdtype: str, server: str, timeout: float) -> str: + """Ask a validating resolver twice: with validation, and with CD=1.""" + + def ask(check_disabled: bool): + query = dns.message.make_query(domain, rdtype, use_edns=0, payload=1232, want_dnssec=True) + if check_disabled: + query.flags |= dns.flags.CD + try: + response, _tcp = dns.query.udp_with_fallback(query, server, timeout=timeout) + except Exception: # noqa: BLE001 - a reference that does not answer is data + return None + return response + + plain = ask(False) + if plain is None: + return "timeout" + rcode = plain.rcode() + has_data = any(rrset.rdtype != dns.rdatatype.RRSIG for rrset in plain.answer) + if rcode == dns.rcode.SERVFAIL: + relaxed = ask(True) + if relaxed is not None and relaxed.rcode() == dns.rcode.NOERROR: + return BOGUS + return "servfail" + if rcode == dns.rcode.NXDOMAIN: + return "nxdomain-secure" if plain.flags & dns.flags.AD else NXDOMAIN + if rcode != dns.rcode.NOERROR: + return dns.rcode.to_text(rcode).lower() + if plain.flags & dns.flags.AD: + return SECURE if has_data else "nodata-secure" + return INSECURE if has_data else NODATA + + +def compare(ours: Outcome, references: dict[str, str]) -> str: + """Return "" when the verdicts are compatible, or a disagreement label.""" + verdicts = [v for v in references.values() if v not in ("timeout",)] + if not verdicts: + return "" + secure = [v for v in verdicts if v in (SECURE, "nodata-secure", "nxdomain-secure")] + bogus = [v for v in verdicts if v in (BOGUS, "servfail")] + insecure = [v for v in verdicts if v in (INSECURE, NODATA, NXDOMAIN)] + + # With a panel this size the odd operator will differ - a stale cache, a + # negative trust anchor, an algorithm it will not verify - so a two-thirds + # majority decides. Only an evenly split panel is inconclusive, which + # happens on ambiguous zones such as a signed zone serving one unsigned + # RRset, and there is nothing there to hold a release for. + groups = {SECURE: secure, BOGUS: bogus, INSECURE: insecure} + majority = [name for name, group in groups.items() if len(group) * 3 >= len(verdicts) * 2] + if not majority: + return "references-disagree" + secure = secure if SECURE in majority else [] + bogus = bogus if BOGUS in majority else [] + insecure = insecure if INSECURE in majority else [] + + if ours.kind == SECURE: + return "" if secure else "we-say-secure-they-do-not" + if ours.kind == INSECURE: + # The dangerous direction: a signature we failed to notice. + return "we-say-insecure-they-say-secure" if secure and not insecure else "" + if ours.kind == BOGUS: + return "" if len(bogus) >= max(2, len(verdicts) - 1) else "false-bogus" + if ours.kind in (NXDOMAIN, NODATA): + if bogus and not insecure and not secure: + return "we-answered-they-refused" + return "" + if ours.kind == UNAVAILABLE: + # Honest, but if every reference resolved it we still want to know. + return "material-unavailable-but-they-resolved" if secure or insecure else "" + if ours.kind == FAILED: + return "we-failed-they-resolved" if secure or insecure else "" + return "" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--csv", required=True, help="Corpus CSV (domain,category[,tags])") + parser.add_argument("-o", "--output", default="verdicts.csv", help="Output CSV path") + parser.add_argument("--types", default=DEFAULT_TYPES, help=f"Record types (default {DEFAULT_TYPES})") + parser.add_argument("--runs", type=int, default=1, help="Resolutions per name before escalation") + parser.add_argument("--escalate", type=int, default=8, help="Resolutions for anomalous names") + parser.add_argument("--sample", type=int, default=0, help="Random sample of N names (0 = all)") + parser.add_argument("--seed", type=int, default=20260830, help="Sampling seed") + parser.add_argument("--workers", type=int, default=16, help="Concurrent names") + parser.add_argument("--timeout", type=float, default=3.0, help="Per-query timeout") + parser.add_argument("--no-references", action="store_true", help="Skip the reference resolvers") + parser.add_argument("--references", default=",".join(REFERENCES), help="Reference resolver IPs") + parser.add_argument("--config", default="default", help=f"Resolver options from: {','.join(CONFIGS)}") + parser.add_argument("--append", action="store_true", help="Append to the output file instead of replacing it") + args = parser.parse_args() + + if args.config not in CONFIGS: + raise SystemExit(f"unknown config {args.config!r}; choose from {', '.join(CONFIGS)}") + options = CONFIGS[args.config] + reference_servers = [s.strip() for s in args.references.split(",") if s.strip()] + + types = [t.strip().upper() for t in args.types.split(",") if t.strip()] + with open(args.csv, newline="", encoding="utf-8") as handle: + corpus = [(r["domain"], r.get("category", ""), r.get("tags", "")) for r in csv.DictReader(handle)] + if args.sample and args.sample < len(corpus): + corpus = random.Random(args.seed).sample(corpus, args.sample) + + work_items = [(domain, category, tags, rdtype) for domain, category, tags in corpus for rdtype in types] + # Shuffled so consecutive lookups do not all land on the same nameservers. + random.Random(args.seed).shuffle(work_items) + rows: list[Row] = [] + lock = threading.Lock() + done = 0 + started = time.time() + + def resolve_n(domain: str, rdtype: str, times: int) -> tuple[list[Outcome], list[str]]: + outcomes: list[Outcome] = [] + violations: list[str] = [] + for _ in range(times): + resolver = RecursiveResolver(timeout=args.timeout, max_resolution_time=20.0, **options) + outcome, found = our_outcome(resolver, domain, rdtype) + outcomes.append(outcome) + violations.extend(found) + return outcomes, violations + + def work(item: tuple[str, str, str, str]) -> Row: + nonlocal done + domain, category, tags, rdtype = item + outcomes, violations = resolve_n(domain, rdtype, args.runs) + distinct = {o.key() for o in outcomes} + + references: dict[str, str] = {} + if not args.no_references: + asked = ascii_name(domain, rdtype) + references = dict( + reference_pool.map( + lambda server: (server, reference_verdict(asked, rdtype, server, args.timeout)), + reference_servers, + ) + ) + + disagreement = compare(outcomes[0], references) + # Escalate anything suspicious: a disagreement, a violation, or a + # verdict that is not simply "it resolved". + suspicious = bool(disagreement or violations or len(distinct) > 1 or outcomes[0].kind in (BOGUS, UNAVAILABLE)) + if suspicious and args.escalate > args.runs: + more, extra = resolve_n(domain, rdtype, args.escalate - args.runs) + outcomes.extend(more) + violations.extend(extra) + distinct = {o.key() for o in outcomes} + # Re-judge on the most severe outcome seen. + severity = [BOGUS, FAILED, UNAVAILABLE, SECURE, INSECURE, NODATA, NXDOMAIN] + worst = min(outcomes, key=lambda o: severity.index(o.kind) if o.kind in severity else 99) + disagreement = compare(worst, references) or disagreement + + with lock: + done += 1 + if done % 200 == 0: + print(f" {done}/{len(work_items)} in {time.time() - started:.0f}s", file=sys.stderr, flush=True) + + counted = collections.Counter(o.key() for o in outcomes) + # Different answers between runs mean the zone itself is unstable, so a + # verdict that changes with them is following the data, not wobbling. + answered = [o for o in outcomes if o.kind not in RETRIEVAL_KINDS] + unstable_data = len({o.fingerprint()[1:] for o in answered}) > 1 + return Row( + domain=domain, + rdtype=rdtype, + category=category, + tags=tags, + ours="+".join(f"{k}x{v}" for k, v in counted.most_common()), + detail=next((o.detail for o in outcomes if o.detail), ""), + runs=len(outcomes), + distinct=len(distinct), + flapped=len(distinct) > 1, + verdict_flap=len(distinct - RETRIEVAL_KINDS) > 1 and not unstable_data, + unstable_data=unstable_data, + references=references, + disagreement=disagreement, + violations=sorted(set(violations)), + ) + + # One shared pool for the reference queries: a per-lookup pool would churn + # five threads per name, which at this scale is both slow and the largest + # thing in memory. + with ( + ThreadPoolExecutor(max_workers=len(reference_servers) * 3) as reference_pool, + ThreadPoolExecutor(max_workers=args.workers) as pool, + ): + rows = list(pool.map(work, work_items)) + + with open(args.output, "a" if args.append else "w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter( + handle, + fieldnames=[ + "domain", + "rdtype", + "category", + "tags", + "ours", + "detail", + "runs", + "distinct", + "flapped", + "verdict_flap", + "unstable_data", + "references", + "disagreement", + "violations", + "config", + ], + ) + if not args.append or handle.tell() == 0: + writer.writeheader() + for row in rows: + record = asdict(row) + record["config"] = args.config + record["references"] = json.dumps(row.references) + record["violations"] = " | ".join(row.violations) + writer.writerow(record) + + verdict_flaps = [r for r in rows if r.verdict_flap] + availability_flaps = [r for r in rows if r.flapped and not r.verdict_flap] + verdict_disagreements = [r for r in rows if r.disagreement in VERDICT_DISAGREEMENTS] + availability_disagreements = [r for r in rows if r.disagreement and r.disagreement not in VERDICT_DISAGREEMENTS] + violated = [r for r in rows if r.violations] + + print(f"\n{len(rows)} lookups over {len(corpus)} names in {time.time() - started:.0f}s", file=sys.stderr) + print(" gating:", file=sys.stderr) + print(f" verdict flaps: {len(verdict_flaps)}", file=sys.stderr) + print(f" verdict disagreements: {len(verdict_disagreements)}", file=sys.stderr) + print(f" invariant violations: {len(violated)}", file=sys.stderr) + print(" informational (the internet, not us):", file=sys.stderr) + print(f" availability flaps: {len(availability_flaps)}", file=sys.stderr) + print(f" availability disagreements: {len(availability_disagreements)}", file=sys.stderr) + print(f" unstable zones: {sum(r.unstable_data for r in rows)}", file=sys.stderr) + + kinds: collections.Counter[str] = collections.Counter() + for row in rows: + kinds.update(part.rsplit("x", 1)[0] for part in row.ours.split("+")) + for kind, count in kinds.most_common(): + print(f" {kind:12s} {count:6d}", file=sys.stderr) + for row in verdict_flaps[:40]: + print(f" VERDICT FLAP {row.domain}/{row.rdtype}: {row.ours}", file=sys.stderr) + for row in verdict_disagreements + availability_disagreements: + print( + f" {'DISAGREE' if row.disagreement in VERDICT_DISAGREEMENTS else 'note'} " + f"{row.domain}/{row.rdtype}: {row.disagreement} ours={row.ours} refs={row.references}", + file=sys.stderr, + ) + for row in violated[:40]: + print(f" VIOLATION {row.domain}/{row.rdtype}: {row.violations}", file=sys.stderr) + return 1 if (verdict_flaps or verdict_disagreements or violated) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/recursive_resolver/__init__.py b/src/recursive_resolver/__init__.py index 9b525e9..cfc4990 100644 --- a/src/recursive_resolver/__init__.py +++ b/src/recursive_resolver/__init__.py @@ -8,6 +8,7 @@ CNAMELoopError, DNSSECError, DNSSECInsecureError, + DNSSECMaterialUnavailableError, DNSSECUnavailableError, DNSSECValidationError, InvalidNameError, @@ -22,7 +23,7 @@ ) from .resolver import Answer, RecursiveResolver, TraceStep -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ "RecursiveResolver", @@ -46,6 +47,7 @@ "DNSSECError", "DNSSECValidationError", "DNSSECInsecureError", + "DNSSECMaterialUnavailableError", "DNSSECUnavailableError", "__version__", ] diff --git a/src/recursive_resolver/addresses.py b/src/recursive_resolver/addresses.py index 4c42e73..dd1af54 100644 --- a/src/recursive_resolver/addresses.py +++ b/src/recursive_resolver/addresses.py @@ -10,14 +10,18 @@ special-purpose address registries: an address is refused unless it is globally routable and none of loopback, link-local, multicast, reserved or private. That is deliberately not a hand-maintained CIDR list: it stays correct as IANA -allocates, and it already covers CGNAT, the documentation prefixes, -``0.0.0.0/8``, ``240.0.0.0/4``, benchmarking space, Teredo, 6to4, NAT64 and the -IPv4-mapped range. - -A short built-in list then adds the handful of ranges that ``is_global`` -still considers routable but that no legitimate public nameserver occupies: -the one that matters most is Azure's ``168.63.129.16``, which is a real public-looking -address serving instance metadata. +allocates, and it covers CGNAT, the documentation prefixes, ``0.0.0.0/8``, +``240.0.0.0/4`` and benchmarking space. + +A short built-in list then adds the ranges that classification does not refuse, +or did not always refuse. Two kinds: addresses that look public but serve +instance metadata (Azure's ``168.63.129.16``), and the IPv6 transition prefixes +that carry an IPv4 address inside them. Those last matter because a resolver +running on a patched interpreter and one running on 3.10.14 do not agree about +them: CVE-2024-4032 corrected ``is_private``/``is_global`` for exactly this +family, so ``2002:a9fe:a9fe::1`` - link-local metadata wrapped in 6to4 - was +globally routable to an older stdlib. Listing them makes the control the same +on every supported version. The order of the checks below (specific categories before ``is_private``, since loopback and link-local are subsets of it in Python's model), the ``is_global`` @@ -48,6 +52,14 @@ "2001:20::/28", # Deprecated IPv6 site-local (RFC 3879); some networks still route it. "fec0::/10", + # IPv6 transition prefixes: each embeds an IPv4 address, so reaching one + # reaches whatever that address is. Not a nameserver location either way, + # and stdlib classification of them varies by patch level. + "2002::/16", # 6to4 (RFC 3056) + "2001::/32", # Teredo (RFC 4380) + "64:ff9b::/96", # NAT64 well-known prefix (RFC 6052) + "64:ff9b:1::/48", # NAT64 local-use prefix (RFC 8215) + "::/96", # deprecated IPv4-compatible IPv6 (RFC 4291 §2.5.5.1) ) # Named purely so a rejection can say *why*. Every one of these is already diff --git a/src/recursive_resolver/cache.py b/src/recursive_resolver/cache.py index 78c3999..69f49a9 100644 --- a/src/recursive_resolver/cache.py +++ b/src/recursive_resolver/cache.py @@ -26,6 +26,7 @@ import dns.name import dns.rdataclass +import dns.rdataset import dns.rdatatype import dns.rrset @@ -135,8 +136,9 @@ class DNSCache: max_size: Maximum number of entries. 0 means unlimited. min_ttl: Floor applied to record TTLs, in seconds. 0 honours the wire TTL exactly, which is what you want when freshness matters: - key rotation, GSLB failover. Negative entries are not floored; - see :meth:`_clamp`. + key rotation, GSLB failover. Negative entries and authenticated + data are never floored; see :meth:`_clamp`. Must not exceed + ``max_ttl``. max_ttl: Ceiling applied to record TTLs, in seconds. negative_ttl: Fallback TTL for negative entries when the authority section carries no usable SOA. @@ -156,6 +158,13 @@ def __init__( max_negative_ttl: int = 3600, max_delegation_depth: int | str | None = None, ) -> None: + # `_clamp` applies the floor last, so min_ttl > max_ttl silently wins + # and every unauthenticated record is cached for min_ttl seconds - + # a max_ttl set for freshness, quietly inverted. + if min_ttl > max_ttl: + raise ValueError(f"min_ttl ({min_ttl}) must not exceed max_ttl ({max_ttl})") + if min_ttl < 0 or negative_ttl < 0 or max_negative_ttl < 0: + raise ValueError("TTL bounds must not be negative") self.max_size = max_size self.min_ttl = min_ttl self.max_ttl = max_ttl @@ -180,7 +189,7 @@ def _rdtype(rdtype: int | str) -> int: return rdtype return int(dns.rdatatype.from_text(rdtype)) - def _clamp(self, ttl: int, negative: bool = False) -> int: + def _clamp(self, ttl: int, negative: bool = False, secure: bool = False) -> int: """Clamp a TTL to this cache's bounds. ``min_ttl`` is a floor on *record* TTLs only. Negative entries have @@ -189,9 +198,17 @@ def _clamp(self, ttl: int, negative: bool = False) -> int: must not also hold an NXDOMAIN past what the zone's SOA asked for, or a name that has just been created stays missing for longer than its operator intended. + + Authenticated answers and delegations have no floor either. The TTL + handed in for one has already been capped to what its signature allows + (RFC 4035 §5.3.3, which includes the time left before that signature + expires), and lifting it back up would serve the data as authenticated + past the point where anything vouches for it. """ if negative: return min(int(ttl), self.max_negative_ttl) + if secure: + return min(int(ttl), self.max_ttl) return max(self.min_ttl, min(int(ttl), self.max_ttl)) def _get(self, key: CacheKey) -> CacheEntry | None: @@ -232,7 +249,12 @@ def _isolate(rrset: Any) -> Any: if isinstance(rrset, dns.rrset.RRset): return rrset.copy() if isinstance(rrset, Delegation): - return replace(rrset, addresses=list(rrset.addresses), ns_names=list(rrset.ns_names)) + # `ds` too: it is a mutable rdataset, and it is the trust material a + # resumed resolution rebuilds its chain from. + ds = rrset.ds + if isinstance(ds, dns.rdataset.Rdataset): + ds = dns.rdataset.from_rdata_list(ds.ttl, list(ds)) + return replace(rrset, addresses=list(rrset.addresses), ns_names=list(rrset.ns_names), ds=ds) return rrset def get_answer( @@ -263,7 +285,9 @@ def put_answer( ) -> None: """Store a positive answer, taking a private copy of the RRset.""" key: CacheKey = (_ANSWER, self._name(qname), self._rdtype(rdtype), rdclass) - entry = CacheEntry(rrset=self._isolate(rrset), expiry=time.monotonic() + self._clamp(ttl), secure=secure) + entry = CacheEntry( + rrset=self._isolate(rrset), expiry=time.monotonic() + self._clamp(ttl, secure=secure), secure=secure + ) with self._lock: self._put(key, entry) @@ -273,21 +297,27 @@ def get_nxdomain(self, qname: dns.name.Name | str) -> CacheEntry | None: """Look up a cached NXDOMAIN for this exact name.""" key: CacheKey = (_NXDOMAIN, self._name(qname), 0, 0) with self._lock: - return self._get(key) + entry = self._get(key) + return None if entry is None else replace(entry) - def get_nxdomain_ancestor(self, qname: dns.name.Name | str) -> dns.name.Name | None: - """Return a cached NXDOMAIN ancestor of ``qname``, if any. + def get_nxdomain_ancestor(self, qname: dns.name.Name | str) -> CacheEntry | None: + """Return the cached NXDOMAIN entry for an ancestor of ``qname``, if any. Implements RFC 8020 / ``harden-below-nxdomain``: if ``foo.example.com`` does not exist, nothing below it can exist either. This is the primary defence against random-subdomain (water torture) floods. + + The entry rather than the name, so the caller can see whether that + denial was proven: a name below an authenticated "does not exist" is + itself authenticated as absent, and one below an unproven denial is not. """ name = self._name(qname) with self._lock: current = name while True: - if self._get((_NXDOMAIN, current, 0, 0)) is not None: - return current + entry = self._get((_NXDOMAIN, current, 0, 0)) + if entry is not None: + return replace(entry) if current == dns.name.root: return None try: @@ -295,12 +325,18 @@ def get_nxdomain_ancestor(self, qname: dns.name.Name | str) -> dns.name.Name | N except dns.name.NoParent: # pragma: no cover - guarded by the root check return None - def put_nxdomain(self, qname: dns.name.Name | str, ttl: int | None = None) -> None: - """Store an NXDOMAIN, keyed by name only (RFC 2308 §5).""" + def put_nxdomain(self, qname: dns.name.Name | str, ttl: int | None = None, secure: bool = False) -> None: + """Store an NXDOMAIN, keyed by name only (RFC 2308 §5). + + ``secure`` records whether the denial was proven, so a caller reading + it back can hold it to the same standard as the answer that produced + it. Without that, a strict-mode resolver refuses an unproven denial + once and then serves it from cache ever after. + """ key: CacheKey = (_NXDOMAIN, self._name(qname), 0, 0) effective = self.negative_ttl if ttl is None else ttl expiry = time.monotonic() + self._clamp(effective, negative=True) - entry = CacheEntry(rrset=None, expiry=expiry, is_negative=True) + entry = CacheEntry(rrset=None, expiry=expiry, is_negative=True, secure=secure) with self._lock: self._put(key, entry) @@ -310,7 +346,8 @@ def get_nodata( """Look up a cached NODATA for this name and type.""" key: CacheKey = (_NODATA, self._name(qname), self._rdtype(rdtype), rdclass) with self._lock: - return self._get(key) + entry = self._get(key) + return None if entry is None else replace(entry) def put_nodata( self, @@ -318,12 +355,13 @@ def put_nodata( rdtype: int | str, ttl: int | None = None, rdclass: int = dns.rdataclass.IN, + secure: bool = False, ) -> None: - """Store a NODATA for this name and type.""" + """Store a NODATA for this name and type. ``secure``: see put_nxdomain.""" key: CacheKey = (_NODATA, self._name(qname), self._rdtype(rdtype), rdclass) effective = self.negative_ttl if ttl is None else ttl expiry = time.monotonic() + self._clamp(effective, negative=True) - entry = CacheEntry(rrset=None, expiry=expiry, is_negative=True) + entry = CacheEntry(rrset=None, expiry=expiry, is_negative=True, secure=secure) with self._lock: self._put(key, entry) @@ -335,7 +373,13 @@ def put_delegation(self, delegation: Delegation, ttl: int) -> None: if self.max_delegation_depth is not None and depth > self.max_delegation_depth: return key: CacheKey = (_DELEGATION, delegation.zone, 0, 0) - entry = CacheEntry(rrset=self._isolate(delegation), expiry=time.monotonic() + self._clamp(ttl)) + # A validated delegation carries the DS that was signed, so it is held + # to the same rule as an authenticated answer: no floor (see _clamp). + entry = CacheEntry( + rrset=self._isolate(delegation), + expiry=time.monotonic() + self._clamp(ttl, secure=delegation.secure), + secure=delegation.secure, + ) with self._lock: self._put(key, entry) diff --git a/src/recursive_resolver/cli.py b/src/recursive_resolver/cli.py index d423fad..cac214b 100644 --- a/src/recursive_resolver/cli.py +++ b/src/recursive_resolver/cli.py @@ -22,6 +22,18 @@ } +def _json_dnssec(answer: Answer | None, args: argparse.Namespace) -> str | None: + """The DNSSEC state for a JSON payload. + + ``--no-dnssec`` leaves the default INSECURE on the Answer, which in JSON + reads as "we looked and the zone is unsigned". Nothing was looked at, so + say so. + """ + if args.no_dnssec: + return "disabled" + return answer.dnssec.value if answer is not None else None + + def _note_dnssec(answer: Answer, args: argparse.Namespace) -> None: """Report the DNSSEC verdict on stderr, so plain output is not silently unvalidated. @@ -181,7 +193,7 @@ def _run_query(resolver: RecursiveResolver, args: argparse.Namespace) -> int: "canonical_name": str(answer.canonical_name), "records": values, "ttl": answer.ttl, - "dnssec": answer.dnssec.value, + "dnssec": _json_dnssec(answer, args), }, indent=2, ) @@ -221,7 +233,7 @@ def _run_trace(resolver: RecursiveResolver, args: argparse.Namespace) -> int: for step in trace ], "records": values, - "dnssec": answer.dnssec.value if answer else None, + "dnssec": _json_dnssec(answer, args), } if failure is not None: # Otherwise the payload is success-shaped with two nulls, and the diff --git a/src/recursive_resolver/dnssec.py b/src/recursive_resolver/dnssec.py index caca4d7..17c5555 100644 --- a/src/recursive_resolver/dnssec.py +++ b/src/recursive_resolver/dnssec.py @@ -22,6 +22,7 @@ import base64 import logging +import time from dataclasses import dataclass, field from enum import Enum from typing import Any @@ -40,13 +41,26 @@ logger = logging.getLogger(__name__) -# DNSKEY flag bits (RFC 4034 §2.1.1). +# DNSKEY flag bits (RFC 4034 §2.1.1, RFC 5011 §7). FLAG_ZONE_KEY = 0x0100 -FLAG_SECURE_ENTRY_POINT = 0x0001 +FLAG_REVOKE = 0x0080 # NSEC3 flags (RFC 5155 §3.1.2). NSEC3_OPT_OUT = 0x01 +# Slack allowed on a signature's inception, in seconds. A zone that re-signs +# continuously publishes records whose inception is a moment in the future as +# far as a validator with a slightly slow clock is concerned, and without any +# tolerance those records are intermittently BOGUS. Other implementations allow +# between a minute and several; this is the conservative end of that range. +CLOCK_SKEW = 60 + + +def _serial_le(first: int, second: int) -> bool: + """RFC 1982 serial comparison: is ``first`` at or before ``second``?""" + return ((second - first) & 0xFFFFFFFF) < 0x80000000 + + # Reject absurd NSEC3 iteration counts outright rather than burning CPU on # them. RFC 9276 requires zones to publish 0; .com/.net/.org already do, and # common ccTLDs stay in single digits, so 100 rejects only abuse. @@ -123,10 +137,21 @@ def _rrset_of(rrsets: list[dns.rrset.RRset], name: dns.name.Name, rdtype: int) - def _trim_keyring(keys: dict[dns.name.Name, Any]) -> dict[dns.name.Name, Any]: - """Keep at most MAX_KEYS_PER_TAG keys per (key tag, algorithm). + """Keep at most MAX_KEYS_PER_TAG usable keys per (key tag, algorithm). Colliding key tags are the core of KeyTrap: without this a single answer can force one signature check per published key. + + Keys with the REVOKE bit are dropped here rather than filtered at each call + site, because this is the one place every signature check passes through. A + revoked key is one whose owner has publicly withdrawn it, typically because + it was compromised, and RFC 5011 §2.1 says it must not be used for any + purpose afterwards. Its private key still produces signatures that verify, + so a validator that ignores the bit keeps trusting exactly the key an + attacker holds - which is the situation revocation exists to end. This + resolver does not track trust anchors automatically, so it has no need of + the one exception the RFC allows (validating the revocation itself) and + simply never uses such a key. """ trimmed: dict[dns.name.Name, Any] = {} for name, keyset in keys.items(): @@ -137,7 +162,12 @@ def _trim_keyring(keys: dict[dns.name.Name, Any]) -> dict[dns.name.Name, Any]: for key in keyset: try: tag = dns.dnssec.key_id(key) - except Exception: # pragma: no cover - malformed key + revoked = bool(key.flags & FLAG_REVOKE) + except Exception: + # Not a usable DNSKEY at all; nothing here can be trusted. + continue + if revoked: + logger.debug("Ignoring revoked DNSKEY for %s", name) continue slot = (tag, int(key.algorithm)) if seen.get(slot, 0) >= MAX_KEYS_PER_TAG: @@ -195,10 +225,12 @@ def __init__( self, trust_anchors: tuple[str, ...] = ROOT_TRUST_ANCHORS, max_nsec3_iterations: int = MAX_NSEC3_ITERATIONS, + clock_skew: int = CLOCK_SKEW, ) -> None: if not trust_anchors: raise ValueError("trust_anchors must not be empty; omit it to use the IANA root anchors") self.max_nsec3_iterations = max_nsec3_iterations + self.clock_skew = clock_skew self._root_ds = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) for anchor in trust_anchors: self._root_ds.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, anchor), ttl=86400) @@ -213,27 +245,71 @@ def validate_rrset( now: float | None = None, budget: Any = None, ) -> bool: - """Verify ``rrset`` against its RRSIG using ``keys``. + """Verify ``rrset`` against its RRSIG using ``keys``.""" + return self.validated_rrsig(rrset, rrsig_rrset, keys, now=now, budget=budget) is not None + + def validated_rrsig( + self, + rrset: dns.rrset.RRset, + rrsig_rrset: dns.rrset.RRset | None, + keys: dict[dns.name.Name, Any], + now: float | None = None, + budget: Any = None, + ) -> Any | None: + """The RRSIG rdata that authenticates ``rrset``, or None. Signatures are tried one at a time, against a tag-trimmed keyring, and each attempt is charged to the budget. Letting dnspython loop over the full cross-product internally would leave the KeyTrap work unbounded. + + The winning signature is returned rather than a bare yes, because how + long the data may be cached depends on *which* signature vouched for it + (RFC 4035 §5.3.3). """ if rrsig_rrset is None: - return False + return None trimmed_keys = _trim_keyring(keys) if not trimmed_keys: - return False + return None + wall = time.time() if now is None else now for rrsig in _trim_rrsigs(rrsig_rrset): + # RFC 4034 §3.1.5 defines inception and expiration as serial + # numbers, so they are compared per RFC 1982 rather than as plain + # integers, and the inception is allowed a little slack for a + # signer whose clock runs ahead of ours. + # + # A window that straddles the 2106 serial wrap is still refused: + # dnspython's own plain comparison below cannot be talked into + # accepting one, and there is no such signature in the world yet. + stamp = int(wall) & 0xFFFFFFFF + if not _serial_le(int(rrsig.inception), (stamp + self.clock_skew) & 0xFFFFFFFF): + logger.debug("RRSIG for %s is not yet valid, beyond the skew allowance", rrset.name) + continue + if not _serial_le(stamp, int(rrsig.expiration)): + logger.debug("RRSIG for %s has expired", rrset.name) + continue + # RFC 4035 §5.3.1: "The RRSIG RR's Signer's Name field MUST be the + # name of the zone that contains the RRset." Nothing below checks + # this - dnspython compares the labels count and looks the signer + # up in the keyring, but never relates the signer to the owner - so + # without it any zone can sign for any name. Owning one signed zone + # would be enough to authenticate forged data for every domain: + # sign the victim's RRset with your own key, name your own zone as + # the signer, and a validator that chases the signer to fetch keys + # fetches yours and verifies against them. + if not rrset.name.is_subdomain(rrsig.signer): + logger.debug("RRSIG for %s claims signer %s, which does not contain it", rrset.name, rrsig.signer) + continue if budget is not None: budget.spend_signature_validation() + inside = max(int(rrsig.inception), min(stamp, int(rrsig.expiration))) try: - dns.dnssec.validate_rrsig(rrset, rrsig, trimmed_keys, now=now) + dns.dnssec.validate_rrsig(rrset, rrsig, trimmed_keys, now=inside) except Exception as exc: logger.debug("RRSIG (tag %s) failed for %s: %s", rrsig.key_tag, rrset.name, exc) continue - return True - return False + return rrsig + return None def validate_dnskey( self, @@ -245,8 +321,11 @@ def validate_dnskey( ) -> ValidationState: """Verify a zone's DNSKEY RRset against the DS records held by its parent. - At least one DS must match a Secure Entry Point key, and that key must - have signed the DNSKEY RRset itself (RFC 4035 §5.2). + At least one DS must match a key with the ZONE flag set, and that key + must have signed the DNSKEY RRset itself (RFC 4035 §5.2). The Secure + Entry Point bit is deliberately not required: it is advisory, every + reference implementation ignores it for this purpose, and a zone whose + KSK does not set it is unusual rather than wrong. Returns: SECURE when that holds. INSECURE when the DS RRset names only @@ -291,9 +370,21 @@ def validate_dnskey( logger.debug("DS for %s names only unsupported algorithms; treating the zone as unsigned", zone) return ValidationState.INSECURE - # The DNSKEY RRset must be self-signed by one of the DS-matched keys. - keyring = {zone: dnskey_rrset} - if self.validate_rrset(dnskey_rrset, rrsig_rrset, keyring, budget=budget): + # The DNSKEY RRset must be signed by a key the DS matched, and only by + # such a key. RFC 4035 §5.2 requires that "the corresponding private + # key has signed the child zone's apex DNSKEY RRset"; verifying against + # the whole published set instead lets any key in it stand in for the + # one the parent blessed. That would make the DS worth nothing: + # whoever compromises a zone-signing key - shorter-lived, rotated more + # often, and never the key the registry protects - could publish a + # DNSKEY RRset containing a key of their own, sign it with the key they + # hold, and have the whole zone validate, while the real KSK sat + # untouched in the same RRset satisfying the DS match. + entry_points = dns.rrset.RRset(zone, dns.rdataclass.IN, dns.rdatatype.DNSKEY) + for key in matched: + entry_points.add(key) + entry_points.ttl = dnskey_rrset.ttl + if self.validate_rrset(dnskey_rrset, rrsig_rrset, {zone: entry_points}, budget=budget): return ValidationState.SECURE return ValidationState.BOGUS @@ -334,6 +425,8 @@ def validate_ds( # No DS: the parent must prove its absence, or the delegation is bogus. if self.prove_no_ds(child, authority, parent_keys, budget=budget): return ValidationState.INSECURE, None + if self.nsec3_beyond_our_limits(authority, parent_keys, budget): + return ValidationState.INSECURE, None return ValidationState.BOGUS, None # ── denial of existence ───────────────────────────────────────────── @@ -341,16 +434,85 @@ def validate_ds( def _validated_nsec_rrsets( self, authority: list[dns.rrset.RRset], keys: dict[dns.name.Name, Any], rdtype: int, budget: Any = None ) -> list[dns.rrset.RRset]: - """Return the NSEC (or NSEC3) RRsets in ``authority`` whose RRSIGs validate.""" + """Return the NSEC (or NSEC3) RRsets in ``authority`` whose RRSIGs validate. + + NSEC3 records with anything but Opt-Out in the Flags field are dropped + unread (RFC 5155 §8.2). The reserved bits have no defined meaning, so a + record that sets one is asserting something this validator cannot + interpret, and guessing at it is how a future flag gets silently + ignored rather than noticed. + """ out: list[dns.rrset.RRset] = [] for rrset in authority: if rrset.rdtype != rdtype or rrset.rdclass != dns.rdataclass.IN: continue + if rdtype == dns.rdatatype.NSEC3 and rrset[0].flags & ~NSEC3_OPT_OUT: + logger.debug("Ignoring NSEC3 at %s with reserved flags %#x", rrset.name, rrset[0].flags) + continue rrsig = find_rrsig(authority, rrset.name, rdtype) if self.validate_rrset(rrset, rrsig, keys, budget=budget): out.append(rrset) return out + @staticmethod + def _signing_zone(keys: dict[dns.name.Name, Any]) -> dns.name.Name | None: + """The zone these proofs are being validated against. + + Read from the keyring, never from the RRSIG on the wire. A signature + verifies only against the key its signer field names, and every keyring + here holds exactly one zone, so this *is* the signer of anything that + validated. Reading the wire field instead is exploitable: an RRSIG + RRset can carry several rdata, only one of which verified, and an + attacker who prepends a second one naming a different zone changes + what a naive `rrsig[0].signer` reports while the record still + validates on the genuine signature behind it. + """ + return next(iter(keys), None) + + def _parent_side( + self, + nsec: dns.rrset.RRset, + owner: dns.name.Name, + zone: dns.name.Name | None, + ) -> bool: + """Is this an "ancestor delegation" record (RFC 6840 §4.1)? + + NS set, SOA clear, and signed by a zone shorter than the name it is + about: that is a record served from the *parent* side of a zone cut. + The parent holds only the child's NS and DS, so its type bitmap lists + only those - and reading that bitmap as "the child has no A record" + denies data the parent was never authoritative for. Anyone able to get + a parent-side record into an answer could otherwise deny any name in + the child zone, which is why RFC 6840 §4.1 forbids using one to prove + the nonexistence of anything at or below the cut except a DS. + + ``owner`` is passed separately because for NSEC3 the record's own name + is a hash; the name it is about is the one that hashed to it. + """ + types = _types_in_bitmap(nsec[0]) + if dns.rdatatype.NS not in types or dns.rdatatype.SOA in types: + return False + return zone is not None and len(zone) < len(owner) + + def _may_deny_below( + self, + nsec: dns.rrset.RRset, + target: dns.name.Name, + zone: dns.name.Name | None, + ) -> bool: + """May this NSEC be used to deny ``target``, which lies below its owner? + + Two records must not be (RFC 6840 §4.1): one from the parent side of a + zone cut, which says nothing about the zone below it, and one with the + DNAME bit, which rewrites every name below its owner and so describes + none of them. + """ + if target == nsec.name or not target.is_subdomain(nsec.name): + return True + if dns.rdatatype.DNAME in _types_in_bitmap(nsec[0]): + return False + return not self._parent_side(nsec, nsec.name, zone) + @staticmethod def _nsec_covers(owner: dns.name.Name, next_name: dns.name.Name, target: dns.name.Name) -> bool: """True if ``target`` falls strictly between ``owner`` and ``next_name``.""" @@ -362,6 +524,27 @@ def _nsec_covers(owner: dns.name.Name, next_name: dns.name.Name, target: dns.nam # The last NSEC in the zone wraps around to the apex. return bool(target > owner or target < next_name) + def nsec3_beyond_our_limits( + self, + authority: list[dns.rrset.RRset], + keys: dict[dns.name.Name, Any], + budget: Any = None, + ) -> bool: + """True when this response's NSEC3 parameters are more than we will compute. + + RFC 9276 §3.2 leaves the choice open - "Validating resolvers MAY return + an insecure response ... MAY also return a SERVFAIL" - and the major + implementations all take the first option. Refusing outright + makes a validly signed zone with a legacy iteration count unresolvable + here while everyone else still answers for it, so the caller returns + the data unauthenticated instead of calling the zone forged. + + The signature is verified first regardless, as RFC 5155 §10.3 requires, + so the iteration count itself cannot have been tampered with. + """ + nsec3s = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC3, budget) + return any(n[0].iterations > self.max_nsec3_iterations for n in nsec3s) + def prove_no_ds( self, child: dns.name.Name, @@ -370,11 +553,15 @@ def prove_no_ds( budget: Any = None, ) -> bool: """Prove that no DS record exists for ``child`` (an insecure delegation).""" - # NSEC: a record matching the child whose bitmap has NS but not DS. + # NSEC: a record matching the child whose bitmap has NS but not DS. A + # record bearing SOA is the child's own apex record and cannot speak for + # the parent's DS (RFC 4035 §5.2); the same guard as in `prove_nodata`. for nsec in self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC, budget): if nsec.name != child: continue types = _types_in_bitmap(nsec[0]) + if dns.rdatatype.SOA in types and child != dns.name.root: + continue if dns.rdatatype.DS not in types and dns.rdatatype.NS in types: return True @@ -394,12 +581,76 @@ def prove_no_ds( if dns.rdatatype.DS not in types and dns.rdatatype.NS in types: return True - # Opt-out: an NSEC3 covering the child's hash, with the opt-out flag. + # Opt-out: no NSEC3 matches the delegation, so the proof is the closest + # provable encloser plus an opt-out NSEC3 covering the next closer name + # (RFC 5155 §8.6). Usually the encloser is the child's own parent and + # the next closer is the child itself, but where a whole subtree sits + # inside an opt-out span the encloser is further up: a delegation two + # or more labels below an unsigned cut is denied by the cover for its + # topmost missing ancestor, not for the delegation name. + if not nsec3s: + return False + params = nsec3s[0][0] + closest = self._closest_encloser_nsec3(child, nsec3s, params, budget) + if closest is None: + return False + next_closer = self._next_closer(child, closest) + if next_closer is None: + return False for nsec3 in nsec3s: - params = nsec3[0] - if not params.flags & NSEC3_OPT_OUT: + if nsec3[0].flags & NSEC3_OPT_OUT and self._nsec3_covers(next_closer, nsec3, params, budget): + return True + return False + + def prove_no_delegation( + self, + name: dns.name.Name, + authority: list[dns.rrset.RRset], + keys: dict[dns.name.Name, Any], + budget: Any = None, + ) -> bool: + """Prove that ``name`` is not a zone cut, so it can hold no DS. + + Walking a chain of trust asks for a DS at every label between the zone + in force and the target, and most of those labels are not delegation + points. The parent answers such a query with a denial that no ``no DS`` + proof matches - there is no delegation to deny - and the caller needs + to tell that apart from a chain that is actually broken. + + Two shapes say it. A name with an NSEC or NSEC3 of its own whose bitmap + carries neither NS nor DS exists but is not delegated: an empty + non-terminal in an NSEC3 zone, or a name holding only, say, TXT. A name + covered by an NSEC owns no NSEC at all, which in an NSEC zone is what + an empty non-terminal looks like (RFC 4035 §3.1.3.4.1). + + Both are signed statements from the parent, so neither can be forged + into existence by an off-path attacker, and neither is used to conclude + anything about the target: the walk simply carries on to the next + label with the same zone and the same keys. + """ + signer = self._signing_zone(keys) + for nsec in self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC, budget): + if nsec.name == name: + types = _types_in_bitmap(nsec[0]) + if dns.rdatatype.NS not in types and dns.rdatatype.DS not in types: + return True + continue + if not self._may_deny_below(nsec, name, signer): continue - if self._nsec3_covers(child, nsec3, params, budget): + if self._nsec_covers(nsec.name, nsec[0].next, name): + return True + + nsec3s = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC3, budget) + if not nsec3s: + return False + params = nsec3s[0][0] + if params.iterations > self.max_nsec3_iterations: + return False + for nsec3 in nsec3s: + if self._nsec3_owner(name, nsec3.name, params, budget) != nsec3.name: + continue + types = _types_in_bitmap(nsec3[0]) + if dns.rdatatype.NS not in types and dns.rdatatype.DS not in types: return True return False @@ -449,37 +700,63 @@ def prove_nxdomain( authority: list[dns.rrset.RRset], keys: dict[dns.name.Name, Any], budget: Any = None, - ) -> bool: - """Prove that ``qname`` does not exist at all.""" + ) -> ValidationState: + """Prove that ``qname`` does not exist at all. + + Returns: + SECURE when the name error is proven, BOGUS when nothing proves it, + and INSECURE when either cover an RFC 5155 §8.4 proof needs - the + next closer name's or the wildcard's - is **opt-out** only. Opt-out asserts that a range contains no signed + delegations, not that it contains no names (RFC 5155 §6), so a name + inside it may exist as an unsigned delegation and the name error is + not proven. This is not a hypothetical: in an opt-out TLD every + unsigned domain sits in such a range, and the records needed are + public and correctly signed, so treating the result as authenticated + would let anyone forge an authenticated "does not exist" for them. + The public DNS resolvers all clear AD on these answers. + """ + signer = self._signing_zone(keys) nsecs = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC, budget) if nsecs: - name_denied = any(self._nsec_covers(n.name, n[0].next, qname) for n in nsecs) - if not name_denied: - return False + # A record from the parent side of a cut, or one carrying a DNAME, + # describes nothing below its owner (RFC 6840 §4.1). + deniers = [n for n in nsecs if self._may_deny_below(n, qname, signer)] + if not any(self._nsec_covers(n.name, n[0].next, qname) for n in deniers): + return ValidationState.BOGUS # A wildcard could still synthesise the name, so its absence must - # also be proven (RFC 4035 §5.4). - return self._wildcard_denied_nsec(qname, nsecs) + # also be proven (RFC 4035 §5.4). NSEC has no opt-out, so a proof + # here is a whole one. + if self._wildcard_denied_nsec(qname, deniers): + return ValidationState.SECURE + return ValidationState.BOGUS nsec3s = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC3, budget) if not nsec3s: - return False + return ValidationState.BOGUS params = nsec3s[0][0] if params.iterations > self.max_nsec3_iterations: - return False + return ValidationState.BOGUS closest = self._closest_encloser_nsec3(qname, nsec3s, params, budget) if closest is None: - return False + return ValidationState.BOGUS # The next closer name must be covered, and so must the wildcard at - # the closest encloser. + # the closest encloser (RFC 5155 §8.4). next_closer = self._next_closer(qname, closest) if next_closer is None: - return False - if not any(self._nsec3_covers(next_closer, n, params, budget) for n in nsec3s): - return False - # A wildcard at the closest encloser would have synthesised the name, - # so its non-existence must be covered too (RFC 5155 §8.4). + return ValidationState.BOGUS + covering = [n for n in nsec3s if self._nsec3_covers(next_closer, n, params, budget)] + if not covering: + return ValidationState.BOGUS wildcard = dns.name.Name((b"*",) + closest.labels) - return any(self._nsec3_covers(wildcard, n, params, budget) for n in nsec3s) + wildcard_covering = [n for n in nsec3s if self._nsec3_covers(wildcard, n, params, budget)] + if not wildcard_covering: + return ValidationState.BOGUS + # Both covers have to be real ones. Either resting on opt-out leaves a + # name the proof needs absent free to exist as an unsigned delegation. + for covers in (covering, wildcard_covering): + if not any(not n[0].flags & NSEC3_OPT_OUT for n in covers): + return ValidationState.INSECURE + return ValidationState.SECURE def prove_wildcard( self, @@ -488,7 +765,7 @@ def prove_wildcard( authority: list[dns.rrset.RRset], keys: dict[dns.name.Name, Any], budget: Any = None, - ) -> bool: + ) -> ValidationState: """Prove a wildcard-expanded answer was legitimate (RFC 4035 §5.3.4). An RRSIG whose ``labels`` count is smaller than the owner name's label @@ -504,59 +781,147 @@ def prove_wildcard( it existed, the server was obliged to answer from the exact name, so a wildcard answer for a name that does exist is a substitution. + Returns: + SECURE when the expansion is proven; BOGUS when nothing denies the + queried name; INSECURE when the only denial is an opt-out NSEC3. + An opt-out record does not assert that the names in its range do + not exist - only that none of them is a *signed* delegation (RFC + 5155 §6) - so it cannot rule out a closer match, and the expansion + cannot be authenticated. The public DNS resolvers return such + answers unauthenticated rather than refusing them. + Args: qname: The name that was queried. labels: The RRSIG ``labels`` field, which fixes the closest encloser and therefore which wildcard was used. """ + # The Labels count fixes which wildcard signed this data, and therefore + # which encloser it belongs to. + extra = len(qname.labels) - 1 - labels + if extra < 1: + return ValidationState.BOGUS + closest = dns.name.Name(qname.labels[extra:]) + # NSEC: some record must cover qname itself, i.e. assert that nothing - # exists between its two neighbours where qname would sort. + # exists between its two neighbours where qname would sort. NSEC has no + # opt-out, so a cover here is a real proof. + # + # It must also agree with the Labels count about where the closest + # encloser is. A zone holding nested wildcards - `*.example` and + # `*.b.example` - signs each with a different Labels value, so without + # this the higher wildcard's genuine record and RRSIG can be replayed + # as the answer for a name the lower one really covers, and the + # substitution comes back authenticated. + signer = self._signing_zone(keys) nsecs = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC, budget) - if any(self._nsec_covers(n.name, n[0].next, qname) for n in nsecs): - return True + for nsec in nsecs: + if not self._may_deny_below(nsec, qname, signer): + continue + if not self._nsec_covers(nsec.name, nsec[0].next, qname): + continue + if self._closest_encloser_nsec(qname, nsec) == closest: + return ValidationState.SECURE - # NSEC3: the closest encloser follows from the labels count, and the - # name one label below it (the "next closer") must be covered. + # NSEC3: the name one label below the closest encloser (the "next + # closer") must be covered. nsec3s = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC3, budget) if not nsec3s: - return False - extra = len(qname.labels) - 1 - labels - if extra < 1: - return False - closest = dns.name.Name(qname.labels[extra:]) + return ValidationState.BOGUS next_closer = self._next_closer(qname, closest) if next_closer is None: # pragma: no cover - extra >= 1 guarantees a next closer - return False + return ValidationState.BOGUS params = nsec3s[0][0] if params.iterations > self.max_nsec3_iterations: - return False - return any(self._nsec3_covers(next_closer, n, params, budget) for n in nsec3s) + return ValidationState.BOGUS - def _wildcard_denied_nsec(self, qname: dns.name.Name, nsecs: list[dns.rrset.RRset]) -> bool: - """Check that no wildcard could have synthesised ``qname``. + covering = [n for n in nsec3s if self._nsec3_covers(next_closer, n, params, budget)] + if not covering: + return ValidationState.BOGUS + if any(not n[0].flags & NSEC3_OPT_OUT for n in covering): + return ValidationState.SECURE + return ValidationState.INSECURE + + @staticmethod + def _common_ancestor(first: dns.name.Name, second: dns.name.Name) -> dns.name.Name: + """The longest name that is a suffix of both, compared case-insensitively.""" + depth = 0 + for left, right in zip(reversed(first.labels), reversed(second.labels), strict=False): + if left.lower() != right.lower(): + break + depth += 1 + return dns.name.Name(first.labels[len(first.labels) - depth :]) + + def _closest_encloser_nsec(self, qname: dns.name.Name, nsec: dns.rrset.RRset) -> dns.name.Name: + """The closest encloser of ``qname``, per an NSEC that covers it. + + The record's owner and its next name both exist, and ``qname`` sorts + between them, so the deepest ancestor of ``qname`` that can exist is + the deeper of the two names it shares a suffix with. Only the wildcard + at *that* name could have synthesised ``qname``; a wildcard higher up + says nothing about it, because the closer encloser would have matched + first (RFC 4592 §3.3.1). + """ + return max( + (self._common_ancestor(qname, nsec.name), self._common_ancestor(qname, nsec[0].next)), + key=lambda name: len(name.labels), + ) - Known limitation: rather than deriving the closest encloser, this - accepts a covering NSEC for a wildcard at any ancestor of ``qname``, - which is weaker than RFC 4035 §5.4 strictly requires. Exploiting the - difference needs valid RRSIGs from the zone's own key: i.e. control of - the zone, so it is a completeness gap, not a third-party attack. + def _wildcard_denied_nsec(self, qname: dns.name.Name, nsecs: list[dns.rrset.RRset]) -> bool: + """Check that no wildcard could have synthesised ``qname`` (RFC 4035 §5.4). + + Exactly one wildcard could have: the one at the closest encloser, the + deepest ancestor of ``qname`` that exists. Accepting a denial of the + wildcard at *any* ancestor instead is not a mere completeness gap - it + is forgeable from public data. Where a zone holds `*.example.` and is + asked for `a.b.example.`, the server must answer from the wildcard, but + two genuine, signed, publicly fetchable NSEC records - one covering + `a.b.example.`, one covering the non-existent `*.b.example.` - would + together read as a proven NXDOMAIN and suppress the wildcard's data. + Often the same record does both. + + The closest encloser follows from the covering NSEC itself. Its owner + and its next name both exist, and ``qname`` sorts between them, so the + deepest ancestor of ``qname`` that can exist is the deeper of the two + names it shares a suffix with. """ - for i in range(1, len(qname.labels)): - wildcard = dns.name.Name((b"*",) + qname.labels[i:]) - for nsec in nsecs: - if self._nsec_covers(nsec.name, nsec[0].next, wildcard): - return True + for nsec in nsecs: + if not self._nsec_covers(nsec.name, nsec[0].next, qname): + continue + wildcard = dns.name.Name((b"*",) + self._closest_encloser_nsec(qname, nsec).labels) + if any(self._nsec_covers(n.name, n[0].next, wildcard) for n in nsecs): + return True return False def _closest_encloser_nsec3( self, qname: dns.name.Name, nsec3s: list[dns.rrset.RRset], params: Any, budget: Any = None ) -> dns.name.Name | None: - """Find the deepest ancestor of ``qname`` with a matching NSEC3 record.""" + """Find the deepest ancestor of ``qname`` with a matching NSEC3 record. + + RFC 5155 §8.3 requires more than a match: "the validator MUST check + that the NSEC3 RR that has the closest encloser as the original owner + name is from the proper zone. The DNAME type bit must not be set and + the NS type bit may only be set if the SOA type bit is set." + + A record failing that came from the parent side of a cut, or from a + name whose subtree is rewritten by a DNAME, and describes nothing + inside the zone below it. Accepting one lets a parent's public, + correctly signed delegation record stand as the closest encloser for a + name in the signed child, and every proof built on it then denies data + the child really serves. The deepest match is the closest encloser or + there is none - falling back to a shallower ancestor would build a + proof about a different name. + """ current = qname while True: for nsec3 in nsec3s: - if self._nsec3_owner(current, nsec3.name, params, budget) == nsec3.name: - return current + if self._nsec3_owner(current, nsec3.name, params, budget) != nsec3.name: + continue + types = _types_in_bitmap(nsec3[0]) + if dns.rdatatype.DNAME in types: + return None + if dns.rdatatype.NS in types and dns.rdatatype.SOA not in types: + return None + return current if current == dns.name.root: return None try: @@ -579,35 +944,131 @@ def prove_nodata( authority: list[dns.rrset.RRset], keys: dict[dns.name.Name, Any], budget: Any = None, - ) -> bool: - """Prove that ``qname`` exists but has no records of type ``rdtype``.""" - for nsec in self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC, budget): - if nsec.name != qname: + ) -> ValidationState: + """Prove that ``qname`` exists but has no records of type ``rdtype``. + + Returns: + SECURE when the denial is proven, BOGUS when nothing proves it, and + INSECURE where the proof rests on an **opt-out** NSEC3. Opt-out says + a range holds no signed delegations, not that it holds no names, so + a name inside one may exist and the denial is returned without being + authenticated: the DS of an unsigned delegation (RFC 5155 §8.6), and + a wildcard NODATA whose next closer name only an opt-out record + covers. + """ + signer = self._signing_zone(keys) + nsecs = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC, budget) + for nsec in nsecs: + if nsec.name == qname: + # A parent-side record lists the delegation's types, not the + # child's, so it cannot deny a type in the child (RFC 6840 + # §4.1) - "all RRs at that (original) owner name other than DS + # RRs". The DS is the exception, and it is the one type that + # lives on the parent side, so a direct DS query is + # answered by exactly this record. + if rdtype != dns.rdatatype.DS and self._parent_side(nsec, qname, signer): + continue + types = _types_in_bitmap(nsec[0]) + # The DS exemption above lets a parent-side record answer a DS + # query. It must not let a *child*-side one: a record bearing + # SOA is the child apex's own, the DS lives in the parent, and + # the child is in no position to say whether its parent + # published one (RFC 4035 §5.2). + if rdtype == dns.rdatatype.DS and dns.rdatatype.SOA in types and qname != dns.name.root: + continue + if rdtype not in types and dns.rdatatype.CNAME not in types: + return ValidationState.SECURE continue - types = _types_in_bitmap(nsec[0]) - if rdtype not in types and dns.rdatatype.CNAME not in types: - return True + if not self._may_deny_below(nsec, qname, signer): + continue + # An empty non-terminal has no NSEC of its own: it holds no records + # while a name below it does, so the zone's NSEC chain jumps + # straight over it. The NSEC that covers the name and whose *next* + # name lies below it proves exactly that, and therefore that the + # name has no records of any type (RFC 4035 §3.1.3.4.1). + # + # A name that did have records would own an NSEC, and the record + # before it would point at the name itself rather than past it, so + # this cannot be used to deny a name that exists. + following: dns.name.Name = nsec[0].next + if following != qname and following.is_subdomain(qname) and self._nsec_covers(nsec.name, following, qname): + return ValidationState.SECURE + + # Wildcard NODATA: the queried name does not exist, but a wildcard + # above it does and holds no record of the queried type, so the server + # answers NODATA rather than NXDOMAIN (RFC 4035 §5.4). The proof is a + # cover for the name plus an NSEC owned by the wildcard itself whose + # bitmap lacks the type. + deniers = [n for n in nsecs if self._may_deny_below(n, qname, signer)] + for covering in deniers: + if not self._nsec_covers(covering.name, covering[0].next, qname): + continue + # The wildcard that answered is the one at the closest encloser, + # and only that one. A zone can hold nested wildcards - say + # `*.deep.example` with an MX and `*.example` without - and taking + # any ancestor's wildcard would let the higher one deny a type the + # closer one really serves. + wildcard = dns.name.Name((b"*",) + self._closest_encloser_nsec(qname, covering).labels) + for nsec in deniers: + if nsec.name != wildcard: + continue + types = _types_in_bitmap(nsec[0]) + if rdtype not in types and dns.rdatatype.CNAME not in types: + return ValidationState.SECURE nsec3s = self._validated_nsec_rrsets(authority, keys, dns.rdatatype.NSEC3, budget) if not nsec3s: - return False + return ValidationState.BOGUS params = nsec3s[0][0] if params.iterations > self.max_nsec3_iterations: - return False + return ValidationState.BOGUS for nsec3 in nsec3s: if self._nsec3_owner(qname, nsec3.name, params, budget) != nsec3.name: continue + if rdtype != dns.rdatatype.DS and self._parent_side(nsec3, qname, signer): + continue types = _types_in_bitmap(nsec3[0]) + if rdtype == dns.rdatatype.DS and dns.rdatatype.SOA in types and qname != dns.name.root: + continue if rdtype not in types and dns.rdatatype.CNAME not in types: - return True + return ValidationState.SECURE - # Opt-out NODATA for an unsigned delegation below an NSEC3 zone. When - # `closest` is a proper ancestor of `qname` a next-closer name always - # exists, so the two conditions collapse into one. + # Everything below needs the closest encloser, and a next closer name + # under it. closest = self._closest_encloser_nsec3(qname, nsec3s, params, budget) - next_closer = self._next_closer(qname, closest) if closest is not None else None - if next_closer is not None: + if closest is None: + return ValidationState.BOGUS + next_closer = self._next_closer(qname, closest) + if next_closer is None: + return ValidationState.BOGUS + + # Wildcard NODATA: the queried name does not exist, but a wildcard at + # its closest encloser does and holds no record of the queried type + # (RFC 5155 §8.7). The proof is the closest encloser, a cover for the + # next closer name, and an NSEC3 matching `*.` whose + # bitmap lacks the type. Zones that answer NODATA off a wildcard are + # common: this is how a name with only an A wildcard denies TXT. + next_closer_covers = [n for n in nsec3s if self._nsec3_covers(next_closer, n, params, budget)] + if next_closer_covers: + wildcard = dns.name.Name((b"*",) + closest.labels) for nsec3 in nsec3s: - if nsec3[0].flags & NSEC3_OPT_OUT and self._nsec3_covers(next_closer, nsec3, params, budget): - return True - return False + if self._nsec3_owner(wildcard, nsec3.name, params, budget) != nsec3.name: + continue + types = _types_in_bitmap(nsec3[0]) + if rdtype not in types and dns.rdatatype.CNAME not in types: + if any(not n[0].flags & NSEC3_OPT_OUT for n in next_closer_covers): + return ValidationState.SECURE + break + + # Opt-out. RFC 5155 §8.6 lets an opt-out cover deny the DS of an + # unsigned delegation, and §8.5 wants a *matching* NSEC3 for any other + # type - which a name inside an opt-out span does not have. The bit + # asserts only that the range holds no *signed* delegation, so a name + # in it may exist, unsigned, and answer for itself: nothing here proves + # the denial, and nothing here contradicts it either. Returned + # unauthenticated rather than refused (§9.2). Refusing is what a whole + # family of TLD subzones looked like in the wild: a NOERROR/NODATA off + # the parent whose only proof is an opt-out gap. + if any(n[0].flags & NSEC3_OPT_OUT for n in next_closer_covers): + return ValidationState.INSECURE + return ValidationState.BOGUS diff --git a/src/recursive_resolver/exceptions.py b/src/recursive_resolver/exceptions.py index f95cf85..ce34575 100644 --- a/src/recursive_resolver/exceptions.py +++ b/src/recursive_resolver/exceptions.py @@ -110,6 +110,26 @@ def __init__(self, qname: str, rdtype: str, resource: str, limit: int) -> None: ) +class DNSSECMaterialUnavailableError(ResolverError): + """A DNSKEY or DS could not be retrieved, so no verdict can be reached. + + Deliberately **not** a :class:`DNSSECError`: nothing was validated and + nothing failed to validate. One lame nameserver in a zone's NS set, or a + SERVFAIL from every one of them, means the validation material never + arrived; reporting that as bogus turns a transient server fault into an + apparent attack. Consumers that treat :class:`DNSSECError` as "refuse to + use this data" should let this one fall through to ordinary retry handling. + """ + + def __init__(self, qname: str, rdtype: str, reason: str = "no usable response from any nameserver") -> None: + self.reason = reason + super().__init__( + f"Could not retrieve DNSSEC material {qname}/{rdtype}: {reason}", + qname=qname, + rdtype=rdtype, + ) + + class DNSSECError(ResolverError): """Base class for DNSSEC validation problems.""" diff --git a/src/recursive_resolver/resolver.py b/src/recursive_resolver/resolver.py index 658ccf6..68d9822 100644 --- a/src/recursive_resolver/resolver.py +++ b/src/recursive_resolver/resolver.py @@ -17,6 +17,7 @@ import threading import time from collections import OrderedDict +from collections.abc import Callable from dataclasses import dataclass, field, replace from typing import Any @@ -26,8 +27,8 @@ import dns.name import dns.query import dns.rcode +import dns.rdata import dns.rdataclass -import dns.rdataset import dns.rdatatype import dns.reversename import dns.rrset @@ -35,10 +36,18 @@ from .addresses import AddressFilter from .budget import Limits, QueryBudget from .cache import Delegation, DNSCache -from .dnssec import DNSSECValidator, ValidationState, ZoneKeys, cryptography_available, find_rrsig +from .dnssec import ( + CLOCK_SKEW, + DNSSECValidator, + ValidationState, + ZoneKeys, + cryptography_available, + find_rrsig, +) from .exceptions import ( CNAMELoopError, DNSSECInsecureError, + DNSSECMaterialUnavailableError, DNSSECUnavailableError, DNSSECValidationError, InvalidNameError, @@ -170,7 +179,9 @@ class RecursiveResolver: below is re-resolved; ``"all"`` (the default) keeps every cut; ``"none"`` disables delegation caching. Any other label depth can be given as an integer. - min_ttl: Floor applied to cached TTLs. 0 honours the wire TTL exactly. + min_ttl: Floor applied to cached record TTLs. 0 honours the wire TTL + exactly. Never applied to negative entries or to authenticated + data, whose TTL is already capped by its signature. use_tcp_fallback: Fall back to TCP when a response is truncated. max_retries: Retries per nameserver. Each retry also downgrades EDNS. ipv4_only: Only use IPv4 addresses for queries. @@ -231,6 +242,7 @@ def __init__( extra_blocked_networks: list[str] | None = None, idna_codec: Any = None, trust_anchors: tuple[str, ...] | None = None, + clock_skew: int = CLOCK_SKEW, ) -> None: self.timeout = timeout self.max_depth = max_depth @@ -240,6 +252,12 @@ def __init__( self.ipv4_only = ipv4_only self.max_resolution_time = max_resolution_time self.limits = limits if limits is not None else Limits() + # An OPT record carries the payload size in the class field, so a value + # outside 16 bits reaches dnspython as a bad rdclass and comes back as a + # bare ValueError from inside the query path. Everything leaving this + # package is a ResolverError, so it is caught where it is configured. + if not 0 <= edns_payload <= 65535: + raise ValueError(f"edns_payload must be between 0 and 65535, got {edns_payload}") self.edns_payload = edns_payload self.require_dnssec = require_dnssec self.require_authoritative = require_authoritative @@ -255,7 +273,9 @@ def __init__( # caller error, and DNSSECValidator rejects it. Falling back to the # IANA anchors there would be the opposite of what was asked for. self._validator = ( - DNSSECValidator(trust_anchors=trust_anchors) if trust_anchors is not None else DNSSECValidator() + DNSSECValidator(trust_anchors=trust_anchors, clock_skew=clock_skew) + if trust_anchors is not None + else DNSSECValidator(clock_skew=clock_skew) ) self.address_filter = AddressFilter(extra_blocked_networks, allow_private=allow_private_addresses) @@ -363,7 +383,16 @@ def _parse_rdtype(self, rdtype: str) -> dns.rdatatype.RdataType: # separately, so a number above 65535 raises a plain ValueError # rather than UnknownRdatatype. Nothing from dnspython may escape. raise UnsupportedRdtypeError(rdtype, "record type out of range") from exc - if dns.rdatatype.is_metatype(value) and value != dns.rdatatype.ANY: + if value == dns.rdatatype.ANY: + # An ANY response carries several RRsets at once, and an `Answer` + # holds exactly one, so there is nothing this API could return. + # Silently letting it through was worse than refusing: no RRset + # ever matched the query type, so a fully populated answer was + # read as NODATA and cached as one, which then denied the name for + # every later query of any type. RFC 8482 has servers answering + # ANY minimally in any case; ask for the types you want. + raise UnsupportedRdtypeError(rdtype, "ANY cannot be queried; ask for specific types") + if dns.rdatatype.is_metatype(value): raise UnsupportedRdtypeError(rdtype, "meta record types cannot be queried") return value @@ -405,17 +434,43 @@ def _resolve_entry(self, qname: dns.name.Name, rdtype: dns.rdatatype.RdataType, raise DNSSECInsecureError(str(qname), dns.rdatatype.to_text(rdtype)) return answer + def _require_proven_denial(self, state: ValidationState, qname: dns.name.Name, rdtype_text: str) -> None: + """Hold a denial to the same standard as an answer under ``require_dnssec``. + + A negative answer leaves through an exception rather than an ``Answer``, + so the check in :meth:`_resolve_entry` never sees it. Without this, the + strict mode refuses an unsigned *answer* while accepting an unsigned + "no such name" for the same zone. + """ + if self.require_dnssec and state is not ValidationState.SECURE: + raise DNSSECInsecureError(str(qname), rdtype_text) + def _check_cache(self, qname: dns.name.Name, rdtype: dns.rdatatype.RdataType) -> Answer | None: """Consult the cache for a positive or negative entry.""" if self.cache is None: return None + # A cached denial has to clear the same bar as a fresh one. It is + # stored before the strict-mode check runs, so without re-checking here + # the first query fails closed and every one after it is served the + # denial straight from cache. nx = self.cache.get_nxdomain_ancestor(qname) if nx is not None: # RFC 8020: nothing exists below a non-existent name. + self._require_proven_denial( + ValidationState.SECURE if nx.secure else ValidationState.INSECURE, + qname, + dns.rdatatype.to_text(rdtype), + ) raise NXDOMAINError(str(qname), dns.rdatatype.to_text(rdtype)) - if self.cache.get_nodata(qname, rdtype) is not None: + nodata = self.cache.get_nodata(qname, rdtype) + if nodata is not None: + self._require_proven_denial( + ValidationState.SECURE if nodata.secure else ValidationState.INSECURE, + qname, + dns.rdatatype.to_text(rdtype), + ) raise NoAnswerError(str(qname), dns.rdatatype.to_text(rdtype)) if not self.cache_answers: @@ -428,18 +483,28 @@ def _check_cache(self, qname: dns.name.Name, rdtype: dns.rdatatype.RdataType) -> canonical_name=qname, rdtype=rdtype, rrset=entry.rrset, - ttl=entry.rrset.ttl, + # What is left of the entry, not the TTL it arrived with. A caller + # re-caching on this value would otherwise extend the lifetime on + # every hit, and for authenticated data that means outliving the + # signature the TTL was capped to (RFC 4035 §5.3.3). + ttl=max(0, int(entry.expiry - time.monotonic())), dnssec=ValidationState.SECURE if entry.secure else ValidationState.INSECURE, ) def _starting_point( self, qname: dns.name.Name, rdtype: dns.rdatatype.RdataType - ) -> tuple[dns.name.Name, list[str], ValidationState, Any]: + ) -> tuple[dns.name.Name, list[str], ValidationState, Any, list[dns.name.Name]]: """Pick where to begin: a cached delegation if we have one, else the root. A DS record is published by the *parent* zone, so a cached delegation for the qname itself would send the query to the child, which does not hold its own DS and correctly answers NODATA. Start one label up. + + The delegation's NS *names* come back too, not just its addresses. If + every cached address has gone dead the walk has nothing else to try, + and a zone whose glue has changed under a long delegation TTL would + stay dark for as long as that TTL - even though re-resolving the + hostnames would find it immediately. """ lookup = qname if rdtype == dns.rdatatype.DS and qname != dns.name.root: @@ -456,9 +521,10 @@ def _starting_point( # silently downgrade to insecure. unprovable = delegation.ds is None and self._cached_keys(delegation.zone) is None if self.dnssec and state is ValidationState.SECURE and unprovable: - return dns.name.root, list(self._root_addresses), ValidationState.SECURE, None - return delegation.zone, addresses, state, delegation.ds - return dns.name.root, list(self._root_addresses), ValidationState.SECURE, None + return dns.name.root, list(self._root_addresses), ValidationState.SECURE, None, [] + names = [dns.name.from_text(n) for n in delegation.ns_names] + return delegation.zone, addresses, state, delegation.ds, names + return dns.name.root, list(self._root_addresses), ValidationState.SECURE, None, [] def _resolve_iterative( self, @@ -479,13 +545,17 @@ def _resolve_iterative( if cached is not None: return cached - current_zone, current_nameservers, chain_state, current_ds = self._starting_point(qname, rdtype) + current_zone, current_nameservers, chain_state, current_ds, cached_ns_names = self._starting_point( + qname, rdtype + ) # `current_nameservers` is pruned as servers fail, but DNSSEC key # fetches must keep talking to the zone's full server set: otherwise a # single-nameserver signed zone returning NXDOMAIN leaves us with an # empty list and we report a DNSSEC failure of our own making. zone_nameservers = list(current_nameservers) - pending_ns_names: list[dns.name.Name] = [] + # Seeded from the cached delegation so a dead address set still has its + # hostnames to fall back on. + pending_ns_names: list[dns.name.Name] = cached_ns_names tried_addresses: set[str] = set() nxdomain_retries_left = 2 @@ -558,6 +628,11 @@ def _resolve_iterative( if not glue: glue = self._resolve_ns_names(resolvable, ctx, depth + 1, limit=self.limits.max_ns_per_referral) if not glue: + # Resolving the NS names returns nothing both when they do + # not resolve and when the budget ran out underneath. Only + # the first is a server failure. + if ctx.budget.expired(): + raise ResolutionTimeoutError(str(qname), rdtype_text) raise ServfailError(str(qname), rdtype_text) self._cache_delegation(child_zone, ns_names, glue, response, chain_state, current_ds) current_zone = child_zone @@ -572,7 +647,7 @@ def _resolve_iterative( nxdomain_retries_left -= 1 logger.debug("NXDOMAIN from %s for %s/%s, trying siblings", server, qname, rdtype_text) continue - self._verify_denial( + denial, proof_ttl = self._verify_denial( response, qname, rdtype, @@ -584,11 +659,16 @@ def _resolve_iterative( negative="nxdomain", ) if self.cache is not None: - self.cache.put_nxdomain(qname, self._negative_ttl(response, qname)) + self.cache.put_nxdomain( + qname, + self._cacheable_negative_ttl(response, qname, current_zone, denial, proof_ttl), + secure=denial is ValidationState.SECURE, + ) + self._require_proven_denial(denial, qname, rdtype_text) raise NXDOMAINError(str(qname), rdtype_text) if kind == "nodata": - self._verify_denial( + denial, proof_ttl = self._verify_denial( response, qname, rdtype, @@ -600,7 +680,13 @@ def _resolve_iterative( negative="nodata", ) if self.cache is not None: - self.cache.put_nodata(qname, rdtype, self._negative_ttl(response, qname)) + self.cache.put_nodata( + qname, + rdtype, + self._cacheable_negative_ttl(response, qname, current_zone, denial, proof_ttl), + secure=denial is ValidationState.SECURE, + ) + self._require_proven_denial(denial, qname, rdtype_text) raise NoAnswerError(str(qname), rdtype_text) # "error" / "lame": drop this server and try another. @@ -658,15 +744,15 @@ def _handle_answer( cname_chain: list[dns.name.Name], ) -> Answer: rrset: dns.rrset.RRset = classification["rrset"] - validated = self._validate_answer(response, rrset, qname, rdtype, ctx, zone, nameservers, state, ds) + validated, ttl = self._validate_answer(response, rrset, qname, rdtype, ctx, zone, nameservers, state, ds) if self.cache is not None and self.cache_answers: - self.cache.put_answer(qname, rdtype, rrset, rrset.ttl, secure=validated is ValidationState.SECURE) + self.cache.put_answer(qname, rdtype, rrset, ttl, secure=validated is ValidationState.SECURE) return Answer( qname=qname, canonical_name=qname, rdtype=rdtype, rrset=rrset, - ttl=rrset.ttl, + ttl=ttl, dnssec=validated, cname_chain=list(cname_chain), ) @@ -694,15 +780,16 @@ def _handle_cname( if len(cname_chain) >= self.max_cname_chain: raise CNAMELoopError(str(qname), [str(n) for n in cname_chain + [target]]) - cname_state = self._validate_answer( - response, cname_rrset, qname, dns.rdatatype.CNAME, ctx, zone, nameservers, state, ds + dname_rrset: dns.rrset.RRset | None = classification.get("dname_rrset") + cname_state, cname_ttl = self._validate_answer( + response, cname_rrset, qname, dns.rdatatype.CNAME, ctx, zone, nameservers, state, ds, dname_rrset ) if self.cache is not None and self.cache_answers: self.cache.put_answer( qname, dns.rdatatype.CNAME, cname_rrset, - cname_rrset.ttl, + cname_ttl, secure=cname_state is ValidationState.SECURE, ) @@ -719,15 +806,17 @@ def _handle_cname( # which the fall-through below does. inline = self._find_answer_rrset(response, target, rdtype) if target.is_subdomain(zone) else None if inline is not None: - inline_state = self._validate_answer(response, inline, target, rdtype, ctx, zone, nameservers, state, ds) + inline_state, inline_ttl = self._validate_answer( + response, inline, target, rdtype, ctx, zone, nameservers, state, ds + ) if self.cache is not None and self.cache_answers: - self.cache.put_answer(target, rdtype, inline, inline.ttl, secure=inline_state is ValidationState.SECURE) + self.cache.put_answer(target, rdtype, inline, inline_ttl, secure=inline_state is ValidationState.SECURE) return Answer( qname=qname, canonical_name=target, rdtype=rdtype, rrset=inline, - ttl=inline.ttl, + ttl=min(cname_ttl, inline_ttl), dnssec=self._weakest(cname_state, inline_state), cname_chain=[*cname_chain, qname], ) @@ -739,7 +828,9 @@ def _handle_cname( canonical_name=answer.canonical_name, rdtype=rdtype, rrset=answer.rrset, - ttl=answer.ttl, + # The chain is only good for as long as its shortest-lived link, + # exactly as its DNSSEC state is that of its weakest one. + ttl=min(cname_ttl, answer.ttl), dnssec=self._weakest(cname_state, answer.dnssec), cname_chain=[*cname_chain, qname, *answer.cname_chain], ) @@ -802,10 +893,29 @@ def _classify_response( "type": "cname", "target": cname_rrset[0].target, "cname_rrset": cname_rrset, + "dname_rrset": self._find_dname(response, qname, current_zone), "ttl": cname_rrset.ttl, "detail": f"-> {cname_rrset[0].target}", } + # A DNAME rewrites everything below its owner. The server should have + # sent a synthesized CNAME with it, and the branch above would have + # taken that; when it did not, RFC 6672 §3.4 makes the synthesis our + # job: "Recursive caching name servers MUST perform CNAME synthesis on + # behalf of clients." + dname_rrset = self._find_dname(response, qname, current_zone) + if dname_rrset is not None: + target = self._dname_target(qname, dname_rrset) + if target is not None: + return { + "type": "cname", + "target": target, + "cname_rrset": self._synthesize_cname(qname, target, dname_rrset), + "dname_rrset": dname_rrset, + "ttl": dname_rrset.ttl, + "detail": f"DNAME {dname_rrset.name} -> {target}", + } + if rcode == dns.rcode.NXDOMAIN: if self.require_authoritative and not authoritative: return {"type": "error", "rcode": rcode, "detail": "NXDOMAIN without AA bit"} @@ -814,19 +924,112 @@ def _classify_response( # An SOA owning the qname or an ancestor marks a negative answer # (RFC 2308), which takes precedence over any NS records also present. for rrset in response.authority: - if rrset.rdtype == dns.rdatatype.SOA and qname.is_subdomain(rrset.name): - if self.require_authoritative and not authoritative: - return {"type": "error", "rcode": rcode, "detail": "NODATA without AA bit"} - return {"type": "nodata"} + if not self._marks_negative(rrset, qname, current_zone): + continue + if self.require_authoritative and not authoritative: + return {"type": "error", "rcode": rcode, "detail": "NODATA without AA bit"} + return {"type": "nodata"} referral = self._find_referral(response, qname, current_zone, rdtype) if referral is not None: return referral + # An NS set that points sideways, upwards, or at a zone the qname does + # not live under is a server sending us somewhere else, not one telling + # us the name has no records. Reading it as NODATA caches a denial on a + # lame server's say-so and stops the sweep before its siblings are + # asked, so it is an error whatever the AA bit says. + # + # A delegation *at* the qname is the exception, and only for a DS: the + # parent handing back the child's NS set with no DS beside it is what + # an unsigned delegation looks like, and that really is a NODATA. + ns_sets = [ + rrset + for rrset in response.authority + if rrset.rdtype == dns.rdatatype.NS and rrset.rdclass == dns.rdataclass.IN + ] + if ns_sets and not any(self._delegates_towards(rrset.name, qname, current_zone) for rrset in ns_sets): + return {"type": "error", "rcode": rcode, "detail": "NS set that does not delegate below this zone"} + if self.require_authoritative and not authoritative: return {"type": "error", "rcode": rcode, "detail": "empty response without AA bit"} return {"type": "nodata"} + def _find_dname( + self, response: dns.message.Message, qname: dns.name.Name, zone: dns.name.Name + ) -> dns.rrset.RRset | None: + """The DNAME in this answer that rewrites ``qname``, if any. + + It must own a *proper* ancestor of the queried name - a DNAME never + applies to its own owner (RFC 6672 §2.4) - and it must be in the zone + we asked, for the same reason inline CNAME targets are: a DNAME is a + licence to rewrite a whole subtree, and one for a subtree the answering + server does not hold is an attempt to rewrite somebody else's. + """ + best: dns.rrset.RRset | None = None + for rrset in response.answer: + if rrset.rdtype != dns.rdatatype.DNAME or rrset.rdclass != dns.rdataclass.IN: + continue + if rrset.name == qname or not qname.is_subdomain(rrset.name): + continue + if not rrset.name.is_subdomain(zone): + logger.debug("Ignoring out-of-bailiwick DNAME at %s (zone %s)", rrset.name, zone) + continue + # The longest match wins, as for any other name-based lookup. + if best is None or len(rrset.name) > len(best.name): + best = rrset + return best + + @staticmethod + def _dname_target(qname: dns.name.Name, dname_rrset: dns.rrset.RRset) -> dns.name.Name | None: + """Apply the DNAME substitution to ``qname`` (RFC 6672 §3.4.1). + + The owner's labels are stripped from the queried name and the DNAME's + target put in their place. A result longer than a legal name is a + YXDOMAIN condition, and the redirection simply does not apply. + """ + try: + return qname.relativize(dname_rrset.name).concatenate(dname_rrset[0].target) + except dns.exception.DNSException as exc: + logger.debug("DNAME substitution for %s is not a usable name: %s", qname, exc) + return None + + @staticmethod + def _synthesize_cname(qname: dns.name.Name, target: dns.name.Name, dname_rrset: dns.rrset.RRset) -> dns.rrset.RRset: + """The CNAME the DNAME implies, with the DNAME's TTL (RFC 6672 §3.1).""" + rrset = dns.rrset.RRset(qname, dns.rdataclass.IN, dns.rdatatype.CNAME) + rrset.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, target.to_text())) + rrset.ttl = dname_rrset.ttl + return rrset + + @staticmethod + def _delegates_towards(owner: dns.name.Name, qname: dns.name.Name, current_zone: dns.name.Name) -> bool: + """Does an NS set at ``owner`` hand us onward toward ``qname``? + + The same progress rule :meth:`_find_referral` enforces, without its + DS-specific exception, so a caller can tell "a delegation we chose not + to follow" from "a server pointing somewhere it has no business + pointing". + """ + return owner != current_zone and owner.is_subdomain(current_zone) and qname.is_subdomain(owner) + + @staticmethod + def _marks_negative(rrset: dns.rrset.RRset, qname: dns.name.Name, current_zone: dns.name.Name) -> bool: + """Is this the SOA that makes the response a negative answer (RFC 2308)? + + It has to be in class IN, to own the queried name or an ancestor of it, + and to belong to the zone we are actually talking to. A server offering + an SOA for some zone above the one it was asked about is not answering + the question, and taking it would let it deny a name it holds nothing + about - and set the negative TTL while it was at it. + """ + return ( + rrset.rdtype == dns.rdatatype.SOA + and rrset.rdclass == dns.rdataclass.IN + and qname.is_subdomain(rrset.name) + and rrset.name.is_subdomain(current_zone) + ) + def _find_referral( self, response: dns.message.Message, @@ -931,6 +1134,18 @@ def _cache_delegation( break if ttl <= 0: return + # The addresses expire with the glue that carried them, not with the NS + # set. A delegation whose NS records live for a day but whose glue + # lives for a minute is how an operator moves a nameserver, and holding + # the old address for the day would follow them nowhere. + wanted = set(ns_names) + glue_ttls = [ + rrset.ttl + for rrset in response.additional + if rrset.name in wanted and rrset.rdtype in (dns.rdatatype.A, dns.rdatatype.AAAA) and rrset.ttl > 0 + ] + if glue_ttls: + ttl = min(ttl, *glue_ttls) self.cache.put_delegation( Delegation( zone=zone, @@ -974,19 +1189,27 @@ def _resolve_ns_names(self, ns_names: list[dns.name.Name], ctx: _Context, depth: unbounded number of those failures is the NXNSAttack amplification primitive. """ + # Both families, unless the caller asked for v4 only. A nameserver set + # reachable only over IPv6 is otherwise unreachable however the + # resolver is configured, because the hostname would only ever be + # looked up for an A record. + wanted = [dns.rdatatype.A] if self.ipv4_only else [dns.rdatatype.A, dns.rdatatype.AAAA] resolved: list[str] = [] for ns_name in ns_names[:limit]: if ctx.budget.expired() or ctx.budget.remaining_queries() <= 0: break - try: - answer = self._resolve_iterative(ns_name, dns.rdatatype.A, ctx, depth, []) - except QueryBudgetExceededError: - raise - except ResolverError as exc: - logger.debug("Failed to resolve glueless NS %s: %s", ns_name, exc) - ctx.budget.note_nx_target(str(ns_name), "A") - continue - resolved.extend(str(rr.address) for rr in answer.rrset) + for rdtype in wanted: + if ctx.budget.expired() or ctx.budget.remaining_queries() <= 0: + break + try: + answer = self._resolve_iterative(ns_name, rdtype, ctx, depth, []) + except QueryBudgetExceededError: + raise + except ResolverError as exc: + logger.debug("Failed to resolve glueless NS %s/%s: %s", ns_name, dns.rdatatype.to_text(rdtype), exc) + ctx.budget.note_nx_target(str(ns_name), dns.rdatatype.to_text(rdtype)) + continue + resolved.extend(str(rr.address) for rr in answer.rrset) return self.address_filter.filter(resolved) # ── DNSSEC ────────────────────────────────────────────────────────── @@ -1012,27 +1235,99 @@ def _store_keys(self, keys: ZoneKeys, ttl: int) -> None: self._key_cache[keys.zone] = keys self._key_cache.move_to_end(keys.zone) + def _usable_dnskey(self, zone: dns.name.Name) -> Callable[[dns.message.Message], bool]: + """Did this server actually serve the zone's signed DNSKEY RRset? + + A lame server, or one authoritative only for the parent, answers + ``NOERROR`` with an empty answer section and the delegation in + AUTHORITY. That says nothing about the zone, so the sweep must go on to + the siblings rather than treat it as the zone's final word. + """ + + def check(response: dns.message.Message) -> bool: + if response.rcode() != dns.rcode.NOERROR: + return False + if self.require_authoritative and not response.flags & dns.flags.AA: + return False + if self._find_answer_rrset(response, zone, dns.rdatatype.DNSKEY) is None: + return False + # No RRSIG means no verdict is possible either: a middlebox that + # strips the DO bit produces exactly this, and judging it BOGUS + # would condemn the zone for someone else's fault. + return find_rrsig(list(response.answer), zone, dns.rdatatype.DNSKEY) is not None + + return check + + def _usable_ds(self, zone: dns.name.Name) -> Callable[[dns.message.Message], bool]: + """Did this server say anything about whether ``zone`` is delegated? + + What is rejected here is the response that says nothing: an error + rcode, or an empty non-authoritative one, which is a server unable to + answer the question rather than a statement about the delegation. + + Only two answers settle it: the DS itself, or a denial of it. Both are + signed and re-checked by the validator, so neither needs the AA bit - + a parent may legitimately answer a DS query with a referral, carrying + the DS or the NSEC3 in AUTHORITY with AA clear, and several ccTLDs do. + + Anything else is rejected, and the SOA-only response is the one that + matters. This is only ever asked of a parent whose own keys have + already validated, and a signed zone denying a DS sends NSEC or NSEC3 + alongside the SOA; a server sending the SOA by itself cannot sign its + denials, which is not the same as the zone saying the child is + unsigned. Accepting it hands the validator half a proof and the whole + delegation is called forged on the strength of one misconfigured + server. Registries running a mixed NS set have both kinds in the same + zone, so the verdict turned on which one the shuffle picked. Sweeping + past them reaches a server that does answer, and if none does, "could + not retrieve" is the honest verdict. + """ + + def check(response: dns.message.Message) -> bool: + if response.rcode() not in (dns.rcode.NOERROR, dns.rcode.NXDOMAIN): + return False + records = list(response.answer) + list(response.authority) + if any(rrset.rdtype == dns.rdatatype.DS and rrset.name == zone for rrset in records): + return True + # A referral to a cut *above* the queried name says "ask further + # down", and the denial it carries belongs to that higher cut, not + # to this delegation. Taking it as an answer is how a server + # authoritative only for the TLD gets to condemn a zone two labels + # below it: the NSEC is genuine and signed, and proves nothing + # about the name that was asked for. + if any( + rrset.rdtype == dns.rdatatype.NS and rrset.name != zone and zone.is_subdomain(rrset.name) + for rrset in records + ): + return False + return any(rrset.rdtype in (dns.rdatatype.NSEC, dns.rdatatype.NSEC3) for rrset in records) + + return check + def _get_zone_keys(self, zone: dns.name.Name, ds: Any, nameservers: list[str], ctx: _Context) -> ZoneKeys: """Fetch and validate a zone's DNSKEY RRset. Raises: - ResolutionTimeoutError: if the DNSKEY could not be fetched at all. - Being unable to *retrieve* validation material is a resolution - failure, not evidence of tampering: reporting it as BOGUS - would turn every slow nameserver into an apparent attack. + DNSSECMaterialUnavailableError: if no nameserver supplied the + DNSKEY. Being unable to *retrieve* validation material is a + resolution failure, not evidence of tampering: reporting it as + BOGUS would turn every lame or slow nameserver into an + apparent attack. """ assert self._validator is not None cached = self._cached_keys(zone) if cached is not None: return cached - response, _server = self._send_query(zone, dns.rdatatype.DNSKEY, nameservers, ctx) + response, _server = self._send_query( + zone, dns.rdatatype.DNSKEY, nameservers, ctx, usable=self._usable_dnskey(zone) + ) if response is None: - raise ResolutionTimeoutError(str(zone), "DNSKEY") + raise DNSSECMaterialUnavailableError(str(zone), "DNSKEY") dnskey_rrset = self._find_answer_rrset(response, zone, dns.rdatatype.DNSKEY) - if dnskey_rrset is None: - return ZoneKeys(zone, None, ValidationState.BOGUS) + if dnskey_rrset is None: # pragma: no cover - the predicate rules this out + raise DNSSECMaterialUnavailableError(str(zone), "DNSKEY") rrsig = find_rrsig(list(response.answer), zone, dns.rdatatype.DNSKEY) if zone == dns.name.root: @@ -1069,28 +1364,44 @@ def _descend_chain( Here we walk the missing labels one at a time, asking the servers we are already talking to for each intermediate DS. + + Most of those labels are not delegation points. ``_dmarc.example.com`` + and ``3.200.in-addr.arpa`` hold no records of their own; they exist + only because something below them does. A parent asked for their DS + answers with a denial that matches no delegation, and reading that as + a broken chain condemns every name underneath. Such a label is skipped + instead: the zone in force does not change at a name that is not a cut. """ assert self._validator is not None if target == zone or not target.is_subdomain(zone): return zone, ds, ValidationState.SECURE - while zone != target: - index = len(target.labels) - len(zone.labels) - 1 - next_zone = dns.name.Name(target.labels[index:]) - + remaining = [ + dns.name.Name(target.labels[index:]) for index in range(len(target.labels) - len(zone.labels) - 1, -1, -1) + ] + for next_zone in remaining: keys = self._get_zone_keys(zone, ds, nameservers, ctx) if keys.state is ValidationState.INSECURE: return zone, None, ValidationState.INSECURE if keys.state is not ValidationState.SECURE or keys.dnskey_rrset is None: return zone, None, ValidationState.BOGUS - response, _server = self._send_query(next_zone, dns.rdatatype.DS, nameservers, ctx) + response, _server = self._send_query( + next_zone, dns.rdatatype.DS, nameservers, ctx, usable=self._usable_ds(next_zone) + ) if response is None: # Could not retrieve the DS: indeterminate, not tampered with. - raise ResolutionTimeoutError(str(next_zone), "DS") + raise DNSSECMaterialUnavailableError(str(next_zone), "DS") records = list(response.answer) + list(response.authority) - state, next_ds = self._validator.validate_ds(next_zone, records, keys.as_keyring(), budget=ctx.budget) + keyring = keys.as_keyring() + state, next_ds = self._validator.validate_ds(next_zone, records, keyring, budget=ctx.budget) + if state is ValidationState.BOGUS and self._validator.prove_no_delegation( + next_zone, records, keyring, budget=ctx.budget + ): + # Not a cut: an empty non-terminal, or a name with records but + # no NS. The zone in force is unchanged, so carry on down. + continue if state is not ValidationState.SECURE: return next_zone, None, state zone, ds = next_zone, next_ds @@ -1110,6 +1421,33 @@ def _align_zone( return zone, ds, ValidationState.SECURE return self._descend_chain(signer, zone, ds, nameservers, ctx) + def _insecurely_delegated( + self, + name: dns.name.Name, + zone: dns.name.Name, + ds: Any, + nameservers: list[str], + ctx: _Context, + ) -> bool: + """True if ``name`` lives under a delegation ``zone`` proves is unsigned. + + Unsigned data is not automatically bogus: a server authoritative for + both a parent and a child answers straight from the child, and if that + child has no DS the answer is insecure, not forged. Signed ccTLD + subzones serving unsigned children this way are common, and every + validating resolver we cross-checked calls the result insecure. + + Without an RRSIG there is no signer to align on, so the delegations + between the zone we believe we are in and the name we got data for have + to be walked explicitly. A stripped signature inside the zone itself + still ends up bogus: proving no DS requires an NSEC bitmap with NS in + it, which a non-delegation name does not have. + """ + if name == zone or not name.is_subdomain(zone): + return False + _zone, _ds, state = self._descend_chain(name, zone, ds, nameservers, ctx) + return state is ValidationState.INSECURE + @staticmethod def _authority_signer(response: dns.message.Message) -> dns.name.Name | None: """The signer of the DS/NSEC/NSEC3 records in the authority section.""" @@ -1136,6 +1474,30 @@ def _advance_dnssec( if not ctx.dnssec or self._validator is None or state is not ValidationState.SECURE: return ValidationState.INSECURE, None + if not self._usable_ds(child_zone)(response): + # A referral from a signed parent normally carries the DS, or the + # NSEC/NSEC3 denying it, alongside the NS set. This one carries + # neither, so the server has said nothing about whether the child + # is signed. Reading that silence as forgery is how a single + # server that omits the proof condemns a whole delegation, and + # registries run mixed NS sets where only some of them do it. + # + # There is also nothing here to align on: no signature, so no + # signer, so no way to tell that the answer came from a zone below + # the one we thought we were querying - which is exactly what a + # registry serving `com.` off its own TLD servers does. So walk + # the delegations from the parent down to the child instead of + # asking for one DS: that sweeps each NS set for each cut in turn, + # and finds the intermediate zone a flat DS query cannot see. + reached, chain_ds, chain_state = self._descend_chain(child_zone, parent_zone, parent_ds, nameservers, ctx) + if chain_state is ValidationState.BOGUS: + raise DNSSECValidationError( + str(child_zone), "DS", f"delegation from {reached} is neither signed nor provably unsigned" + ) + if chain_state is ValidationState.INSECURE: + return ValidationState.INSECURE, None + return ValidationState.SECURE, chain_ds + # The referral may have been answered from a zone below the one we # believe we are querying; catch up before validating the DS. signer = self._authority_signer(response) @@ -1174,13 +1536,32 @@ def _validate_answer( nameservers: list[str], state: ValidationState, ds: Any, - ) -> ValidationState: - """Validate an answer RRset, returning its DNSSEC state.""" + dname_rrset: dns.rrset.RRset | None = None, + ) -> tuple[ValidationState, int]: + """Validate an answer RRset, returning its DNSSEC state and cache TTL. + + The TTL comes back with the state because RFC 4035 §5.3.3 caps how long + authenticated data may be held: no longer than the RRset's own TTL, the + RRSIG's TTL, the RRSIG's Original TTL, or the time left before the + signature expires. Without the last of those, a record signed with a + short-lived RRSIG and a long TTL is served as authenticated long after + the signature it rests on has expired. + """ if not ctx.dnssec or self._validator is None or state is not ValidationState.SECURE: - return ValidationState.INSECURE + return ValidationState.INSECURE, rrset.ttl rrsig = find_rrsig(list(response.answer), rrset.name, rdtype) if rrsig is None: + # A CNAME synthesized from a DNAME carries no signature of its own, + # and must not (RFC 6672 §3.3): the DNAME's signature is what + # authenticates the redirection, and the CNAME follows from it by + # construction. So validate the DNAME and inherit its verdict. + if rdtype == dns.rdatatype.CNAME and dname_rrset is not None: + return self._validate_answer( + response, dname_rrset, qname, dns.rdatatype.DNAME, ctx, zone, nameservers, state, ds + ) + if self._insecurely_delegated(rrset.name, zone, ds, nameservers, ctx): + return ValidationState.INSECURE, rrset.ttl raise DNSSECValidationError( str(qname), dns.rdatatype.to_text(rdtype), "zone is signed but the answer carries no RRSIG" ) @@ -1188,9 +1569,25 @@ def _validate_answer( # The signer tells us which zone really produced this data; a server # authoritative for both a parent and a child answers from the child # without ever emitting the intermediate referral. - zone, ds, aligned = self._align_zone(rrsig[0].signer, zone, ds, nameservers, ctx) + # + # Only a zone that contains the owner name can have signed it (RFC 4035 + # §5.3.1). The signer here comes off the wire, so without that check an + # attacker names a zone of their own and we walk the chain of trust + # down to it and fetch its keys - work they can provoke with a single + # spoofed packet, for a signature that cannot verify anyway. + # + # This is only a hint about which zone to go and fetch keys for. It is + # never a security decision: whatever zone it picks, `validated_rrsig` + # still requires a signature naming that zone as its signer and that + # zone to contain the owner name, so a wrong hint costs a wasted fetch + # and a BOGUS verdict, never a false SECURE. + signer: dns.name.Name = rrsig[0].signer + if rrset.name.is_subdomain(signer): + zone, ds, aligned = self._align_zone(signer, zone, ds, nameservers, ctx) + else: + aligned = ValidationState.SECURE if aligned is ValidationState.INSECURE: - return ValidationState.INSECURE + return ValidationState.INSECURE, rrset.ttl if aligned is ValidationState.BOGUS: raise DNSSECValidationError(str(qname), dns.rdatatype.to_text(rdtype), f"broken chain of trust at {zone}") @@ -1198,30 +1595,57 @@ def _validate_answer( if keys.state is ValidationState.INSECURE: # The zone is signed with algorithms we cannot verify, so we have # no more assurance about this answer than for an unsigned zone. - return ValidationState.INSECURE + return ValidationState.INSECURE, rrset.ttl if keys.state is not ValidationState.SECURE or keys.dnskey_rrset is None: raise DNSSECValidationError(str(qname), dns.rdatatype.to_text(rdtype), f"no valid DNSKEY for zone {zone}") keyring = keys.as_keyring() - if not self._validator.validate_rrset(rrset, rrsig, keyring, budget=ctx.budget): + signature = self._validator.validated_rrsig(rrset, rrsig, keyring, budget=ctx.budget) + if signature is None: raise DNSSECValidationError(str(qname), dns.rdatatype.to_text(rdtype), "RRSIG did not verify") + ttl = min( + rrset.ttl, + rrsig.ttl, + int(signature.original_ttl), + max(0, int(signature.expiration) - int(time.time())), + ) # A signature covering fewer labels than the owner name has means the # answer was synthesised from a wildcard. That signature verifies for # every name the wildcard could cover, so on its own it lets a replayed # wildcard record stand in for a name that has different real data. # RFC 4035 §5.3.4 requires a proof that no closer match exists. - if rrsig[0].labels < len(rrset.name.labels) - 1: + # + # The Labels count is read from the signature that actually verified, + # never from the first rdata in the RRSIG RRset. An RRSIG RRset can + # carry several rdata in an order the attacker chooses, only one of + # which verified; a decoy naming a larger Labels count would otherwise + # make a wildcard expansion look like an ordinary answer and skip the + # proof entirely. + # A query for the wildcard name itself is answered by an exact match, not + # by expansion (RFC 4592 §2.2.1), and comes with no denial records. Its + # RRSIG always covers one label fewer than the owner has, so the test + # below would read every such answer as an expansion and demand a proof + # that cannot exist. + if not rrset.name.is_wild() and signature.labels < len(rrset.name.labels) - 1: proven = self._validator.prove_wildcard( - rrset.name, rrsig[0].labels, list(response.authority), keyring, budget=ctx.budget + rrset.name, signature.labels, list(response.authority), keyring, budget=ctx.budget ) - if not proven: + if proven is ValidationState.INSECURE: + # Only an opt-out NSEC3 denies the queried name, and opt-out + # denies nothing about names that are not signed delegations. + # The data is returned, unauthenticated, as every public + # validating resolver returns it. + return ValidationState.INSECURE, ttl + if proven is not ValidationState.SECURE: + if self._validator.nsec3_beyond_our_limits(list(response.authority), keyring, ctx.budget): + return ValidationState.INSECURE, ttl raise DNSSECValidationError( str(qname), dns.rdatatype.to_text(rdtype), "wildcard-expanded answer without a proof that no closer match exists", ) - return ValidationState.SECURE + return ValidationState.SECURE, ttl def _verify_denial( self, @@ -1234,43 +1658,169 @@ def _verify_denial( state: ValidationState, ds: Any, negative: str, - ) -> None: - """Validate the denial-of-existence proof for a negative answer.""" + ) -> tuple[ValidationState, int | None]: + """Validate the denial-of-existence proof for a negative answer. + + Returns the state the *denial* was established in, so a caller running + with ``require_dnssec`` can hold a "this does not exist" to the same + standard as an answer. Being told a name has no CAA record, or no MX, + on unauthenticated evidence is exactly the thing that mode exists to + refuse. + + The second element caps how long the denial may be cached, or None when + nothing constrains it. See :meth:`_denial_ttl_cap`. + """ if not ctx.dnssec or self._validator is None or state is not ValidationState.SECURE: - return + return ValidationState.INSECURE, None signer = self._authority_signer(response) zone, ds, aligned = self._align_zone(signer, zone, ds, nameservers, ctx) if aligned is ValidationState.INSECURE: - return + return ValidationState.INSECURE, None if aligned is ValidationState.BOGUS: raise DNSSECValidationError(str(qname), dns.rdatatype.to_text(rdtype), f"broken chain of trust at {zone}") + # Nothing in the authority section is signed. As for a positive answer, + # that is what an insecurely delegated child served by its own parent + # looks like, so check the delegation before demanding a proof. + if signer is None and self._insecurely_delegated(qname, zone, ds, nameservers, ctx): + return ValidationState.INSECURE, None + keys = self._get_zone_keys(zone, ds, nameservers, ctx) if keys.state is ValidationState.INSECURE: - return + return ValidationState.INSECURE, None if keys.state is not ValidationState.SECURE or keys.dnskey_rrset is None: raise DNSSECValidationError(str(qname), dns.rdatatype.to_text(rdtype), f"no valid DNSKEY for zone {zone}") keyring = keys.as_keyring() authority = list(response.authority) - if negative == "nxdomain": - proven = self._validator.prove_nxdomain(qname, authority, keyring, budget=ctx.budget) - else: - proven = self._validator.prove_nodata(qname, rdtype, authority, keyring, budget=ctx.budget) - if not proven: - raise DNSSECValidationError( - str(qname), dns.rdatatype.to_text(rdtype), f"unproven {negative} in a signed zone" - ) + if self._validator.nsec3_beyond_our_limits(authority, keyring, ctx.budget): + return ValidationState.INSECURE, None + proven = self._proven_denial(qname, rdtype, authority, keyring, ctx, negative) + if proven is not ValidationState.BOGUS: + return proven, self._denial_ttl_cap(authority) + + # This server's proof does not hold up. RFC 4035 §5.5: "If the + # resolver has other servers to try, it SHOULD try one of them before + # concluding the answer is Bogus." One server out of sync with the + # rest of its NS set - serving an NSEC3 chain from before the last + # re-signing, say - would otherwise make the zone intermittently + # unresolvable, on the say-so of the one machine that is wrong. + # + # The sweep is expressed as a usability predicate, so `_send_query` + # moves on until a server produces a denial that actually proves + # something, and stops when the NS set or the budget runs out. Nothing + # is relaxed: whatever comes back is validated the same way. + fresh, _server = self._send_query( + qname, rdtype, nameservers, ctx, usable=self._usable_denial(qname, rdtype, keyring, ctx, negative) + ) + if fresh is not None: + authority = list(fresh.authority) + proven = self._proven_denial(qname, rdtype, authority, keyring, ctx, negative) + if proven is not ValidationState.BOGUS: + return proven, self._denial_ttl_cap(authority) + raise DNSSECValidationError(str(qname), dns.rdatatype.to_text(rdtype), f"unproven {negative} in a signed zone") @staticmethod - def _negative_ttl(response: dns.message.Message, qname: dns.name.Name) -> int | None: + def _denial_ttl_cap(authority: list[dns.rrset.RRset]) -> int | None: + """How long the records proving a denial stay authenticated, in seconds. + + RFC 4035 §5.3.3 caps authenticated data at its signature's original TTL + and at the time left before that signature expires. A denial is + authenticated data too: without this, a proof that expires in a minute + is served as proven for as long as ``max_negative_ttl`` allows. + + The minimum is taken over every RRSIG in the authority section rather + than the one that verified, which the prover does not report back. That + can only shorten the lifetime, never extend it. + """ + now = int(time.time()) + caps = [ + min(int(rrset.ttl), int(sig.original_ttl), max(0, int(sig.expiration) - now)) + for rrset in authority + if rrset.rdtype == dns.rdatatype.RRSIG + for sig in rrset + if sig.type_covered in (dns.rdatatype.NSEC, dns.rdatatype.NSEC3, dns.rdatatype.SOA) + ] + return min(caps) if caps else None + + def _proven_denial( + self, + qname: dns.name.Name, + rdtype: dns.rdatatype.RdataType, + authority: list[dns.rrset.RRset], + keyring: dict[dns.name.Name, Any], + ctx: _Context, + negative: str, + ) -> ValidationState: + """How well this authority section proves the denial. + + BOGUS means nothing here proves it. INSECURE means the proof rests on + an opt-out NSEC3, which says a range holds no signed delegations rather + than no names: something inside it may exist, so the answer is returned + without being authenticated. + """ + assert self._validator is not None + if negative == "nxdomain": + return self._validator.prove_nxdomain(qname, authority, keyring, budget=ctx.budget) + return self._validator.prove_nodata(qname, rdtype, authority, keyring, budget=ctx.budget) + + def _usable_denial( + self, + qname: dns.name.Name, + rdtype: dns.rdatatype.RdataType, + keyring: dict[dns.name.Name, Any], + ctx: _Context, + negative: str, + ) -> Callable[[dns.message.Message], bool]: + """Did this server send a denial that proves anything? + + Used to sweep past a server whose proof does not validate. The check is + the real one, run against each candidate in turn, so a server only + satisfies it by actually answering the question. + """ + + def check(response: dns.message.Message) -> bool: + if response.rcode() not in (dns.rcode.NOERROR, dns.rcode.NXDOMAIN): + return False + # The same bar `_classify_response` sets for a negative answer, so + # the sweep cannot accept from a sibling what the ordinary path + # would have refused. + if self.require_authoritative and not response.flags & dns.flags.AA: + return False + state = self._proven_denial(qname, rdtype, list(response.authority), keyring, ctx, negative) + return state is not ValidationState.BOGUS + + return check + + def _negative_ttl( + self, response: dns.message.Message, qname: dns.name.Name, current_zone: dns.name.Name + ) -> int | None: """Derive the negative-caching TTL from the authority SOA (RFC 2308 §5).""" for rrset in response.authority: - if rrset.rdtype == dns.rdatatype.SOA and qname.is_subdomain(rrset.name): + if self._marks_negative(rrset, qname, current_zone): soa = rrset[0] return int(min(rrset.ttl, soa.minimum)) return None + def _cacheable_negative_ttl( + self, + response: dns.message.Message, + qname: dns.name.Name, + current_zone: dns.name.Name, + denial: ValidationState, + proof_ttl: int | None, + ) -> int | None: + """The SOA-derived negative TTL, held to the proof's own lifetime. + + A proven denial must not outlive the signatures that proved it, or it + goes on being served as authenticated after nothing vouches for it + (RFC 4035 §5.3.3). + """ + ttl = self._negative_ttl(response, qname, current_zone) + if denial is not ValidationState.SECURE or proof_ttl is None: + return ttl + return proof_ttl if ttl is None else min(ttl, proof_ttl) + # ── transport ─────────────────────────────────────────────────────── def _order_servers(self, nameservers: list[str]) -> list[str]: @@ -1282,7 +1832,12 @@ def _effective_timeout(self, ctx: _Context) -> float: return min(self.timeout, ctx.budget.time_remaining()) def _send_query( - self, qname: dns.name.Name, rdtype: dns.rdatatype.RdataType, nameservers: list[str], ctx: _Context + self, + qname: dns.name.Name, + rdtype: dns.rdatatype.RdataType, + nameservers: list[str], + ctx: _Context, + usable: Callable[[dns.message.Message], bool] | None = None, ) -> tuple[dns.message.Message | None, str]: """Send a query to a set of nameservers, sweeping breadth-first. @@ -1293,27 +1848,43 @@ def _send_query( otherwise have succeeded immediately. Each successive sweep also degrades EDNS: full payload, then 512 bytes, - then no EDNS at all. Without the payload step a path with a broken PMTU + then no EDNS at all - except while validating, where the last rung stays + at 512 with DO set, because an answer with no OPT has no DO and is worth + nothing to us. Without the payload step a path with a broken PMTU blackholes every large response identically and no amount of retrying recovers: the classic silent failure for large TXT answers. TCP fallback does not help there, because nothing arrives to carry the TC bit. + + Args: + usable: Optional predicate deciding whether a response actually + answers what was asked. A server that answers ``NOERROR`` with + an empty answer section - a lame delegation, or a parent-side + server returning a referral - is not evidence about the data, + only about that server, so the sweep continues to its siblings + and ``(None, "")`` is returned only once every server has been + given a chance. Callers that validate use this so a single lame + server cannot masquerade as missing DNSSEC material. """ rdtype_text = dns.rdatatype.to_text(rdtype) servers = self._order_servers(nameservers) abandoned: set[str] = set() # Servers that answered in a way meaning "I do not speak EDNS". no_edns: set[str] = set() + # Servers that answered, but not with what was asked for. + unusable: set[str] = set() + # An error response held back in case nothing better turns up. + last_resort: tuple[dns.message.Message, str] | None = None for attempt in range(self.max_retries + 1): for server in servers: - if server in abandoned: + if server in abandoned or server in unusable: continue timeout = self._effective_timeout(ctx) if timeout <= 0: return None, "" - payload = None if server in no_edns else self._payload_for_attempt(attempt) + payload = None if server in no_edns else self._payload_for_attempt(attempt, ctx.dnssec) ctx.budget.spend_query(str(qname), rdtype_text) try: @@ -1330,9 +1901,12 @@ def _send_query( except _MalformedResponseError as exc: # A mangled response often means the server dislikes EDNS. logger.debug("Malformed response from %s for %s/%s: %s", server, qname, rdtype_text, exc) - if payload is not None: + if payload is not None and not ctx.dnssec: no_edns.add(server) else: + # While validating there is no point retrying without + # EDNS: no OPT means no DO, so the answer comes back + # unsigned and unusable. See _payload_for_attempt. abandoned.add(server) continue @@ -1340,24 +1914,44 @@ def _send_query( if payload is not None and rcode in _EDNS_UNSUPPORTED_RCODES: # FORMERR / NOTIMP / SERVFAIL / BADVERS are all emitted by # servers and middleboxes that cannot cope with an OPT - # record. Retry this one without EDNS on the next sweep. + # record. Retry this one without EDNS on the next sweep, + # unless we are validating, where a DO-less answer is worth + # nothing to us: keep the response instead, so a genuine + # SERVFAIL still reaches the caller as one. logger.debug("rcode %s from %s, dropping EDNS for it", dns.rcode.to_text(rcode), server) - no_edns.add(server) + if ctx.dnssec: + abandoned.add(server) + if last_resort is None: + last_resort = (response, server) + else: + no_edns.add(server) + continue + if usable is not None and not usable(response): + logger.debug("Unusable %s/%s response from %s, sweeping on", qname, rdtype_text, server) + unusable.add(server) continue return response, server - if len(abandoned) == len(servers): + if len(abandoned) + len(unusable) == len(servers): break + # Only for callers with no predicate: one that asked for specific + # validation material wants "could not retrieve", not an error response. + if usable is None and last_resort is not None: + return last_resort return None, "" - def _payload_for_attempt(self, attempt: int) -> int | None: + def _payload_for_attempt(self, attempt: int, dnssec: bool = False) -> int | None: """EDNS payload for a retry attempt; ``None`` means no EDNS at all.""" if attempt <= 0: return self.edns_payload if attempt == 1: return min(512, self.edns_payload) - return None + # DNSSEC needs EDNS0 to carry the DO bit (RFC 4035 3.2.1). Dropping the + # OPT record on the last sweep means the answer comes back with no + # RRSIGs at all, which the validator can only read as BOGUS: a + # self-inflicted validation failure against a perfectly good zone. + return min(512, self.edns_payload) if dnssec else None def _query_once( self, @@ -1380,13 +1974,20 @@ def _query_once( except dns.exception.DNSException as exc: # pragma: no cover - name is pre-validated raise _FatalServerError(f"cannot build query: {exc}") from exc + # ignore_errors keeps listening when a datagram arrives that is not an + # answer to this query, instead of giving up on the first one. The + # acceptance test is unchanged - dnspython still requires the reply to + # match the query - so nothing forged is let through; what changes is + # that an off-path spoofer cannot retire a healthy nameserver by + # racing in a single wrong-ID packet, which is the whole point of the + # randomised ID and source port (RFC 5452 §9). try: if self.use_tcp_fallback: - response, _used_tcp = dns.query.udp_with_fallback(query, server, timeout=timeout) + response, _used_tcp = dns.query.udp_with_fallback(query, server, timeout=timeout, ignore_errors=True) else: # raise_on_truncation is essential: without it a truncated # response is silently returned with a partial answer section. - response = dns.query.udp(query, server, timeout=timeout, raise_on_truncation=True) + response = dns.query.udp(query, server, timeout=timeout, raise_on_truncation=True, ignore_errors=True) except dns.message.Truncated as exc: raise _RetryableError("truncated response and TCP fallback is disabled") from exc except dns.exception.Timeout as exc: diff --git a/tests/conftest.py b/tests/conftest.py index 6b19215..682c039 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -107,7 +107,7 @@ def sequence(responses: list[tuple[dns.message.Message, str]]): """Return a _send_query side effect that replays responses in order.""" state = {"i": 0} - def side_effect(qname, rdtype, nameservers, ctx): + def side_effect(qname, rdtype, nameservers, ctx, usable=None): i = state["i"] if i < len(responses): state["i"] += 1 diff --git a/tests/signed_zone.py b/tests/signed_zone.py index 049992d..3e6ed31 100644 --- a/tests/signed_zone.py +++ b/tests/signed_zone.py @@ -12,6 +12,7 @@ from __future__ import annotations +import time from dataclasses import dataclass import dns.dnssec @@ -24,11 +25,17 @@ from cryptography.hazmat.primitives.asymmetric import ec ALGORITHM = dns.dnssec.Algorithm.ECDSAP256SHA256 +REVOKE = 0x0080 # RFC 5011 §7 DIGEST = dns.dnssec.DSDigest.SHA256 -# A wide signature validity window keeps the fixture stable over time. -INCEPTION = 1_600_000_000 -EXPIRATION = 4_000_000_000 +# A realistic signature validity window, computed at import so the fixture +# stays valid as time passes. It must be realistic rather than merely wide: +# RFC 4034 §3.1.5 compares inception and expiration as serial numbers per RFC +# 1982, so a window spanning more than 2^31 seconds - about 68 years - reads as +# wrapped and is refused, here and by Unbound alike. Real zones sign for days. +_NOW = int(time.time()) +INCEPTION = _NOW - 3600 +EXPIRATION = _NOW + 30 * 86400 def rrset_of(name: str, rdtype: str, *rdatas: str, ttl: int = 300) -> dns.rrset.RRset: @@ -61,7 +68,7 @@ def create(cls, zone: str) -> SignedZone: dnskey_rrset.ttl = 3600 return cls(name=name, private_key=private_key, dnskey=dnskey, dnskey_rrset=dnskey_rrset) - def sign(self, rrset: dns.rrset.RRset) -> dns.rrset.RRset: + def sign(self, rrset: dns.rrset.RRset, expiration: int = EXPIRATION) -> dns.rrset.RRset: """Return the RRSIG RRset covering ``rrset``, signed by this zone.""" rrsig = dns.dnssec.sign( rrset, @@ -69,7 +76,7 @@ def sign(self, rrset: dns.rrset.RRset) -> dns.rrset.RRset: self.name, self.dnskey, inception=INCEPTION, - expiration=EXPIRATION, + expiration=expiration, ) out = dns.rrset.RRset(rrset.name, dns.rdataclass.IN, dns.rdatatype.RRSIG) out.add(rrsig) @@ -96,6 +103,25 @@ def ds_rrset(self, child: SignedZone) -> dns.rrset.RRset: def keyring(self) -> dict[dns.name.Name, dns.rrset.RRset]: return {self.name: self.dnskey_rrset} + def revoked(self) -> SignedZone: + """The same zone after revoking its key (RFC 5011 §2.1). + + Revocation is the REVOKE bit set on the *same* key material, so the + holder of the private key can still produce signatures that verify + against it. That is the point: a validator that ignores the bit keeps + trusting a key its owner has publicly withdrawn. + """ + revoked_key = dns.rdata.from_text( + dns.rdataclass.IN, + dns.rdatatype.DNSKEY, + f"{self.dnskey.flags | REVOKE} {self.dnskey.protocol} {self.dnskey.algorithm} " + f"{dns.rdata._base64ify(self.dnskey.key, chunksize=0)}", + ) + rrset = dns.rrset.RRset(self.name, dns.rdataclass.IN, dns.rdatatype.DNSKEY) + rrset.add(revoked_key) + rrset.ttl = self.dnskey_rrset.ttl + return SignedZone(name=self.name, private_key=self.private_key, dnskey=revoked_key, dnskey_rrset=rrset) + def anchor_text(self) -> str: """This zone's DS in presentation format, for use as a trust anchor.""" ds = dns.dnssec.make_ds(self.name, self.dnskey, DIGEST) diff --git a/tests/test_cache.py b/tests/test_cache.py index 6d5df4a..52989e4 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -7,6 +7,9 @@ from unittest.mock import patch import dns.name +import dns.rdata +import dns.rdataclass +import dns.rdataset import dns.rdatatype import pytest from conftest import make_response, referral, root_to_com @@ -108,20 +111,36 @@ def test_nxdomain_is_keyed_by_name_not_type(self) -> None: cache = DNSCache() cache.put_nxdomain(EXAMPLE, ttl=300) assert cache.get_nxdomain(EXAMPLE) is not None - assert cache.get_nxdomain_ancestor(EXAMPLE) == EXAMPLE + assert cache.get_nxdomain_ancestor(EXAMPLE) is not None def test_harden_below_nxdomain(self) -> None: """RFC 8020: nothing can exist below a non-existent name.""" cache = DNSCache() cache.put_nxdomain(dns.name.from_text("gone.example.com."), ttl=300) found = cache.get_nxdomain_ancestor(dns.name.from_text("deep.under.gone.example.com.")) - assert found == dns.name.from_text("gone.example.com.") + assert found is not None and found.is_negative def test_unrelated_names_are_not_covered(self) -> None: cache = DNSCache() cache.put_nxdomain(dns.name.from_text("gone.example.com."), ttl=300) assert cache.get_nxdomain_ancestor(dns.name.from_text("live.example.com.")) is None + def test_the_denial_state_is_recorded_and_returned(self) -> None: + """A cached denial must remember whether it was ever proven.""" + cache = DNSCache() + cache.put_nxdomain(dns.name.from_text("proven.example.com."), ttl=300, secure=True) + cache.put_nxdomain(dns.name.from_text("unproven.example.com."), ttl=300) + proven = cache.get_nxdomain_ancestor(dns.name.from_text("a.proven.example.com.")) + unproven = cache.get_nxdomain_ancestor(dns.name.from_text("a.unproven.example.com.")) + assert proven is not None and proven.secure is True + assert unproven is not None and unproven.secure is False + + def test_nodata_records_the_denial_state_too(self) -> None: + cache = DNSCache() + cache.put_nodata(EXAMPLE, "MX", ttl=300, secure=True) + entry = cache.get_nodata(EXAMPLE, "MX") + assert entry is not None and entry.secure is True + def test_nodata_is_keyed_by_name_and_type(self) -> None: cache = DNSCache() cache.put_nodata(EXAMPLE, dns.rdatatype.MX, ttl=300) @@ -153,7 +172,30 @@ def test_negative_ttl_ceiling(self) -> None: assert cache.get_nxdomain(EXAMPLE) is None +class TestTTLBounds: + def test_a_floor_above_the_ceiling_is_refused(self) -> None: + """_clamp applies the floor last, so this would silently invert max_ttl.""" + with pytest.raises(ValueError, match="min_ttl"): + DNSCache(min_ttl=600, max_ttl=300) + + def test_negative_bounds_are_refused(self) -> None: + with pytest.raises(ValueError, match="negative"): + DNSCache(min_ttl=-1) + + class TestDelegationCache: + def test_a_validated_delegation_is_not_floored(self) -> None: + """It carries a signed DS, so min_ttl must not hold it past the signature.""" + cache = DNSCache(min_ttl=600) + now = time.monotonic() + zone = dns.name.from_text("example.com.") + with patch("recursive_resolver.cache.time.monotonic", return_value=now): + cache.put_delegation(Delegation(zone=zone, addresses=["1.2.3.4"], secure=True), ttl=30) + cache.put_delegation(Delegation(zone=dns.name.from_text("unsigned.test."), addresses=["1.2.3.4"]), ttl=30) + with patch("recursive_resolver.cache.time.monotonic", return_value=now + 31): + assert cache.get_delegation(zone) is None + assert cache.get_delegation(dns.name.from_text("unsigned.test.")) is not None + def test_put_and_closest(self) -> None: cache = DNSCache() cache.put_delegation(Delegation(zone=dns.name.from_text("com."), addresses=["1.2.3.4"]), ttl=3600) @@ -291,7 +333,7 @@ def test_negative_ttl_comes_from_the_soa(self) -> None: authority=[("example.com.", 900, "SOA", ["ns1.example.com. a.example.com. 1 3600 900 604800 120"])], aa=True, ) - assert resolver._negative_ttl(response, EXAMPLE) == 120 + assert resolver._negative_ttl(response, EXAMPLE, EXAMPLE) == 120 def test_nodata_is_cached(self) -> None: resolver = RecursiveResolver(dnssec=False, cache_enabled=True) @@ -373,3 +415,52 @@ def test_the_resolver_accepts_a_level_name(self) -> None: resolver = RecursiveResolver(dnssec=False, max_delegation_cache_depth="tld") assert resolver.cache is not None assert resolver.cache.max_delegation_depth == 1 + + +class TestEntriesHandedOutAreCopies: + """A caller must not be able to rewrite the cache by touching what it got. + + `secure` on a negative entry is what `require_dnssec` gates on, so an + aliased entry is a way to downgrade a cached denial for every thread. + """ + + def test_a_negative_entry_is_a_copy(self) -> None: + cache = DNSCache() + cache.put_nxdomain("gone.example.", ttl=300, secure=True) + cache.get_nxdomain(dns.name.from_text("gone.example.")) + entry = cache.get_nxdomain(dns.name.from_text("gone.example.")) + assert entry is not None + entry.secure = False + again = cache.get_nxdomain(dns.name.from_text("gone.example.")) + assert again is not None and again.secure is True + + def test_the_rfc8020_ancestor_lookup_is_a_copy_too(self) -> None: + cache = DNSCache() + cache.put_nxdomain("gone.example.", ttl=300, secure=True) + entry = cache.get_nxdomain_ancestor(dns.name.from_text("a.b.gone.example.")) + assert entry is not None + entry.secure = False + again = cache.get_nxdomain_ancestor(dns.name.from_text("a.b.gone.example.")) + assert again is not None and again.secure is True + + def test_a_nodata_entry_is_a_copy(self) -> None: + cache = DNSCache() + cache.put_nodata("x.example.", "MX", ttl=300, secure=True) + entry = cache.get_nodata(dns.name.from_text("x.example."), dns.rdatatype.MX) + assert entry is not None + entry.secure = False + again = cache.get_nodata(dns.name.from_text("x.example."), dns.rdatatype.MX) + assert again is not None and again.secure is True + + def test_a_delegations_ds_is_a_copy(self) -> None: + """It is the trust material a resumed resolution rebuilds its chain from.""" + cache = DNSCache() + ds = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) + ds.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, "12345 8 2 " + "ab" * 32), ttl=300) + cache.put_delegation(Delegation(zone=EXAMPLE, addresses=["1.2.3.4"], ds=ds), ttl=300) + handed_out = cache.get_delegation(EXAMPLE) + assert handed_out is not None and handed_out.ds is not None + handed_out.ds.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, "999 8 2 " + "cd" * 32), ttl=300) + again = cache.get_delegation(EXAMPLE) + assert again is not None and again.ds is not None + assert len(again.ds) == 1, "a caller's edit reached the cached trust material" diff --git a/tests/test_cli.py b/tests/test_cli.py index 126d2df..3823e5b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -284,6 +284,11 @@ def test_json_output_has_no_note(self, capsys: pytest.CaptureFixture) -> None: assert err == "" assert json.loads(out)["dnssec"] == "secure" + def test_json_says_disabled_rather_than_insecure(self, capsys: pytest.CaptureFixture) -> None: + """The stderr note gets this right; the JSON payload used to claim "insecure".""" + out, _ = self._run(["--json", "--no-dnssec", "example.com"], _answer(dnssec=ValidationState.INSECURE), capsys) + assert json.loads(out)["dnssec"] == "disabled" + def test_text_mode_still_reports(self, capsys: pytest.CaptureFixture) -> None: out, err = self._run(["--text", "example.com", "TXT"], _answer('"v=spf1 -all"', "TXT"), capsys) assert err.strip() == "; fully validated" diff --git a/tests/test_dnssec.py b/tests/test_dnssec.py index 2c61768..635c237 100644 --- a/tests/test_dnssec.py +++ b/tests/test_dnssec.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import time from unittest.mock import patch import dns.dnssec @@ -108,7 +109,7 @@ def test_nodata_proven_by_matching_nsec(self) -> None: v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] [nsec] if rdtype == dns.rdatatype.NSEC else [] ) - assert v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.MX, [], {}) + assert v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.MX, [], {}) is ValidationState.SECURE def test_nodata_not_proven_when_the_type_is_present(self) -> None: v = DNSSECValidator() @@ -116,7 +117,7 @@ def test_nodata_not_proven_when_the_type_is_present(self) -> None: v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] [nsec] if rdtype == dns.rdatatype.NSEC else [] ) - assert not v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.MX, [], {}) + assert v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.MX, [], {}) is ValidationState.BOGUS def test_nodata_not_proven_when_a_cname_exists(self) -> None: """A CNAME at the name means the answer should have been a CNAME.""" @@ -125,7 +126,7 @@ def test_nodata_not_proven_when_a_cname_exists(self) -> None: v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] [nsec] if rdtype == dns.rdatatype.NSEC else [] ) - assert not v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.MX, [], {}) + assert v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.MX, [], {}) is ValidationState.BOGUS def test_no_ds_proven_by_nsec_without_the_ds_bit(self) -> None: v = DNSSECValidator() @@ -152,7 +153,7 @@ def test_nxdomain_needs_a_wildcard_denial_too(self) -> None: [covering] if rdtype == dns.rdatatype.NSEC else [] ) # b.example.com is covered, but *.example.com is not denied. - assert not v.prove_nxdomain(dns.name.from_text("b.example.com."), [], {}) + assert v.prove_nxdomain(dns.name.from_text("b.example.com."), [], {}) is ValidationState.BOGUS def test_nxdomain_proven_with_both_denials(self) -> None: v = DNSSECValidator() @@ -161,12 +162,12 @@ def test_nxdomain_proven_with_both_denials(self) -> None: v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] [covering, wildcard] if rdtype == dns.rdatatype.NSEC else [] ) - assert v.prove_nxdomain(dns.name.from_text("b.example.com."), [], {}) + assert v.prove_nxdomain(dns.name.from_text("b.example.com."), [], {}) is ValidationState.SECURE def test_unsigned_authority_proves_nothing(self) -> None: v = DNSSECValidator() - assert not v.prove_nxdomain(dns.name.from_text("b.example.com."), [], {}) - assert not v.prove_nodata(dns.name.from_text("b.example.com."), dns.rdatatype.A, [], {}) + assert v.prove_nxdomain(dns.name.from_text("b.example.com."), [], {}) is ValidationState.BOGUS + assert v.prove_nodata(dns.name.from_text("b.example.com."), dns.rdatatype.A, [], {}) is ValidationState.BOGUS assert not v.prove_no_ds(dns.name.from_text("b.example.com."), [], {}) @@ -330,26 +331,53 @@ def test_rrsigs_are_trimmed(self) -> None: per_tag[slot] = per_tag.get(slot, 0) + 1 assert max(per_tag.values()) <= MAX_RRSIGS_PER_TAG - def test_signature_validations_are_charged_to_the_budget(self) -> None: - from recursive_resolver.budget import QueryBudget - from recursive_resolver.dnssec import MAX_RRSIGS_PER_RRSET + @staticmethod + def _sigs(count: int, *, window: str) -> dns.rrset.RRset: + """``count`` RRSIGs with distinct key tags, in or out of validity. - v = DNSSECValidator() - data = rrset("evil.test.", "A", "1.2.3.4") + The window matters: an out-of-window signature is refused before any + crypto is attempted, so it never reaches the budget. + """ + now = int(time.time()) + if window == "valid": + expiration, inception = now + 30 * 86400, now - 3600 + else: + expiration, inception = now - 7200, now - 30 * 86400 + stamp = time.strftime("%Y%m%d%H%M%S", time.gmtime(expiration)) + start = time.strftime("%Y%m%d%H%M%S", time.gmtime(inception)) sigs = dns.rrset.RRset(dns.name.from_text("evil.test."), dns.rdataclass.IN, dns.rdatatype.RRSIG) - for tag in range(50): + for tag in range(count): sigs.add( dns.rdata.from_text( dns.rdataclass.IN, dns.rdatatype.RRSIG, - f"A 8 2 300 20990101000000 20200101000000 {tag} evil.test. AAAA", + f"A 8 2 300 {stamp} {start} {tag} evil.test. AAAA", ) ) + return sigs + + def test_signature_validations_are_charged_to_the_budget(self) -> None: + from recursive_resolver.budget import QueryBudget + from recursive_resolver.dnssec import MAX_RRSIGS_PER_RRSET + + v = DNSSECValidator() + data = rrset("evil.test.", "A", "1.2.3.4") budget = QueryBudget() keys = {dns.name.from_text("evil.test."): self._dnskey_rrset(40)} - assert v.validate_rrset(data, sigs, keys, budget=budget) is False + assert v.validate_rrset(data, self._sigs(50, window="valid"), keys, budget=budget) is False assert 0 < budget.signature_validations <= MAX_RRSIGS_PER_RRSET + def test_expired_signatures_cost_no_crypto_at_all(self) -> None: + """The cheap check comes first, so a flood of stale RRSIGs is free.""" + from recursive_resolver.budget import QueryBudget + + v = DNSSECValidator() + data = rrset("evil.test.", "A", "1.2.3.4") + budget = QueryBudget() + keys = {dns.name.from_text("evil.test."): self._dnskey_rrset(40)} + assert v.validate_rrset(data, self._sigs(50, window="expired"), keys, budget=budget) is False + assert budget.signature_validations == 0 + def test_budget_exhaustion_raises(self) -> None: from recursive_resolver.budget import QueryBudget from recursive_resolver.exceptions import QueryBudgetExceededError @@ -423,6 +451,18 @@ def _successor(digest: str) -> str: return "".join(chars) +def _predecessor(digest: str) -> str: + """The immediately preceding base32hex hash of the same length.""" + chars = list(digest) + for i in range(len(chars) - 1, -1, -1): + idx = B32HEX.index(chars[i]) + if idx > 0: + chars[i] = B32HEX[idx - 1] + return "".join(chars) + chars[i] = B32HEX[-1] + return "".join(chars) + + def _stub_nsec(v, nsecs=(), nsec3s=()): """Bypass signature checking so denial logic can be tested in isolation.""" v._validated_nsec_rrsets = lambda authority, keys, rdtype, budget=None: ( # type: ignore[method-assign] @@ -509,6 +549,25 @@ def test_undecodable_next_hash_is_handled(self) -> None: class TestNoDSProofBranches: + def test_the_childs_own_apex_nsec_cannot_deny_its_ds(self) -> None: + """The DS lives in the parent, so a record bearing SOA is the wrong side. + + RFC 4035 §5.2. Without the guard a child could declare itself unsigned + and drop out of the chain of trust on its own say-so. + """ + v = DNSSECValidator() + child = dns.name.from_text("child.example.com.") + apex = rrset("child.example.com.", "NSEC", "z.child.example.com. NS SOA RRSIG NSEC") + _stub_nsec(v, nsecs=[apex]) + assert v.prove_no_ds(child, [], {}) is False + + def test_the_root_is_exempt_from_the_soa_guard(self) -> None: + """The root has no parent, so its own record is the only one there is.""" + v = DNSSECValidator() + apex = rrset(".", "NSEC", "a. NS SOA RRSIG NSEC") + _stub_nsec(v, nsecs=[apex]) + assert v.prove_no_ds(dns.name.root, [], {}) is True + def test_nsec3_matching_record_without_ds_bit(self) -> None: v = DNSSECValidator() child = dns.name.from_text("child.example.com.") @@ -532,17 +591,86 @@ def test_nsec3_non_matching_record_is_skipped(self) -> None: assert v.prove_no_ds(dns.name.from_text("child.example.com."), [], {}) is False def test_nsec3_opt_out_covering_record_proves_no_ds(self) -> None: - """.com uses NSEC3 opt-out; the child's hash is merely covered.""" + """Opt-out zones cover the next closer name rather than matching it.""" v = DNSSECValidator() child = dns.name.from_text("child.example.com.") digest = dns.dnssec.nsec3_hash(child, "AABBCCDD", 10, 1) + apex_hash = dns.dnssec.nsec3_hash(dns.name.from_text("example.com."), "AABBCCDD", 10, 1) lo = "0" * 32 hi = "V" * 32 assert lo < digest < hi - n3 = _nsec3("example.com.", lo, hi, "NS", flags=NSEC3_OPT_OUT) - _stub_nsec(v, nsec3s=[n3]) + encloser = _nsec3("example.com.", apex_hash, _successor(apex_hash), "NS SOA RRSIG DNSKEY") + covering = _nsec3("example.com.", lo, hi, "NS", flags=NSEC3_OPT_OUT) + _stub_nsec(v, nsec3s=[encloser, covering]) assert v.prove_no_ds(child, [], {}) is True + def test_nsec3_opt_out_covers_a_delegation_below_an_unsigned_cut(self) -> None: + """A whole subtree inside an opt-out span: the cover is for `sub`. + + The delegation is two labels below the encloser, so the next closer + name is its parent, not the delegation itself. + """ + v = DNSSECValidator() + child = dns.name.from_text("deep.sub.example.com.") + next_closer = dns.name.from_text("sub.example.com.") + nc_hash = dns.dnssec.nsec3_hash(next_closer, "AABBCCDD", 10, 1) + apex_hash = dns.dnssec.nsec3_hash(dns.name.from_text("example.com."), "AABBCCDD", 10, 1) + # The range brackets the next closer name alone, so a proof that looked + # for a cover of the delegation itself would not find one. + lo, hi = _predecessor(nc_hash), _successor(nc_hash) + assert not lo < dns.dnssec.nsec3_hash(child, "AABBCCDD", 10, 1) < hi + encloser = _nsec3("example.com.", apex_hash, _successor(apex_hash), "NS SOA RRSIG DNSKEY") + covering = _nsec3("example.com.", lo, hi, "NS", flags=NSEC3_OPT_OUT) + _stub_nsec(v, nsec3s=[encloser, covering]) + assert v.prove_no_ds(child, [], {}) is True + + def test_nsec3_opt_out_without_a_closest_encloser_proves_nothing(self) -> None: + v = DNSSECValidator() + child = dns.name.from_text("child.example.com.") + digest = dns.dnssec.nsec3_hash(child, "AABBCCDD", 10, 1) + lo, hi = "0" * 32, "V" * 32 + assert lo < digest < hi + covering = _nsec3("example.com.", lo, hi, "NS", flags=NSEC3_OPT_OUT) + _stub_nsec(v, nsec3s=[covering]) + assert v.prove_no_ds(child, [], {}) is False + + def test_a_matching_record_that_asserts_a_ds_leaves_nothing_to_fall_back_on(self) -> None: + """The child *is* its own closest encloser, so there is no next closer. + + The bitmap says a DS exists, so the matching branch cannot prove its + absence, and the opt-out branch has no name below the encloser to look + for a cover of. + """ + v = DNSSECValidator() + child = dns.name.from_text("child.example.com.") + digest = dns.dnssec.nsec3_hash(child, "AABBCCDD", 10, 1) + n3 = _nsec3("example.com.", digest, _successor(digest), "NS SOA DS RRSIG") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_no_ds(child, [], {}) is False + + def test_opt_out_records_that_cover_nothing_prove_nothing(self) -> None: + v = DNSSECValidator() + child = dns.name.from_text("child.example.com.") + apex_hash = dns.dnssec.nsec3_hash(dns.name.from_text("example.com."), "AABBCCDD", 10, 1) + elsewhere = dns.dnssec.nsec3_hash(dns.name.from_text("nowhere.example.com."), "AABBCCDD", 10, 1) + encloser = _nsec3("example.com.", apex_hash, _successor(apex_hash), "NS SOA RRSIG DNSKEY") + # Opt-out, but its range brackets an unrelated name. + covering = _nsec3("example.com.", _predecessor(elsewhere), _successor(elsewhere), "NS", flags=NSEC3_OPT_OUT) + _stub_nsec(v, nsec3s=[encloser, covering]) + assert v.prove_no_ds(child, [], {}) is False + + def test_nsec3_covering_record_without_opt_out_proves_nothing(self) -> None: + v = DNSSECValidator() + child = dns.name.from_text("child.example.com.") + digest = dns.dnssec.nsec3_hash(child, "AABBCCDD", 10, 1) + apex_hash = dns.dnssec.nsec3_hash(dns.name.from_text("example.com."), "AABBCCDD", 10, 1) + lo, hi = "0" * 32, "V" * 32 + assert lo < digest < hi + encloser = _nsec3("example.com.", apex_hash, _successor(apex_hash), "NS SOA RRSIG DNSKEY") + covering = _nsec3("example.com.", lo, hi, "NS") + _stub_nsec(v, nsec3s=[encloser, covering]) + assert v.prove_no_ds(child, [], {}) is False + def test_nsec_owner_mismatch_is_skipped(self) -> None: v = DNSSECValidator() nsec = rrset("other.example.com.", "NSEC", "next.example.com. NS RRSIG NSEC") @@ -555,19 +683,19 @@ def test_nsec_not_covering_the_name(self) -> None: v = DNSSECValidator() nsec = rrset("x.example.com.", "NSEC", "y.example.com. A RRSIG NSEC") _stub_nsec(v, nsecs=[nsec]) - assert v.prove_nxdomain(dns.name.from_text("zz.example.com."), [], {}) is False + assert v.prove_nxdomain(dns.name.from_text("zz.example.com."), [], {}) is ValidationState.BOGUS def test_nsec3_iteration_cap(self) -> None: v = DNSSECValidator() n3 = _nsec3("example.com.", "AAAA", "ZZZZ", "A", iterations=MAX_NSEC3_ITERATIONS + 1) _stub_nsec(v, nsec3s=[n3]) - assert v.prove_nxdomain(dns.name.from_text("x.example.com."), [], {}) is False + assert v.prove_nxdomain(dns.name.from_text("x.example.com."), [], {}) is ValidationState.BOGUS def test_nsec3_without_a_closest_encloser(self) -> None: v = DNSSECValidator() n3 = _nsec3("example.com.", "00000000000000000000000000000000", "11111111111111111111111111111111", "A") _stub_nsec(v, nsec3s=[n3]) - assert v.prove_nxdomain(dns.name.from_text("x.example.com."), [], {}) is False + assert v.prove_nxdomain(dns.name.from_text("x.example.com."), [], {}) is ValidationState.BOGUS def test_nsec3_closest_encloser_without_a_covered_next_closer(self) -> None: v = DNSSECValidator() @@ -580,7 +708,7 @@ def test_nsec3_closest_encloser_without_a_covered_next_closer(self) -> None: n3 = _nsec3("example.com.", apex_hash, _successor(apex_hash), "A") assert not (apex_hash < qname_hash < _successor(apex_hash)) _stub_nsec(v, nsec3s=[n3]) - assert v.prove_nxdomain(qname, [], {}) is False + assert v.prove_nxdomain(qname, [], {}) is ValidationState.BOGUS def test_next_closer_of_an_equal_name_is_none(self) -> None: v = DNSSECValidator() @@ -598,7 +726,7 @@ def test_nsec_owner_mismatch_is_skipped(self) -> None: v = DNSSECValidator() nsec = rrset("other.example.com.", "NSEC", "next.example.com. A RRSIG NSEC") _stub_nsec(v, nsecs=[nsec]) - assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.MX, [], {}) is False + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.MX, [], {}) is ValidationState.BOGUS def test_nsec3_matching_record_without_the_type(self) -> None: v = DNSSECValidator() @@ -606,7 +734,7 @@ def test_nsec3_matching_record_without_the_type(self) -> None: digest = dns.dnssec.nsec3_hash(qname, "AABBCCDD", 10, 1) n3 = _nsec3("example.com.", digest, "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG") _stub_nsec(v, nsec3s=[n3]) - assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is True + assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is ValidationState.SECURE def test_nsec3_matching_record_with_the_type_present(self) -> None: v = DNSSECValidator() @@ -614,27 +742,650 @@ def test_nsec3_matching_record_with_the_type_present(self) -> None: digest = dns.dnssec.nsec3_hash(qname, "AABBCCDD", 10, 1) n3 = _nsec3("example.com.", digest, "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A MX RRSIG") _stub_nsec(v, nsec3s=[n3]) - assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is False + assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is ValidationState.BOGUS def test_nsec3_iteration_cap(self) -> None: v = DNSSECValidator() n3 = _nsec3("example.com.", "AAAA", "ZZZZ", "A", iterations=MAX_NSEC3_ITERATIONS + 1) _stub_nsec(v, nsec3s=[n3]) - assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.MX, [], {}) is False + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.MX, [], {}) is ValidationState.BOGUS - def test_nsec3_opt_out_covers_an_unsigned_delegation(self) -> None: + @staticmethod + def _opt_out_chain(): + """An opt-out cover for the next closer, plus a matching closest encloser.""" + apex_hash = dns.dnssec.nsec3_hash(dns.name.from_text("example.com."), "AABBCCDD", 10, 1) + nc_hash = dns.dnssec.nsec3_hash(dns.name.from_text("sub.example.com."), "AABBCCDD", 10, 1) + lo, hi = "0" * 32, "V" * 32 + assert lo < nc_hash < hi + return [ + _nsec3("example.com.", apex_hash, "Z" * 32, "A SOA RRSIG"), + _nsec3("example.com.", lo, hi, "NS", flags=NSEC3_OPT_OUT), + ] + + def test_nsec3_opt_out_covers_the_ds_of_an_unsigned_delegation(self) -> None: + """RFC 5155 §8.6: an opt-out cover denies a DS, and only a DS. + + Unauthenticated, though: opt-out says the range holds no *signed* + delegation, so the absent DS is insecure rather than proven (§9.2). + """ v = DNSSECValidator() + _stub_nsec(v, nsec3s=self._opt_out_chain()) qname = dns.name.from_text("deep.sub.example.com.") + assert v.prove_nodata(qname, dns.rdatatype.DS, [], {}) is ValidationState.INSECURE + + def test_an_opt_out_record_covering_nothing_denies_no_ds_either(self) -> None: + v = DNSSECValidator() + apex_hash = dns.dnssec.nsec3_hash(dns.name.from_text("example.com."), "AABBCCDD", 10, 1) + elsewhere = dns.dnssec.nsec3_hash(dns.name.from_text("nowhere.example.com."), "AABBCCDD", 10, 1) + _stub_nsec( + v, + nsec3s=[ + _nsec3("example.com.", apex_hash, _successor(apex_hash), "A SOA RRSIG"), + _nsec3("example.com.", _predecessor(elsewhere), _successor(elsewhere), "NS", flags=NSEC3_OPT_OUT), + ], + ) + qname = dns.name.from_text("deep.sub.example.com.") + assert v.prove_nodata(qname, dns.rdatatype.DS, [], {}) is ValidationState.BOGUS + + def test_nsec3_opt_out_does_not_authenticate_any_other_type(self) -> None: + """§8.5 wants a matching NSEC3; opt-out asserts nothing about names. + + A range with the bit set says only that it holds no *signed* + delegations. The queried name may well exist inside it, unsigned, and + hold exactly the records being denied. So the denial is not proven - + but it is not contradicted either, and BOGUS is an accusation of + forgery the record does not support. Insecure, and the answer stands. + """ + v = DNSSECValidator() + _stub_nsec(v, nsec3s=self._opt_out_chain()) + qname = dns.name.from_text("deep.sub.example.com.") + for rdtype in (dns.rdatatype.MX, dns.rdatatype.A, dns.rdatatype.TLSA): + assert v.prove_nodata(qname, rdtype, [], {}) is ValidationState.INSECURE + + def test_a_name_that_exists_is_still_denied_nothing_by_opt_out(self) -> None: + """The escape hatch is a *gap*, so a name with its own NSEC3 cannot use it. + + Without this the rule above would read as "opt-out anywhere in the zone + excuses any missing proof", which would let a matching NSEC3 that lists + the type be talked past. + """ + v = DNSSECValidator() + qname = dns.name.from_text("deep.sub.example.com.") + digest = dns.dnssec.nsec3_hash(qname, "AABBCCDD", 10, 1) + chain = self._opt_out_chain() + chain.append(_nsec3("example.com.", digest, _successor(digest), "A MX RRSIG")) + _stub_nsec(v, nsec3s=chain) + assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is ValidationState.BOGUS + + +def _signed(name: str, rdtype: str, signer: str) -> dns.rrset.RRset: + """An RRSIG at ``name`` covering ``rdtype``, claiming to come from ``signer``.""" + return rrset(name, "RRSIG", f"{rdtype} 8 2 300 20990101000000 20200101000000 1 {signer} AAAA") + + +def _keyring(zone: str) -> dict: + """A keyring naming the zone a proof is being validated against. + + Which zone signed a record is read from here, not from the RRSIG on the + wire, so tests that turn on the signer have to say it here too. + """ + return {dns.name.from_text(zone): None} + + +class TestAncestorDelegationRecords: + """RFC 6840 §4.1: a parent-side record denies nothing below the cut. + + The parent of a delegation holds the child's NS and DS and nothing else, + so its NSEC bitmap lists only those. Read as a statement about the child + it denies every type the child actually serves, which is a denial anyone + able to place a parent-side record in an answer could exploit. + """ + + @staticmethod + def _delegation_nsec() -> list: + """`example.com` delegated by `com`: NS set, SOA clear, signer above.""" + return [ + rrset("example.com.", "NSEC", "z.com. NS RRSIG NSEC"), + _signed("example.com.", "NSEC", "com."), + ] + + def test_a_parent_side_nsec_cannot_deny_a_type_at_the_cut(self) -> None: + v = DNSSECValidator() + records = self._delegation_nsec() + _stub_nsec(v, nsecs=[records[0]]) + assert ( + v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.A, records, _keyring("com.")) + is ValidationState.BOGUS + ) + + def test_the_childs_own_nsec_still_denies_it(self) -> None: + """Same bitmap, but signed at the apex, so it is the child speaking.""" + v = DNSSECValidator() + nsec = rrset("example.com.", "NSEC", "z.example.com. NS SOA RRSIG NSEC") + records = [nsec, _signed("example.com.", "NSEC", "example.com.")] + _stub_nsec(v, nsecs=[nsec]) + assert ( + v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.A, records, _keyring("example.com.")) + is ValidationState.SECURE + ) + + def test_a_parent_side_nsec_cannot_deny_a_name_below_the_cut(self) -> None: + v = DNSSECValidator() + nsec = rrset("example.com.", "NSEC", "z.com. NS RRSIG NSEC") + records = [nsec, _signed("example.com.", "NSEC", "com.")] + _stub_nsec(v, nsecs=[nsec]) + target = dns.name.from_text("host.example.com.") + # The range does cover the name; the record is simply not allowed to say so. + assert v._nsec_covers(nsec.name, nsec[0].next, target) is True + assert v.prove_nxdomain(target, records, _keyring("com.")) is ValidationState.BOGUS + + def test_a_parent_side_nsec_cannot_authenticate_a_wildcard_below_the_cut(self) -> None: + v = DNSSECValidator() + nsec = rrset("example.com.", "NSEC", "z.com. NS RRSIG NSEC") + records = [nsec, _signed("example.com.", "NSEC", "com.")] + _stub_nsec(v, nsecs=[nsec]) + state = v.prove_wildcard(dns.name.from_text("host.example.com."), 2, records, _keyring("com.")) + assert state is ValidationState.BOGUS + + def test_a_dname_nsec_cannot_deny_a_subdomain(self) -> None: + v = DNSSECValidator() + nsec = rrset("example.com.", "NSEC", "z.com. DNAME RRSIG NSEC") + records = [nsec, _signed("example.com.", "NSEC", "example.com.")] + _stub_nsec(v, nsecs=[nsec]) + # Signed by the zone itself, so only the DNAME bit disqualifies it. + assert ( + v.prove_nxdomain(dns.name.from_text("host.example.com."), records, _keyring("example.com.")) + is ValidationState.BOGUS + ) + + def test_a_parent_side_nsec3_cannot_deny_a_type_at_the_cut(self) -> None: + v = DNSSECValidator() + name = dns.name.from_text("example.com.") + digest = dns.dnssec.nsec3_hash(name, "AABBCCDD", 10, 1) + n3 = _nsec3("com.", digest, _successor(digest), "NS RRSIG") + records = [n3, _signed(f"{digest}.com.", "NSEC3", "com.")] + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nodata(name, dns.rdatatype.A, records, _keyring("com.")) is ValidationState.BOGUS + + def test_a_ds_at_the_cut_is_the_documented_exception(self) -> None: + """RFC 6840 §4.1 exempts "DS RRs" at the delegation's own name. + + The DS is the one type that lives on the parent side, so a + direct DS query is answered by exactly this record. Refusing it would + make every DS lookup on an insecure delegation in an NSEC zone fail. + """ + v = DNSSECValidator() + records = self._delegation_nsec() + _stub_nsec(v, nsecs=[records[0]]) + child = dns.name.from_text("example.com.") + assert v.prove_nodata(child, dns.rdatatype.DS, records, _keyring("com.")) is ValidationState.SECURE + # Every other type at that name is still refused. + assert v.prove_nodata(child, dns.rdatatype.A, records, _keyring("com.")) is ValidationState.BOGUS + + def test_a_parent_side_nsec_cannot_prove_a_label_below_it_is_not_a_cut(self) -> None: + """The chain walk must not skip a label on a parent-side record's say-so. + + Skipping means "the zone in force is unchanged", so a record that knows + nothing about the zone below the cut must not be what decides it. + """ + v = DNSSECValidator() + nsec = rrset("example.com.", "NSEC", "z.com. NS RRSIG NSEC") + records = [nsec, _signed("example.com.", "NSEC", "com.")] + _stub_nsec(v, nsecs=[nsec]) + assert v.prove_no_delegation(dns.name.from_text("sub.example.com."), records, _keyring("com.")) is False + + def test_the_sweep_carries_on_past_a_parent_side_record(self) -> None: + """Skipping one record must not abandon the rest of the proof.""" + v = DNSSECValidator() + parent_side = rrset("example.com.", "NSEC", "z.com. NS RRSIG NSEC") + usable = rrset("a.example.com.", "NSEC", "z.example.com. A RRSIG NSEC") + records = [ + parent_side, + _signed("example.com.", "NSEC", "com."), + usable, + _signed("a.example.com.", "NSEC", "example.com."), + ] + _stub_nsec(v, nsecs=[parent_side, usable]) + assert v.prove_no_delegation(dns.name.from_text("m.example.com."), records, _keyring("com.")) is True + + def test_a_parent_side_nsec_cannot_prove_an_empty_non_terminal_below_it(self) -> None: + v = DNSSECValidator() + nsec = rrset("example.com.", "NSEC", "a.b.example.com. NS RRSIG NSEC") + records = [nsec, _signed("example.com.", "NSEC", "com.")] + _stub_nsec(v, nsecs=[nsec]) + assert ( + v.prove_nodata(dns.name.from_text("b.example.com."), dns.rdatatype.A, records, _keyring("com.")) + is ValidationState.BOGUS + ) + + def test_an_unsigned_record_is_not_treated_as_parent_side(self) -> None: + """With no RRSIG there is no signer to compare, so the rule cannot apply. + + Such a record never reaches these functions in production - it would + have failed signature validation first - but the guard must not throw. + """ + v = DNSSECValidator() + nsec = rrset("example.com.", "NSEC", "z.com. NS RRSIG NSEC") + _stub_nsec(v, nsecs=[nsec]) + assert v.prove_nodata(dns.name.from_text("example.com."), dns.rdatatype.A, [nsec], {}) is ValidationState.SECURE + + +class TestNSEC3ReservedFlags: + """RFC 5155 §8.2: only Opt-Out is defined; anything else must be ignored.""" + + def test_a_reserved_flag_bit_makes_the_record_unusable(self) -> None: + v = DNSSECValidator() + name = dns.name.from_text("x.example.com.") + digest = dns.dnssec.nsec3_hash(name, "AABBCCDD", 10, 1) + n3 = _nsec3("example.com.", digest, _successor(digest), "A RRSIG", flags=0x02) + with patch.object(DNSSECValidator, "validate_rrset", return_value=True): + assert v.prove_nodata(name, dns.rdatatype.MX, [n3], {}) is ValidationState.BOGUS + + def test_opt_out_alone_is_still_accepted(self) -> None: + v = DNSSECValidator() + name = dns.name.from_text("x.example.com.") + digest = dns.dnssec.nsec3_hash(name, "AABBCCDD", 10, 1) + n3 = _nsec3("example.com.", digest, _successor(digest), "A RRSIG", flags=NSEC3_OPT_OUT) + with patch.object(DNSSECValidator, "validate_rrset", return_value=True): + assert v.prove_nodata(name, dns.rdatatype.MX, [n3], {}) is ValidationState.SECURE + + +class TestWildcardDenialUsesTheClosestEncloser: + """RFC 4035 §5.4: exactly one wildcard could have synthesised the name. + + Denying the wildcard at any ancestor instead is forgeable from public data: + where a zone holds `*.example.` and is asked for `a.b.example.`, it must + answer from the wildcard, but a genuine NSEC covering the non-existent + `*.b.example.` would otherwise read as a proof that nothing exists there. + """ + + def test_a_wildcard_at_a_deeper_ancestor_does_not_prove_nxdomain(self) -> None: + v = DNSSECValidator() + # example. < *.example. < *.b.example. < a.b.example. < c.example. + # so this one record covers both the queried name and *.b.example. + nsec = rrset("*.example.", "NSEC", "c.example. A RRSIG NSEC") + _stub_nsec(v, nsecs=[nsec]) + qname = dns.name.from_text("a.b.example.") + assert v._nsec_covers(nsec.name, nsec[0].next, qname) is True + # `*.example.` owns this NSEC, so it exists and would have answered. + assert v.prove_nxdomain(qname, [nsec], {}) is ValidationState.BOGUS + + def test_denying_the_wildcard_at_the_closest_encloser_does_prove_it(self) -> None: + v = DNSSECValidator() + # The closest encloser of `nx.example.` is `example.`, and the NSEC at + # the apex covers `*.example.`, so no wildcard could have matched. + covering = rrset("a.example.", "NSEC", "z.example. A RRSIG NSEC") + apex = rrset("example.", "NSEC", "a.example. SOA NS RRSIG NSEC") + _stub_nsec(v, nsecs=[covering, apex]) + assert v.prove_nxdomain(dns.name.from_text("nx.example."), [covering, apex], {}) is ValidationState.SECURE + + def test_records_that_do_not_cover_the_name_are_skipped(self) -> None: + """The covering record is what fixes the closest encloser, so the + others must be passed over rather than used to derive one.""" + v = DNSSECValidator() + unrelated = rrset("q.other.", "NSEC", "r.other. A RRSIG NSEC") + covering = rrset("a.example.", "NSEC", "z.example. A RRSIG NSEC") + apex = rrset("example.", "NSEC", "a.example. SOA NS RRSIG NSEC") + _stub_nsec(v, nsecs=[unrelated, covering, apex]) + assert ( + v.prove_nxdomain(dns.name.from_text("nx.example."), [unrelated, covering, apex], {}) + is ValidationState.SECURE + ) + + def test_the_closest_encloser_comes_from_the_covering_record(self) -> None: + v = DNSSECValidator() + qname = dns.name.from_text("x.deep.example.") + nsec = rrset("a.deep.example.", "NSEC", "z.deep.example. A RRSIG NSEC") + assert v._common_ancestor(qname, nsec.name) == dns.name.from_text("deep.example.") + assert v._common_ancestor(qname, nsec[0].next) == dns.name.from_text("deep.example.") + + def test_common_ancestor_is_case_insensitive(self) -> None: + v = DNSSECValidator() + assert v._common_ancestor( + dns.name.from_text("a.EXAMPLE."), dns.name.from_text("b.example.") + ) == dns.name.from_text("example.") + + def test_unrelated_names_share_only_the_root(self) -> None: + v = DNSSECValidator() + assert v._common_ancestor(dns.name.from_text("a.foo."), dns.name.from_text("b.bar.")) == dns.name.root + + +class TestNestedWildcards: + """Only the wildcard at the closest encloser answered (RFC 4592 §3.3.1). + + A zone can hold nested wildcards. Taking any ancestor's lets the higher, + sparser one deny a type the closer one really serves - a forged "no MX" or + "no TLSA" built from two genuine, public, correctly signed records. + """ + + QNAME = dns.name.from_text("foo.deep.example.") + + @staticmethod + def _zone(closer_types: str): + return [ + rrset("*.example.", "NSEC", "a.example. A RRSIG NSEC"), + rrset("a.deep.example.", "NSEC", "z.deep.example. A RRSIG NSEC"), + rrset("*.deep.example.", "NSEC", f"a.deep.example. {closer_types}"), + ] + + def test_a_higher_wildcard_cannot_deny_what_the_closer_one_serves(self) -> None: + v = DNSSECValidator() + records = self._zone("MX A RRSIG NSEC") + _stub_nsec(v, nsecs=records) + assert v.prove_nodata(self.QNAME, dns.rdatatype.MX, records, _keyring("example.")) is ValidationState.BOGUS + + def test_the_closest_wildcard_still_denies_a_type_it_lacks(self) -> None: + """Only the closer wildcard's bitmap changes, so that is what decides it.""" + v = DNSSECValidator() + records = self._zone("A RRSIG NSEC") + _stub_nsec(v, nsecs=records) + assert v.prove_nodata(self.QNAME, dns.rdatatype.MX, records, _keyring("example.")) is ValidationState.SECURE + + def test_the_same_rule_governs_the_nxdomain_wildcard_denial(self) -> None: + v = DNSSECValidator() + # `*.deep.example.` exists, so nothing under deep.example. is NXDOMAIN. + records = self._zone("A RRSIG NSEC") + _stub_nsec(v, nsecs=records) + assert v.prove_nxdomain(self.QNAME, records, _keyring("example.")) is ValidationState.BOGUS + + +class TestPositiveWildcardProofMatchesTheLabelsCount: + """RFC 4035 §5.3.4: the wildcard that signed the data is the one that applies. + + A zone with nested wildcards signs each with a different Labels value. + Without checking it, the higher wildcard's genuine record and RRSIG can be + replayed as the answer for a name the lower one really covers, and the + substituted data comes back authenticated. + """ + + QNAME = dns.name.from_text("a.b.example.") + + @staticmethod + def _covering(): + # The NSEC owned by *.b.example. covers a.b.example., so the closest + # encloser is b.example. and the applicable wildcard is *.b.example. + return rrset("*.b.example.", "NSEC", "example. A RRSIG NSEC") + + def test_the_labels_count_of_the_applicable_wildcard_is_accepted(self) -> None: + v = DNSSECValidator() + records = [self._covering()] + _stub_nsec(v, nsecs=records) + assert v.prove_wildcard(self.QNAME, 2, records, _keyring("example.")) is ValidationState.SECURE + + def test_a_replayed_higher_wildcard_is_refused(self) -> None: + """Labels=1 means `*.example.`, which is not the closest encloser here.""" + v = DNSSECValidator() + records = [self._covering()] + _stub_nsec(v, nsecs=records) + assert v.prove_wildcard(self.QNAME, 1, records, _keyring("example.")) is ValidationState.BOGUS + + def test_a_record_that_does_not_cover_the_name_is_skipped(self) -> None: + v = DNSSECValidator() + unrelated = rrset("q.other.", "NSEC", "r.other. A RRSIG NSEC") + records = [unrelated, self._covering()] + _stub_nsec(v, nsecs=records) + assert v.prove_wildcard(self.QNAME, 2, records, _keyring("example.")) is ValidationState.SECURE + + +class TestADSIsNeverDeniedByTheChildItself: + """RFC 4035 §5.2: the DS lives in the parent, so the child cannot deny it. + + The parent-side exemption that lets a delegation record answer a DS query + must not also admit the child apex's own record, which carries SOA. + """ + + CHILD = dns.name.from_text("child.example.") + + def test_a_soa_bearing_nsec_cannot_deny_a_ds(self) -> None: + v = DNSSECValidator() + apex = rrset("child.example.", "NSEC", "z.child.example. NS SOA RRSIG DNSKEY NSEC") + _stub_nsec(v, nsecs=[apex]) + assert v.prove_nodata(self.CHILD, dns.rdatatype.DS, [apex], _keyring("child.example.")) is ValidationState.BOGUS + + def test_a_soa_bearing_nsec3_cannot_deny_a_ds(self) -> None: + v = DNSSECValidator() + digest = dns.dnssec.nsec3_hash(self.CHILD, "AABBCCDD", 10, 1) + n3 = _nsec3("child.example.", digest, _successor(digest), "NS SOA RRSIG DNSKEY") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_nodata(self.CHILD, dns.rdatatype.DS, [n3], _keyring("child.example.")) is ValidationState.BOGUS + + def test_the_parents_own_record_still_denies_the_ds(self) -> None: + v = DNSSECValidator() + parent_side = rrset("child.example.", "NSEC", "z.example. NS RRSIG NSEC") + _stub_nsec(v, nsecs=[parent_side]) + assert ( + v.prove_nodata(self.CHILD, dns.rdatatype.DS, [parent_side], _keyring("example.")) is ValidationState.SECURE + ) + + def test_the_root_is_exempt_because_it_has_no_parent(self) -> None: + v = DNSSECValidator() + apex = rrset(".", "NSEC", "a. NS SOA RRSIG DNSKEY NSEC") + _stub_nsec(v, nsecs=[apex]) + assert v.prove_nodata(dns.name.root, dns.rdatatype.DS, [apex], {dns.name.root: None}) is ValidationState.SECURE + + +class TestClosestEncloserMustBeFromTheProperZone: + """RFC 5155 §8.3: "The DNAME type bit must not be set and the NS type bit + may only be set if the SOA type bit is set." + + A record failing that came from the parent side of a cut, and describes + nothing inside the zone below it. Accepting one lets a parent's public, + correctly signed delegation record stand as the closest encloser for a name + in the signed child. + """ + + @staticmethod + def _matching(name: str, types: str): + digest = dns.dnssec.nsec3_hash(dns.name.from_text(name), "AABBCCDD", 10, 1) + return _nsec3("example.com.", digest, _successor(digest), types) + + def test_a_delegation_record_is_not_a_closest_encloser(self) -> None: + v = DNSSECValidator() + n3 = self._matching("child.example.com.", "NS RRSIG") + assert v._closest_encloser_nsec3(dns.name.from_text("secret.child.example.com."), [n3], n3[0]) is None + + def test_a_dname_record_is_not_a_closest_encloser(self) -> None: + v = DNSSECValidator() + n3 = self._matching("child.example.com.", "DNAME RRSIG") + assert v._closest_encloser_nsec3(dns.name.from_text("secret.child.example.com."), [n3], n3[0]) is None + + def test_an_apex_carrying_ns_and_soa_is_a_closest_encloser(self) -> None: + """The child's own apex has both bits, and is the proper zone.""" + v = DNSSECValidator() + n3 = self._matching("child.example.com.", "NS SOA RRSIG DNSKEY") + assert v._closest_encloser_nsec3( + dns.name.from_text("secret.child.example.com."), [n3], n3[0] + ) == dns.name.from_text("child.example.com.") + + def test_an_ordinary_name_is_a_closest_encloser(self) -> None: + v = DNSSECValidator() + n3 = self._matching("child.example.com.", "A RRSIG") + assert v._closest_encloser_nsec3( + dns.name.from_text("secret.child.example.com."), [n3], n3[0] + ) == dns.name.from_text("child.example.com.") + + +class TestOptOutNXDOMAINIsNotAuthenticated: + """Opt-out denies signed delegations, not names (RFC 5155 §6). + + A name inside an opt-out range may exist as an unsigned delegation, so the + name error is returned but not authenticated. In an opt-out TLD that covers + every unsigned domain, and the records needed are public and correctly + signed. Google, Cloudflare and Quad9 all clear AD on these answers. + """ + + QNAME = dns.name.from_text("x.example.") + + @staticmethod + def _proof(opt_out: bool): + def hashed(name: str) -> str: + return dns.dnssec.nsec3_hash(dns.name.from_text(name), "AABBCCDD", 10, 1) + + apex, nc, wild = hashed("example."), hashed("x.example."), hashed("*.example.") + flags = NSEC3_OPT_OUT if opt_out else 0 + # Each record brackets one name, so the three roles cannot overlap. + return [ + _nsec3("example.", apex, _successor(apex), "NS SOA RRSIG DNSKEY"), + _nsec3("example.", _predecessor(nc), _successor(nc), "NS", flags=flags), + _nsec3("example.", _predecessor(wild), _successor(wild), "A RRSIG"), + ] + + def test_an_opt_out_cover_for_the_next_closer_is_insecure(self) -> None: + v = DNSSECValidator() + _stub_nsec(v, nsec3s=self._proof(opt_out=True)) + assert v.prove_nxdomain(self.QNAME, [], {}) is ValidationState.INSECURE + + def test_the_same_proof_without_opt_out_is_secure(self) -> None: + """Only the flag differs, so that is what the verdict turns on.""" + v = DNSSECValidator() + _stub_nsec(v, nsec3s=self._proof(opt_out=False)) + assert v.prove_nxdomain(self.QNAME, [], {}) is ValidationState.SECURE + + +class TestNoDelegationProof: + """Telling "this label is not a cut" apart from "the chain is broken".""" + + def test_nsec_matching_a_name_with_no_ns_or_ds(self) -> None: + v = DNSSECValidator() + nsec = rrset("x.example.com.", "NSEC", "y.example.com. TXT RRSIG NSEC") + _stub_nsec(v, nsecs=[nsec]) + assert v.prove_no_delegation(dns.name.from_text("x.example.com."), [], {}) is True + + def test_nsec_matching_a_delegation_proves_nothing(self) -> None: + v = DNSSECValidator() + nsec = rrset("x.example.com.", "NSEC", "y.example.com. NS RRSIG NSEC") + _stub_nsec(v, nsecs=[nsec]) + assert v.prove_no_delegation(dns.name.from_text("x.example.com."), [], {}) is False + + def test_nsec_matching_a_signed_delegation_proves_nothing(self) -> None: + v = DNSSECValidator() + nsec = rrset("x.example.com.", "NSEC", "y.example.com. DS RRSIG NSEC") + _stub_nsec(v, nsecs=[nsec]) + assert v.prove_no_delegation(dns.name.from_text("x.example.com."), [], {}) is False + + def test_a_covering_nsec_is_an_empty_non_terminal(self) -> None: + """A name that owns no NSEC owns no records, so it owns no NS.""" + v = DNSSECValidator() + nsec = rrset("a.example.com.", "NSEC", "z.example.com. A RRSIG NSEC") + _stub_nsec(v, nsecs=[nsec]) + assert v.prove_no_delegation(dns.name.from_text("m.example.com."), [], {}) is True + + def test_nsec3_matching_an_empty_non_terminal(self) -> None: + v = DNSSECValidator() + name = dns.name.from_text("x.example.com.") + digest = dns.dnssec.nsec3_hash(name, "AABBCCDD", 10, 1) + n3 = _nsec3("example.com.", digest, _successor(digest), "") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_no_delegation(name, [], {}) is True + + def test_nsec3_matching_a_delegation_proves_nothing(self) -> None: + v = DNSSECValidator() + name = dns.name.from_text("x.example.com.") + digest = dns.dnssec.nsec3_hash(name, "AABBCCDD", 10, 1) + n3 = _nsec3("example.com.", digest, _successor(digest), "NS") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_no_delegation(name, [], {}) is False + + def test_an_nsec_that_neither_matches_nor_covers_proves_nothing(self) -> None: + v = DNSSECValidator() + nsec = rrset("a.example.com.", "NSEC", "c.example.com. A RRSIG NSEC") + _stub_nsec(v, nsecs=[nsec]) + assert v.prove_no_delegation(dns.name.from_text("z.example.com."), [nsec], {}) is False + + def test_an_unrelated_nsec3_proves_nothing(self) -> None: + v = DNSSECValidator() + n3 = _nsec3("example.com.", "0" * 32, "V" * 32, "") + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_no_delegation(dns.name.from_text("x.example.com."), [], {}) is False + + def test_nothing_at_all_proves_nothing(self) -> None: + v = DNSSECValidator() + _stub_nsec(v) + assert v.prove_no_delegation(dns.name.from_text("x.example.com."), [], {}) is False + + def test_nsec3_iteration_cap_applies(self) -> None: + v = DNSSECValidator() + name = dns.name.from_text("x.example.com.") + digest = dns.dnssec.nsec3_hash(name, "AABBCCDD", MAX_NSEC3_ITERATIONS + 1, 1) + n3 = _nsec3("example.com.", digest, _successor(digest), "", iterations=MAX_NSEC3_ITERATIONS + 1) + _stub_nsec(v, nsec3s=[n3]) + assert v.prove_no_delegation(name, [], {}) is False + + +class TestWildcardNODATA: + """A name that does not exist, denied a type by the wildcard above it. + + The zone answers NOERROR/NODATA rather than NXDOMAIN because a wildcard + would have synthesised the name, but holds no record of the queried type. + Proving that needs the wildcard's own denial, not the queried name's. + """ + + @staticmethod + def _nsec3_chain(qname: str, wildcard_types: str) -> list: apex = dns.name.from_text("example.com.") apex_hash = dns.dnssec.nsec3_hash(apex, "AABBCCDD", 10, 1) - next_closer = dns.name.from_text("sub.example.com.") - nc_hash = dns.dnssec.nsec3_hash(next_closer, "AABBCCDD", 10, 1) - matching = _nsec3("example.com.", apex_hash, "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG") + wildcard_hash = dns.dnssec.nsec3_hash(dns.name.from_text("*.example.com."), "AABBCCDD", 10, 1) + nc_hash = dns.dnssec.nsec3_hash(dns.name.from_text(qname), "AABBCCDD", 10, 1) lo, hi = "0" * 32, "V" * 32 assert lo < nc_hash < hi - covering = _nsec3("example.com.", lo, hi, "NS", flags=NSEC3_OPT_OUT) - _stub_nsec(v, nsec3s=[matching, covering]) - assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is True + return [ + _nsec3("example.com.", apex_hash, _successor(apex_hash), "A NS SOA RRSIG DNSKEY"), + _nsec3("example.com.", wildcard_hash, _successor(wildcard_hash), wildcard_types), + _nsec3("example.com.", lo, hi, "A RRSIG"), + ] + + def test_nsec3_wildcard_without_the_type_proves_nodata(self) -> None: + v = DNSSECValidator() + _stub_nsec(v, nsec3s=self._nsec3_chain("x.example.com.", "A AAAA RRSIG")) + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.TXT, [], {}) is ValidationState.SECURE + + def test_nsec3_wildcard_holding_the_type_proves_nothing(self) -> None: + v = DNSSECValidator() + _stub_nsec(v, nsec3s=self._nsec3_chain("x.example.com.", "A TXT RRSIG")) + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.TXT, [], {}) is ValidationState.BOGUS + + def test_nsec3_wildcard_with_a_cname_proves_nothing(self) -> None: + v = DNSSECValidator() + _stub_nsec(v, nsec3s=self._nsec3_chain("x.example.com.", "A CNAME RRSIG")) + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.TXT, [], {}) is ValidationState.BOGUS + + def test_nsec3_wildcard_needs_the_next_closer_covered(self) -> None: + """Without a cover for the queried name it may exist and hold the type.""" + v = DNSSECValidator() + chain = self._nsec3_chain("x.example.com.", "A AAAA RRSIG") + _stub_nsec(v, nsec3s=chain[:2]) + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.TXT, [], {}) is ValidationState.BOGUS + + def test_an_opt_out_cover_of_the_next_closer_is_not_authenticated(self) -> None: + """Opt-out leaves the next closer name unproven, so the wildcard may not + be the closest encloser's. The denial is returned, unauthenticated.""" + v = DNSSECValidator() + chain = self._nsec3_chain("x.example.com.", "A AAAA RRSIG") + chain[2] = _nsec3("example.com.", "0" * 32, "V" * 32, "A RRSIG", flags=NSEC3_OPT_OUT) + _stub_nsec(v, nsec3s=chain) + assert ( + v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.TXT, [], {}) is ValidationState.INSECURE + ) + + def test_nsec_wildcard_without_the_type_proves_nodata(self) -> None: + v = DNSSECValidator() + covering = rrset("a.example.com.", "NSEC", "y.example.com. A RRSIG NSEC") + wildcard = rrset("*.example.com.", "NSEC", "b.example.com. A AAAA RRSIG NSEC") + _stub_nsec(v, nsecs=[covering, wildcard]) + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.TXT, [], {}) is ValidationState.SECURE + + def test_nsec_wildcard_holding_the_type_proves_nothing(self) -> None: + v = DNSSECValidator() + covering = rrset("a.example.com.", "NSEC", "y.example.com. A RRSIG NSEC") + wildcard = rrset("*.example.com.", "NSEC", "b.example.com. A TXT RRSIG NSEC") + _stub_nsec(v, nsecs=[covering, wildcard]) + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.TXT, [], {}) is ValidationState.BOGUS + + def test_nsec_wildcard_needs_the_queried_name_covered(self) -> None: + v = DNSSECValidator() + wildcard = rrset("*.example.com.", "NSEC", "b.example.com. A AAAA RRSIG NSEC") + _stub_nsec(v, nsecs=[wildcard]) + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.TXT, [], {}) is ValidationState.BOGUS class TestTrimmingGuards: @@ -705,7 +1456,7 @@ def test_nxdomain_where_the_qname_is_its_own_closest_encloser(self) -> None: digest = dns.dnssec.nsec3_hash(qname, "AABBCCDD", 10, 1) n3 = _nsec3("example.com.", digest, _successor(digest), "A") _stub_nsec(v, nsec3s=[n3]) - assert v.prove_nxdomain(qname, [], {}) is False + assert v.prove_nxdomain(qname, [], {}) is ValidationState.BOGUS class TestOptOutNodataEdges: @@ -713,7 +1464,7 @@ def test_no_closest_encloser_means_no_opt_out_proof(self) -> None: v = DNSSECValidator() n3 = _nsec3("example.com.", "00000000000000000000000000000000", "11111111111111111111111111111111", "A") _stub_nsec(v, nsec3s=[n3]) - assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.MX, [], {}) is False + assert v.prove_nodata(dns.name.from_text("x.example.com."), dns.rdatatype.MX, [], {}) is ValidationState.BOGUS def test_qname_as_its_own_closest_encloser_has_no_next_closer(self) -> None: """A matching NSEC3 that does list the type: no NODATA, no opt-out either.""" @@ -722,7 +1473,7 @@ def test_qname_as_its_own_closest_encloser_has_no_next_closer(self) -> None: digest = dns.dnssec.nsec3_hash(qname, "AABBCCDD", 10, 1) n3 = _nsec3("example.com.", digest, _successor(digest), "A MX RRSIG") _stub_nsec(v, nsec3s=[n3]) - assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is False + assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is ValidationState.BOGUS def test_next_closer_not_covered_by_any_opt_out_record(self) -> None: """A closest encloser exists but nothing opts the next-closer name out.""" @@ -734,7 +1485,7 @@ def test_next_closer_not_covered_by_any_opt_out_record(self) -> None: # opt-out flag, so the next-closer name is never covered. n3 = _nsec3("example.com.", apex_hash, _successor(apex_hash), "A RRSIG") _stub_nsec(v, nsec3s=[n3]) - assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is False + assert v.prove_nodata(qname, dns.rdatatype.MX, [], {}) is ValidationState.BOGUS class TestWildcardProofNSEC3: @@ -753,7 +1504,37 @@ def test_a_covering_nsec3_proves_the_expansion(self) -> None: "example.test.", "00000000000000000000000000000000", "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG" ) _stub_nsec(v, nsec3s=[covering]) - assert v.prove_wildcard(self.QNAME, self.LABELS, [], {}) is True + assert v.prove_wildcard(self.QNAME, self.LABELS, [], {}) is ValidationState.SECURE + + def test_an_opt_out_cover_cannot_authenticate_the_expansion(self) -> None: + """Opt-out denies signed delegations, not existence (RFC 5155 §6). + + Found in a signed TLD zone whose wildcard answers come with a single + opt-out NSEC3: four independent public validators all return that data + with AD unset. Accepting it as SECURE would let a replayed wildcard + RRset override the real contents of any name inside an opt-out span. + """ + v = DNSSECValidator() + covering = _nsec3( + "example.test.", + "00000000000000000000000000000000", + "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + "A RRSIG", + flags=1, + ) + _stub_nsec(v, nsec3s=[covering]) + assert v.prove_wildcard(self.QNAME, self.LABELS, [], {}) is ValidationState.INSECURE + + def test_a_non_opt_out_cover_alongside_an_opt_out_one_still_proves_it(self) -> None: + v = DNSSECValidator() + opt_out = _nsec3( + "example.test.", "00000000000000000000000000000000", "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG", flags=1 + ) + solid = _nsec3( + "example.test.", "00000000000000000000000000000000", "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG" + ) + _stub_nsec(v, nsec3s=[opt_out, solid]) + assert v.prove_wildcard(self.QNAME, self.LABELS, [], {}) is ValidationState.SECURE def test_an_nsec3_matching_the_next_closer_is_not_covering_it(self) -> None: """A record whose owner IS the hash proves the name exists, not that it does not.""" @@ -761,12 +1542,12 @@ def test_an_nsec3_matching_the_next_closer_is_not_covering_it(self) -> None: digest = dns.dnssec.nsec3_hash(self.QNAME, "AABBCCDD", 10, 1) matching = _nsec3("example.test.", digest, "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG") _stub_nsec(v, nsec3s=[matching]) - assert v.prove_wildcard(self.QNAME, self.LABELS, [], {}) is False + assert v.prove_wildcard(self.QNAME, self.LABELS, [], {}) is ValidationState.BOGUS def test_no_denial_records_at_all_proves_nothing(self) -> None: v = DNSSECValidator() _stub_nsec(v) - assert v.prove_wildcard(self.QNAME, self.LABELS, [], {}) is False + assert v.prove_wildcard(self.QNAME, self.LABELS, [], {}) is ValidationState.BOGUS def test_a_labels_count_leaving_no_next_closer_is_refused(self) -> None: """labels >= the owner's label count is not a wildcard expansion at all.""" @@ -775,7 +1556,7 @@ def test_a_labels_count_leaving_no_next_closer_is_refused(self) -> None: "example.test.", "00000000000000000000000000000000", "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "A RRSIG" ) _stub_nsec(v, nsec3s=[covering]) - assert v.prove_wildcard(self.QNAME, 3, [], {}) is False + assert v.prove_wildcard(self.QNAME, 3, [], {}) is ValidationState.BOGUS def _predecessor(digest: str) -> str: @@ -835,23 +1616,23 @@ def test_a_complete_proof_is_accepted(self) -> None: self._covering("*.example.com."), # no wildcard either ], ) - assert v.prove_nxdomain(self.QNAME, [], {}) is True + assert v.prove_nxdomain(self.QNAME, [], {}) is ValidationState.SECURE def test_without_the_next_closer_covered_it_is_refused(self) -> None: v = DNSSECValidator() _stub_nsec(v, nsec3s=[self._matching("example.com."), self._covering("*.example.com.")]) - assert v.prove_nxdomain(self.QNAME, [], {}) is False + assert v.prove_nxdomain(self.QNAME, [], {}) is ValidationState.BOGUS def test_without_the_wildcard_covered_it_is_refused(self) -> None: """Otherwise a name a wildcard would have answered is denied as nonexistent.""" v = DNSSECValidator() _stub_nsec(v, nsec3s=[self._matching("example.com."), self._covering("missing.example.com.")]) - assert v.prove_nxdomain(self.QNAME, [], {}) is False + assert v.prove_nxdomain(self.QNAME, [], {}) is ValidationState.BOGUS def test_without_a_closest_encloser_it_is_refused(self) -> None: v = DNSSECValidator() _stub_nsec(v, nsec3s=[self._covering("missing.example.com."), self._covering("*.example.com.")]) - assert v.prove_nxdomain(self.QNAME, [], {}) is False + assert v.prove_nxdomain(self.QNAME, [], {}) is ValidationState.BOGUS def test_an_absurd_iteration_count_is_refused_despite_a_complete_proof(self) -> None: """The cap must be what fails this, not a missing part of the proof. @@ -860,13 +1641,13 @@ def test_an_absurd_iteration_count_is_refused_despite_a_complete_proof(self) -> genuinely complete and only the cap differs between them. """ - def attempt(iterations: int) -> bool: + def attempt(iterations: int) -> ValidationState: v = DNSSECValidator() _stub_nsec(v, nsec3s=self._complete_proof(iterations)) return v.prove_nxdomain(self.QNAME, [], {}) - assert attempt(self.ITER) is True - assert attempt(MAX_NSEC3_ITERATIONS + 1) is False + assert attempt(self.ITER) is ValidationState.SECURE + assert attempt(MAX_NSEC3_ITERATIONS + 1) is ValidationState.BOGUS class TestNSEC3IterationCapActuallyBites: @@ -900,21 +1681,21 @@ def attempt(iterations: int) -> bool: def test_prove_nodata_cap(self) -> None: qname = dns.name.from_text("nodata.example.com.") - def attempt(iterations: int) -> bool: + def attempt(iterations: int) -> ValidationState: v = DNSSECValidator() digest = self._digest("nodata.example.com.", iterations) n3 = _nsec3(self.ZONE, digest, _successor(digest), "A RRSIG", iterations=iterations) _stub_nsec(v, nsec3s=[n3]) return v.prove_nodata(qname, dns.rdatatype.MX, [], {}) - assert attempt(10) is True - assert attempt(MAX_NSEC3_ITERATIONS + 1) is False + assert attempt(10) is ValidationState.SECURE + assert attempt(MAX_NSEC3_ITERATIONS + 1) is ValidationState.BOGUS def test_prove_wildcard_cap(self) -> None: """The wildcard proof hashes too, so it needs the same guard as the others.""" qname = dns.name.from_text("anything.example.com.") - def attempt(iterations: int) -> bool: + def attempt(iterations: int) -> ValidationState: v = DNSSECValidator() covering = _nsec3( self.ZONE, @@ -926,5 +1707,48 @@ def attempt(iterations: int) -> bool: _stub_nsec(v, nsec3s=[covering]) return v.prove_wildcard(qname, 2, [], {}) - assert attempt(10) is True - assert attempt(MAX_NSEC3_ITERATIONS + 1) is False + assert attempt(10) is ValidationState.SECURE + assert attempt(MAX_NSEC3_ITERATIONS + 1) is ValidationState.BOGUS + + +class TestHighNSEC3IterationsDowngradeRatherThanFail: + """RFC 9276 §3.2 leaves the choice open; the ecosystem picks insecure. + + Unbound, BIND, Knot and PowerDNS all treat an iteration count above their + cap as a downgrade. Refusing outright makes a validly signed zone with a + legacy count unresolvable here while everyone else still answers for it. + The signature is verified first regardless (RFC 5155 §10.3), so the count + cannot have been tampered with. + """ + + @staticmethod + def _over_cap(): + return _nsec3("example.com.", "0" * 32, "V" * 32, "NS", iterations=MAX_NSEC3_ITERATIONS + 1) + + def test_the_check_reports_parameters_beyond_the_cap(self) -> None: + v = DNSSECValidator() + _stub_nsec(v, nsec3s=[self._over_cap()]) + assert v.nsec3_beyond_our_limits([], {}) is True + + def test_parameters_within_the_cap_are_fine(self) -> None: + v = DNSSECValidator() + _stub_nsec(v, nsec3s=[_nsec3("example.com.", "0" * 32, "V" * 32, "NS", iterations=10)]) + assert v.nsec3_beyond_our_limits([], {}) is False + + def test_no_nsec3_at_all_is_not_beyond_the_cap(self) -> None: + v = DNSSECValidator() + _stub_nsec(v) + assert v.nsec3_beyond_our_limits([], {}) is False + + def test_a_delegation_we_cannot_evaluate_is_insecure_not_bogus(self) -> None: + v = DNSSECValidator() + _stub_nsec(v, nsec3s=[self._over_cap()]) + state, ds = v.validate_ds(dns.name.from_text("child.example.com."), [], {}) + assert (state, ds) == (ValidationState.INSECURE, None) + + def test_a_delegation_with_no_proof_at_all_is_still_bogus(self) -> None: + """The downgrade must not swallow a missing proof.""" + v = DNSSECValidator() + _stub_nsec(v) + state, ds = v.validate_ds(dns.name.from_text("child.example.com."), [], {}) + assert (state, ds) == (ValidationState.BOGUS, None) diff --git a/tests/test_dnssec_chain.py b/tests/test_dnssec_chain.py index 6be96dd..8366f12 100644 --- a/tests/test_dnssec_chain.py +++ b/tests/test_dnssec_chain.py @@ -7,6 +7,7 @@ from unittest.mock import patch +import dns.message import dns.name import dns.rdata import dns.rdataclass @@ -16,7 +17,13 @@ import pytest from conftest import make_response -from recursive_resolver import DNSSECValidationError, RecursiveResolver, ValidationState +from recursive_resolver import ( + DNSSECInsecureError, + DNSSECMaterialUnavailableError, + DNSSECValidationError, + RecursiveResolver, + ValidationState, +) from recursive_resolver.dnssec import ZoneKeys ROOT = dns.name.root @@ -46,32 +53,73 @@ def _keys(zone: dns.name.Name, state: ValidationState = ValidationState.SECURE) return ZoneKeys(zone, rrset if state is ValidationState.SECURE else None, state) +def _signed_dnskey_response() -> dns.message.Message: + """A DNSKEY answer carrying an RRSIG, so it is material worth judging. + + The signature is nonsense, which is the point for the callers below: what + is being exercised is what happens *after* retrieval succeeds. + """ + return make_response( + answer=[ + ("example.com.", 300, "DNSKEY", ["257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WS"]), + ("example.com.", 300, "RRSIG", ["DNSKEY 8 2 300 20990101000000 20200101000000 1 example.com. AAAA"]), + ] + ) + + class TestZoneKeyFetch: def test_no_response_is_a_resolution_failure(self) -> None: """Not BOGUS: see TestIndeterminateVsBogus for the reasoning.""" - from recursive_resolver import ResolutionTimeoutError - resolver = _dnssec_resolver() ctx = resolver._new_context() with ( patch.object(resolver, "_send_query", return_value=(None, "")), - pytest.raises(ResolutionTimeoutError), + pytest.raises(DNSSECMaterialUnavailableError), ): resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) - def test_response_without_a_dnskey_is_bogus(self) -> None: + def test_every_server_answering_emptily_is_a_resolution_failure(self) -> None: + """A server answering emptily is a fact about the server, not the zone. + + Every one of them must be asked before anything is concluded, and what + is concluded is "could not retrieve", not "forged". + """ resolver = _dnssec_resolver() ctx = resolver._new_context() - with patch.object(resolver, "_send_query", return_value=(make_response(), "9.9.9.9")): - keys = resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) - assert keys.state is ValidationState.BOGUS + asked: list[str] = [] + + def query_once(qname, rdtype, server, payload, timeout, ctx_): + asked.append(server) + return make_response(aa=False) + + with ( + patch.object(resolver, "_query_once", side_effect=query_once), + pytest.raises(DNSSECMaterialUnavailableError), + ): + resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9", "1.1.1.1"], ctx) + assert set(asked) == {"9.9.9.9", "1.1.1.1"} + + def test_a_lame_server_does_not_stop_the_sweep(self) -> None: + """The zone's real DNSKEY is one sibling away; it must be found.""" + resolver = _dnssec_resolver() + ctx = resolver._new_context() + + def query_once(qname, rdtype, server, payload, timeout, ctx_): + return make_response(aa=False) if server == "9.9.9.9" else _signed_dnskey_response() + + with ( + patch.object(resolver, "_order_servers", side_effect=lambda s: list(s)), + patch.object(resolver, "_query_once", side_effect=query_once), + patch.object(resolver._validator, "validate_dnskey", return_value=ValidationState.SECURE), + ): + keys = resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9", "1.1.1.1"], ctx) + assert keys.state is ValidationState.SECURE def test_no_ds_for_a_non_root_zone_is_bogus(self) -> None: """Without a DS there is nothing to anchor the zone's keys to.""" resolver = _dnssec_resolver() ctx = resolver._new_context() - response = make_response(answer=[("example.com.", 300, "DNSKEY", ["257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WS"])]) - with patch.object(resolver, "_send_query", return_value=(response, "9.9.9.9")): + with patch.object(resolver, "_send_query", return_value=(_signed_dnskey_response(), "9.9.9.9")): keys = resolver._get_zone_keys(EXAMPLE, None, ["9.9.9.9"], ctx) assert keys.state is ValidationState.BOGUS @@ -108,14 +156,12 @@ def test_unusable_parent_keys_abort(self) -> None: def test_unfetchable_ds_is_a_resolution_failure(self) -> None: """Not BOGUS: see TestIndeterminateVsBogus for the reasoning.""" - from recursive_resolver import ResolutionTimeoutError - resolver = _dnssec_resolver() ctx = resolver._new_context() with ( patch.object(resolver, "_get_zone_keys", return_value=_keys(COM)), patch.object(resolver, "_send_query", return_value=(None, "")), - pytest.raises(ResolutionTimeoutError), + pytest.raises(DNSSECMaterialUnavailableError), ): resolver._descend_chain(EXAMPLE, COM, SENTINEL_DS, ["9.9.9.9"], ctx) @@ -130,13 +176,55 @@ def test_insecure_intermediate_stops_the_descent(self) -> None: zone, ds, state = resolver._descend_chain(EXAMPLE, COM, SENTINEL_DS, ["9.9.9.9"], ctx) assert (zone, ds, state) == (EXAMPLE, None, ValidationState.INSECURE) + def test_a_label_that_is_not_a_cut_is_skipped(self) -> None: + """`_dmarc.example.com` and `3.200.in-addr.arpa` are not delegations. + + The parent denies the DS with a proof that matches no delegation, and + the walk has to carry on with the same zone rather than call the chain + broken. + """ + resolver = _dnssec_resolver() + ctx = resolver._new_context() + middle = dns.name.from_text("b.example.com.") + asked: list[str] = [] + + def validate_ds(zone, records, keyring, budget=None): + asked.append(str(zone)) + if zone == middle: + return ValidationState.BOGUS, None + return ValidationState.SECURE, SENTINEL_DS + + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(COM)), + patch.object(resolver, "_send_query", return_value=(make_response(), "9.9.9.9")), + patch.object(resolver._validator, "validate_ds", side_effect=validate_ds), + patch.object( + resolver._validator, "prove_no_delegation", side_effect=lambda n, r, k, budget=None: n == middle + ), + ): + zone, _ds, state = resolver._descend_chain(DEEP, COM, SENTINEL_DS, ["9.9.9.9"], ctx) + assert (zone, state) == (DEEP, ValidationState.SECURE) + assert asked == ["example.com.", "b.example.com.", "a.b.example.com."] + + def test_a_broken_intermediate_that_is_a_cut_still_stops_the_descent(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(COM)), + patch.object(resolver, "_send_query", return_value=(make_response(), "9.9.9.9")), + patch.object(resolver._validator, "validate_ds", return_value=(ValidationState.BOGUS, None)), + patch.object(resolver._validator, "prove_no_delegation", return_value=False), + ): + zone, ds, state = resolver._descend_chain(DEEP, COM, SENTINEL_DS, ["9.9.9.9"], ctx) + assert (zone, ds, state) == (EXAMPLE, None, ValidationState.BOGUS) + def test_multi_label_descent_walks_one_label_at_a_time(self) -> None: """uk. -> co.uk. -> bbc.co.uk. when one server serves several zones.""" resolver = _dnssec_resolver() ctx = resolver._new_context() visited: list[str] = [] - def send(zone, rdtype, nameservers, ctx_): + def send(zone, rdtype, nameservers, ctx_, usable=None): visited.append(str(zone)) return make_response(), "9.9.9.9" @@ -150,6 +238,28 @@ def send(zone, rdtype, nameservers, ctx_): assert visited == ["example.com.", "b.example.com.", "a.b.example.com."] +def _referral_with_a_denial(child: dns.name.Name = EXAMPLE) -> dns.message.Message: + """A referral of the shape a signed parent sends: NS plus an NSEC3. + + ``_advance_dnssec`` walks the delegations when the referral carries + neither, so tests aimed at anything else start from this one. The NS is + owned by ``child`` because a referral to a cut above the queried name is + not an answer about it. + """ + return make_response( + authority=[ + (str(child), 300, "NS", ["ns.child.example."]), + ("0" * 32 + ".com.", 300, "NSEC3", ["1 1 0 - " + "V" * 32 + " NS"]), + ], + aa=False, + ) + + +def _bare_referral() -> dns.message.Message: + """A delegation with no DS and no denial of one: says nothing about DNSSEC.""" + return make_response(authority=[("example.com.", 300, "NS", ["ns.child.example."])], aa=False) + + class TestAdvanceDNSSEC: def test_insecure_parent_stays_insecure(self) -> None: resolver = _dnssec_resolver() @@ -167,7 +277,7 @@ def test_alignment_to_an_insecure_zone(self) -> None: patch.object(resolver, "_descend_chain", return_value=(EXAMPLE, None, ValidationState.INSECURE)), ): state, ds = resolver._advance_dnssec( - make_response(), DEEP, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx + _referral_with_a_denial(DEEP), DEEP, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx ) assert (state, ds) == (ValidationState.INSECURE, None) @@ -179,7 +289,9 @@ def test_alignment_to_a_bogus_zone_raises(self) -> None: patch.object(resolver, "_descend_chain", return_value=(EXAMPLE, None, ValidationState.BOGUS)), pytest.raises(DNSSECValidationError, match="broken chain"), ): - resolver._advance_dnssec(make_response(), DEEP, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx) + resolver._advance_dnssec( + _referral_with_a_denial(DEEP), DEEP, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx + ) def test_child_equal_to_the_aligned_parent_is_already_proven(self) -> None: resolver = _dnssec_resolver() @@ -189,7 +301,7 @@ def test_child_equal_to_the_aligned_parent_is_already_proven(self) -> None: patch.object(resolver, "_descend_chain", return_value=(EXAMPLE, SENTINEL_DS, ValidationState.SECURE)), ): state, ds = resolver._advance_dnssec( - make_response(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx + _referral_with_a_denial(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx ) assert (state, ds) == (ValidationState.SECURE, SENTINEL_DS) @@ -200,9 +312,12 @@ def test_unusable_parent_keys_raise(self) -> None: patch.object(resolver, "_get_zone_keys", return_value=_keys(COM, ValidationState.BOGUS)), pytest.raises(DNSSECValidationError, match="cannot establish DNSKEY"), ): - resolver._advance_dnssec(make_response(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx) + resolver._advance_dnssec( + _referral_with_a_denial(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx + ) def test_bogus_ds_raises(self) -> None: + """The proof was there and did not hold up: that is a real BOGUS.""" resolver = _dnssec_resolver() ctx = resolver._new_context() with ( @@ -210,7 +325,61 @@ def test_bogus_ds_raises(self) -> None: patch.object(resolver._validator, "validate_ds", return_value=(ValidationState.BOGUS, None)), pytest.raises(DNSSECValidationError, match="neither signed nor provably unsigned"), ): - resolver._advance_dnssec(make_response(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx) + resolver._advance_dnssec( + _referral_with_a_denial(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx + ) + + def test_a_referral_without_a_denial_walks_the_delegations(self) -> None: + """Some servers in a signed parent's NS set send a bare delegation. + + There is no proof to judge and no signature to align on, so the cuts + between parent and child are walked instead, and what that walk finds + is what settles the delegation. + """ + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_descend_chain", return_value=(EXAMPLE, None, ValidationState.INSECURE)) as descend, + patch.object(resolver, "_get_zone_keys") as keys, + patch.object(resolver._validator, "validate_ds") as validate, + ): + state, ds = resolver._advance_dnssec( + _bare_referral(), DEEP, COM, ["9.9.9.9"], ValidationState.SECURE, SENTINEL_DS, ctx + ) + assert (state, ds) == (ValidationState.INSECURE, None) + assert descend.call_args.args[:3] == (DEEP, COM, SENTINEL_DS) + # Nothing was judged against the parent's keys: there was no proof. + keys.assert_not_called() + validate.assert_not_called() + + def test_a_bare_referral_whose_walk_reaches_the_child_is_secure(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with patch.object(resolver, "_descend_chain", return_value=(DEEP, SENTINEL_DS, ValidationState.SECURE)): + state, ds = resolver._advance_dnssec( + _bare_referral(), DEEP, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx + ) + assert (state, ds) == (ValidationState.SECURE, SENTINEL_DS) + + def test_a_bare_referral_whose_walk_goes_bogus_raises(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_descend_chain", return_value=(EXAMPLE, None, ValidationState.BOGUS)), + pytest.raises(DNSSECValidationError, match="neither signed nor provably unsigned"), + ): + resolver._advance_dnssec(_bare_referral(), DEEP, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx) + + def test_a_referral_without_a_denial_and_no_ds_anywhere_is_unavailable(self) -> None: + """The walk asks each NS set in turn; none of them answered.""" + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(COM)), + patch.object(resolver, "_send_query", return_value=(None, None)), + pytest.raises(DNSSECMaterialUnavailableError), + ): + resolver._advance_dnssec(_bare_referral(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx) def test_authority_signer_extraction(self) -> None: resolver = _dnssec_resolver() @@ -251,7 +420,7 @@ def test_insecure_chain_skips_validation(self) -> None: resolver = _dnssec_resolver() ctx = resolver._new_context() rrset, response = self._answer_with_sig() - state = resolver._validate_answer( + state, _ttl = resolver._validate_answer( response, rrset, EXAMPLE, dns.rdatatype.A, ctx, EXAMPLE, ["9.9.9.9"], ValidationState.INSECURE, None ) assert state is ValidationState.INSECURE @@ -278,7 +447,7 @@ def test_alignment_to_insecure_returns_insecure(self) -> None: ctx = resolver._new_context() rrset, response = self._answer_with_sig() with patch.object(resolver, "_align_zone", return_value=(EXAMPLE, None, ValidationState.INSECURE)): - state = resolver._validate_answer( + state, _ttl = resolver._validate_answer( response, rrset, EXAMPLE, dns.rdatatype.A, ctx, COM, ["9.9.9.9"], ValidationState.SECURE, None ) assert state is ValidationState.INSECURE @@ -411,9 +580,10 @@ def test_a_valid_proof_is_accepted(self, negative: str) -> None: resolver = _dnssec_resolver() ctx = resolver._new_context() prover = "prove_nxdomain" if negative == "nxdomain" else "prove_nodata" + proven = ValidationState.SECURE if negative == "nxdomain" else True with ( patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE)), - patch.object(resolver._validator, prover, return_value=True), + patch.object(resolver._validator, prover, return_value=proven), ): resolver._verify_denial( make_response(), @@ -437,44 +607,54 @@ class TestIndeterminateVsBogus: against live DNS exposed. """ - def test_unfetchable_dnskey_is_a_timeout_not_bogus(self) -> None: - from recursive_resolver import ResolutionTimeoutError - + def test_unfetchable_dnskey_is_not_bogus(self) -> None: resolver = _dnssec_resolver() ctx = resolver._new_context() with ( patch.object(resolver, "_send_query", return_value=(None, "")), - pytest.raises(ResolutionTimeoutError), + pytest.raises(DNSSECMaterialUnavailableError), ): resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) - def test_unfetchable_ds_is_a_timeout_not_bogus(self) -> None: - from recursive_resolver import ResolutionTimeoutError - + def test_unfetchable_ds_is_not_bogus(self) -> None: resolver = _dnssec_resolver() ctx = resolver._new_context() with ( patch.object(resolver, "_get_zone_keys", return_value=_keys(COM)), patch.object(resolver, "_send_query", return_value=(None, "")), - pytest.raises(ResolutionTimeoutError), + pytest.raises(DNSSECMaterialUnavailableError), ): resolver._descend_chain(EXAMPLE, COM, SENTINEL_DS, ["9.9.9.9"], ctx) + def test_the_unavailable_error_is_not_a_dnssec_verdict(self) -> None: + """Consumers branch on DNSSECError to mean "refuse to use this data".""" + from recursive_resolver import DNSSECError, ResolverError + + assert issubclass(DNSSECMaterialUnavailableError, ResolverError) + assert not issubclass(DNSSECMaterialUnavailableError, DNSSECError) + def test_a_retrievable_but_invalid_dnskey_is_still_bogus(self) -> None: - """The distinction is retrieval failure versus verification failure.""" + """The distinction is retrieval failure versus verification failure. + + Here the signed DNSKEY RRset arrives in full and simply does not match + the DS, which is the one thing BOGUS is for. + """ resolver = _dnssec_resolver() ctx = resolver._new_context() - response = make_response(answer=[("example.com.", 300, "DNSKEY", ["257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WS"])]) - with patch.object(resolver, "_send_query", return_value=(response, "9.9.9.9")): + with patch.object(resolver, "_send_query", return_value=(_signed_dnskey_response(), "9.9.9.9")): keys = resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) assert keys.state is ValidationState.BOGUS - def test_a_missing_dnskey_rrset_is_still_bogus(self) -> None: + def test_a_dnskey_arriving_without_its_rrsig_is_not_bogus(self) -> None: + """An OPT-stripping middlebox produces exactly this; the zone is fine.""" resolver = _dnssec_resolver() ctx = resolver._new_context() - with patch.object(resolver, "_send_query", return_value=(make_response(), "9.9.9.9")): - keys = resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) - assert keys.state is ValidationState.BOGUS + unsigned = make_response(answer=[("example.com.", 300, "DNSKEY", ["257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WS"])]) + with ( + patch.object(resolver, "_query_once", return_value=unsigned), + pytest.raises(DNSSECMaterialUnavailableError), + ): + resolver._get_zone_keys(EXAMPLE, SENTINEL_DS, ["9.9.9.9"], ctx) class TestUnverifiableZoneKeysPropagateAsInsecure: @@ -503,7 +683,7 @@ def test_a_ds_lookup_under_unverifiable_parent_keys_is_insecure(self) -> None: patch.object(resolver, "_descend_chain", return_value=(COM, SENTINEL_DS, ValidationState.SECURE)), ): state, ds = resolver._advance_dnssec( - make_response(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx + _referral_with_a_denial(), EXAMPLE, COM, ["9.9.9.9"], ValidationState.SECURE, None, ctx ) assert state is ValidationState.INSECURE assert ds is None @@ -532,3 +712,399 @@ def test_a_matched_key_with_an_unverifiable_algorithm_is_insecure(self) -> None: ds_rdataset.add(dns.dnssec.make_ds(zone, key, "SHA256"), ttl=300) assert DNSSECValidator().validate_dnskey(zone, keys, None, ds_rdataset) is ValidationState.INSECURE + + +class TestMaterialPredicates: + """What counts as an answer to "give me this zone's validation material". + + These decide whether the sweep moves on to the next nameserver, so each + rejection reason is driven explicitly: a predicate that quietly accepts a + useless response puts the old false-BOGUS behaviour straight back. + """ + + def test_an_error_rcode_is_not_a_dnskey_answer(self) -> None: + resolver = _dnssec_resolver() + assert not resolver._usable_dnskey(EXAMPLE)(make_response(rcode=dns.rcode.SERVFAIL, aa=False)) + + def test_an_authoritative_answer_without_the_dnskey_is_not_one_either(self) -> None: + resolver = _dnssec_resolver() + assert not resolver._usable_dnskey(EXAMPLE)(make_response()) + + def test_a_signed_dnskey_answer_is_accepted(self) -> None: + resolver = _dnssec_resolver() + assert resolver._usable_dnskey(EXAMPLE)(_signed_dnskey_response()) + + def test_an_error_rcode_is_not_a_ds_answer(self) -> None: + resolver = _dnssec_resolver() + assert not resolver._usable_ds(EXAMPLE)(make_response(rcode=dns.rcode.REFUSED, aa=False)) + + def test_a_referral_carrying_the_ds_is_accepted_without_aa(self) -> None: + """A parent may answer a DS query with a referral; the DS is signed either way.""" + resolver = _dnssec_resolver() + response = make_response(aa=False) + response.authority.append(_ds_rrset_at(EXAMPLE)) + assert resolver._usable_ds(EXAMPLE)(response) + + def test_a_bare_nodata_from_a_non_authoritative_server_is_refused(self) -> None: + resolver = _dnssec_resolver() + response = make_response( + authority=[("example.com.", 300, "SOA", ["ns.example.com. a.example.com. 1 2 3 4 5"])], aa=False + ) + assert not resolver._usable_ds(EXAMPLE)(response) + + def test_an_authoritative_soa_without_a_denial_is_refused(self) -> None: + """The DS is only ever asked of a signed parent, which signs its denials. + + A server sending the SOA alone cannot settle the delegation, so the + sweep has to move on rather than hand the validator half a proof. + """ + resolver = _dnssec_resolver() + response = make_response(authority=[("example.com.", 300, "SOA", ["ns.example.com. a.example.com. 1 2 3 4 5"])]) + assert not resolver._usable_ds(EXAMPLE)(response) + + def test_an_authoritative_soa_with_an_nsec_denial_is_accepted(self) -> None: + resolver = _dnssec_resolver() + response = make_response( + authority=[ + ("example.com.", 300, "SOA", ["ns.example.com. a.example.com. 1 2 3 4 5"]), + ("example.com.", 300, "NSEC", ["a.example.com. NS SOA RRSIG NSEC DNSKEY"]), + ] + ) + assert resolver._usable_ds(EXAMPLE)(response) + + def test_an_authoritative_soa_with_an_nsec3_denial_is_accepted(self) -> None: + resolver = _dnssec_resolver() + response = make_response( + authority=[ + ("example.com.", 300, "SOA", ["ns.example.com. a.example.com. 1 2 3 4 5"]), + ("0" * 32 + ".example.com.", 300, "NSEC3", ["1 1 0 - " + "V" * 32 + " NS"]), + ] + ) + assert resolver._usable_ds(EXAMPLE)(response) + + def test_a_referral_to_a_cut_above_the_queried_name_is_refused(self) -> None: + """ "Ask further down" is not an answer, however well signed it is. + + The NSEC belongs to the higher cut and denies nothing about the name + that was asked for, so the sweep has to reach a server authoritative + for the right zone instead of judging the delegation on this. + """ + resolver = _dnssec_resolver() + response = make_response( + authority=[ + ("example.com.", 300, "NS", ["ns.example.com."]), + ("example.com.", 300, "NSEC", ["a.example.com. NS RRSIG NSEC"]), + ], + aa=False, + ) + assert not resolver._usable_ds(DEEP)(response) + # The same records do settle the cut they actually belong to. + assert resolver._usable_ds(EXAMPLE)(response) + + def test_a_referral_carrying_the_denial_is_accepted_without_aa(self) -> None: + """The standard "no DS" answer from several ccTLDs: a referral plus NSEC3. + + AA is clear because the parent is handing the query on, but the NSEC3 + is signed by the parent and re-checked by the validator either way. + """ + resolver = _dnssec_resolver() + response = make_response( + authority=[ + ("example.com.", 300, "NS", ["ns.child.example."]), + ("0" * 32 + ".example.com.", 300, "NSEC3", ["1 1 0 - " + "V" * 32 + " NS"]), + ], + aa=False, + ) + assert resolver._usable_ds(EXAMPLE)(response) + + +def _ds_rrset_at(name: dns.name.Name) -> dns.rrset.RRset: + rrset = dns.rrset.RRset(name, dns.rdataclass.IN, dns.rdatatype.DS) + rrset.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, "12345 8 2 " + "ab" * 32)) + rrset.ttl = 300 + return rrset + + +class TestDenialUnderUnverifiableKeys: + def test_insecure_zone_keys_skip_the_denial_proof(self) -> None: + """A zone we cannot verify is unsigned, so there is no proof to demand. + + The signer is present, so the delegation walk does not apply and the + keys themselves are what settles it. + """ + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_authority_signer", return_value=EXAMPLE), + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE, ValidationState.INSECURE)), + ): + resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + negative="nxdomain", + ) + + +class TestRequireDNSSECCoversDenials: + """`require_dnssec` must hold "no such name" to the standard of an answer. + + A negative answer leaves through an exception rather than an `Answer`, so + the check on the returned value never sees it. Being told a name has no CAA + or no MX record, on unauthenticated evidence, is exactly what the strict + mode exists to refuse. + """ + + def test_an_unproven_denial_is_refused_when_dnssec_is_required(self) -> None: + resolver = _dnssec_resolver(require_dnssec=True) + with pytest.raises(DNSSECInsecureError): + resolver._require_proven_denial(ValidationState.INSECURE, EXAMPLE, "A") + + def test_a_proven_denial_passes(self) -> None: + resolver = _dnssec_resolver(require_dnssec=True) + resolver._require_proven_denial(ValidationState.SECURE, EXAMPLE, "A") + + def test_without_the_flag_an_unproven_denial_is_allowed(self) -> None: + resolver = _dnssec_resolver() + resolver._require_proven_denial(ValidationState.INSECURE, EXAMPLE, "A") + + def test_a_denial_in_an_insecure_zone_reports_insecure(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + state, _ttl = resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + COM, + ["9.9.9.9"], + ValidationState.INSECURE, + None, + negative="nxdomain", + ) + assert state is ValidationState.INSECURE + + def test_an_opt_out_nxdomain_is_returned_but_not_authenticated(self) -> None: + """The whole point of the tri-state reaching the caller.""" + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE)), + patch.object(resolver._validator, "prove_nxdomain", return_value=ValidationState.INSECURE), + ): + state, _ttl = resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + negative="nxdomain", + ) + assert state is ValidationState.INSECURE + + +class TestUnverifiableNSEC3ParametersDowngrade: + """A denial or wildcard proof we cannot compute is unauthenticated, not forged.""" + + def test_a_denial_with_parameters_beyond_the_cap_is_insecure(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE)), + patch.object(resolver._validator, "nsec3_beyond_our_limits", return_value=True), + patch.object(resolver._validator, "prove_nxdomain") as prove, + ): + state, _ttl = resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + negative="nxdomain", + ) + assert state is ValidationState.INSECURE + prove.assert_not_called() + + def test_a_wildcard_answer_we_cannot_prove_is_insecure(self) -> None: + resolver = _dnssec_resolver() + ctx = resolver._new_context() + rrset = dns.rrset.from_text("a.b.example.com.", 300, "IN", "A", "1.2.3.4") + response = make_response() + response.answer = [ + rrset, + dns.rrset.from_text( + "a.b.example.com.", + 300, + "IN", + "RRSIG", + "A 8 2 300 20990101000000 20200101000000 1 example.com. AAAA", + ), + ] + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE)), + patch.object(resolver._validator, "validated_rrsig", return_value=response.answer[1][0]), + patch.object(resolver._validator, "prove_wildcard", return_value=ValidationState.BOGUS), + patch.object(resolver._validator, "nsec3_beyond_our_limits", return_value=True), + ): + state, _ttl = resolver._validate_answer( + response, + rrset, + rrset.name, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + ) + assert state is ValidationState.INSECURE + + +class TestADenialThatDoesNotProveIsRetriedElsewhere: + """RFC 4035 §5.5: try another server before concluding an answer is forged. + + One server out of sync with the rest of its NS set - serving an NSEC3 + chain from before the last re-signing, say - would otherwise make the zone + intermittently unresolvable on the say-so of the one machine that is wrong. + Found in the wild on a signed TLD whose seven nameservers included one + stale one. + """ + + def _resolver_with_keys(self): + resolver = _dnssec_resolver() + return resolver, resolver._new_context() + + def test_a_failed_proof_sweeps_to_another_server(self) -> None: + resolver, ctx = self._resolver_with_keys() + proofs = iter([ValidationState.BOGUS, ValidationState.SECURE]) + asked: list[str] = [] + + def prove(*args, **kwargs): + return next(proofs, ValidationState.SECURE) + + def send(qname, rdtype, nameservers, ctx_, usable=None): + asked.append("resent") + return make_response(), "1.1.1.1" + + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE)), + patch.object(resolver._validator, "prove_nxdomain", side_effect=prove), + patch.object(resolver, "_send_query", side_effect=send), + ): + state, _ttl = resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + negative="nxdomain", + ) + assert state is ValidationState.SECURE + assert asked == ["resent"], "the second server was never asked" + + def test_a_zone_where_no_server_can_prove_it_is_still_bogus(self) -> None: + """Sweeping must not turn a broken zone into a pass.""" + resolver, ctx = self._resolver_with_keys() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE)), + patch.object(resolver._validator, "prove_nxdomain", return_value=ValidationState.BOGUS), + patch.object(resolver, "_send_query", return_value=(None, None)), + pytest.raises(DNSSECValidationError, match="unproven nxdomain"), + ): + resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + negative="nxdomain", + ) + + def test_a_fresh_response_that_also_fails_is_bogus(self) -> None: + resolver, ctx = self._resolver_with_keys() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE)), + patch.object(resolver._validator, "prove_nodata", return_value=ValidationState.BOGUS), + patch.object(resolver, "_send_query", return_value=(make_response(), "1.1.1.1")), + pytest.raises(DNSSECValidationError, match="unproven nodata"), + ): + resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + negative="nodata", + ) + + def test_the_sweep_predicate_rejects_an_error_rcode(self) -> None: + resolver, ctx = self._resolver_with_keys() + check = resolver._usable_denial(EXAMPLE, dns.rdatatype.A, {}, ctx, "nodata") + assert check(make_response(rcode=dns.rcode.SERVFAIL, aa=False)) is False + + def test_the_sweep_predicate_accepts_a_denial_that_proves(self) -> None: + resolver, ctx = self._resolver_with_keys() + with patch.object(resolver._validator, "prove_nodata", return_value=ValidationState.SECURE): + check = resolver._usable_denial(EXAMPLE, dns.rdatatype.A, {}, ctx, "nodata") + assert check(make_response()) is True + + def test_the_sweep_predicate_rejects_a_non_authoritative_denial(self) -> None: + """The sweep must not accept what `_classify_response` would have refused. + + A cache or a parent-side server can hand back a proof-shaped answer with + AA clear; taking it here would let the sweep launder it into a verdict. + """ + resolver, ctx = self._resolver_with_keys() + assert resolver.require_authoritative + with patch.object(resolver._validator, "prove_nodata", return_value=ValidationState.SECURE): + check = resolver._usable_denial(EXAMPLE, dns.rdatatype.A, {}, ctx, "nodata") + assert check(make_response(aa=False)) is False + assert check(make_response()) is True + + def test_the_sweep_predicate_allows_it_when_authority_is_not_required(self) -> None: + resolver, ctx = self._resolver_with_keys() + resolver.require_authoritative = False + with patch.object(resolver._validator, "prove_nodata", return_value=ValidationState.SECURE): + check = resolver._usable_denial(EXAMPLE, dns.rdatatype.A, {}, ctx, "nodata") + assert check(make_response(aa=False)) is True + + def test_an_opt_out_nxdomain_is_accepted_without_sweeping(self) -> None: + """INSECURE is an outcome, not a failure, so it must not trigger a retry.""" + resolver, ctx = self._resolver_with_keys() + with ( + patch.object(resolver, "_get_zone_keys", return_value=_keys(EXAMPLE)), + patch.object(resolver._validator, "prove_nxdomain", return_value=ValidationState.INSECURE), + patch.object(resolver, "_send_query", side_effect=AssertionError("should not resend")), + ): + state, _ttl = resolver._verify_denial( + make_response(), + EXAMPLE, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + SENTINEL_DS, + negative="nxdomain", + ) + assert state is ValidationState.INSECURE diff --git a/tests/test_dnssec_signed.py b/tests/test_dnssec_signed.py index c776fd0..863cfe5 100644 --- a/tests/test_dnssec_signed.py +++ b/tests/test_dnssec_signed.py @@ -7,8 +7,10 @@ from __future__ import annotations +import time from unittest.mock import patch +import dns.dnssec import dns.name import dns.rcode import dns.rdataclass @@ -16,21 +18,29 @@ import dns.rrset import pytest from conftest import make_response -from signed_zone import SignedZone, rrset_of +from signed_zone import EXPIRATION, SignedZone, rrset_of from recursive_resolver import ( DNSSECInsecureError, + DNSSECMaterialUnavailableError, DNSSECValidationError, + NoAnswerError, NXDOMAINError, RecursiveResolver, ValidationState, ) from recursive_resolver.budget import QueryBudget -from recursive_resolver.dnssec import DNSSECValidator +from recursive_resolver.cache import DNSCache +from recursive_resolver.dnssec import DNSSECValidator, ZoneKeys EXAMPLE = dns.name.from_text("example.test.") +def _zone_keys(zone: SignedZone) -> ZoneKeys: + """The validated-key record the resolver would have built for this zone.""" + return ZoneKeys(zone.name, zone.dnskey_rrset, ValidationState.SECURE) + + @pytest.fixture(scope="module") def zone() -> SignedZone: return SignedZone.create("example.test.") @@ -41,6 +51,309 @@ def parent() -> SignedZone: return SignedZone.create("test.") +class TestAuthenticatedTTLIsCappedBySignatureValidity: + """RFC 4035 §5.3.3: authenticated data must not outlive its signature. + + The TTL must be no greater than the minimum of the RRset's TTL, the RRSIG's + TTL, the RRSIG's Original TTL, and the time left before the signature + expires. The last term is the one that matters: a record with a day-long + TTL signed with a short-lived RRSIG would otherwise be served as + authenticated long after anything vouched for it. + """ + + @staticmethod + def _signed_for(zone: SignedZone, rrset: dns.rrset.RRset, seconds: int) -> dns.rrset.RRset: + now = int(time.time()) + rrsig = dns.dnssec.sign( + rrset, zone.private_key, zone.name, zone.dnskey, inception=now - 10, expiration=now + seconds + ) + out = dns.rrset.RRset(rrset.name, dns.rdataclass.IN, dns.rdatatype.RRSIG) + out.add(rrsig) + out.ttl = rrset.ttl + return out + + def test_the_cap_follows_the_signature_that_actually_verified(self, zone: SignedZone) -> None: + data = rrset_of("example.test.", "A", "192.0.2.1", ttl=86400) + sig = self._signed_for(zone, data, 30) + signature = DNSSECValidator().validated_rrsig(data, sig, zone.keyring()) + assert signature is not None + cap = min(data.ttl, sig.ttl, int(signature.original_ttl), int(signature.expiration) - int(time.time())) + assert cap <= 30, "a day-long TTL survived a 30-second signature" + + def test_a_long_lived_signature_does_not_shorten_the_ttl(self, zone: SignedZone) -> None: + data = rrset_of("example.test.", "A", "192.0.2.1", ttl=300) + signature = DNSSECValidator().validated_rrsig(data, zone.sign(data), zone.keyring()) + assert signature is not None + cap = min(data.ttl, int(signature.original_ttl), int(signature.expiration) - int(time.time())) + assert cap == 300 + + def test_the_cache_floor_does_not_lift_an_authenticated_ttl(self) -> None: + """`min_ttl` keeps a warm cache warm; it must not outlive a signature.""" + cache = DNSCache(min_ttl=3600) + data = rrset_of("example.test.", "A", "192.0.2.1", ttl=86400) + cache.put_answer("example.test.", "A", data, 30, secure=True) + entry = cache.get_answer("example.test.", "A") + assert entry is not None + assert entry.expiry - time.monotonic() <= 31 + + def test_the_cache_floor_still_applies_to_unauthenticated_data(self) -> None: + cache = DNSCache(min_ttl=3600) + data = rrset_of("example.test.", "A", "192.0.2.1", ttl=10) + cache.put_answer("example.test.", "A", data, 10, secure=False) + entry = cache.get_answer("example.test.", "A") + assert entry is not None + assert entry.expiry - time.monotonic() > 3000 + + +class TestTheValidatedSignatureIsWhatCounts: + """Every decision must come from the RRSIG that verified, not the first one. + + An RRSIG RRset can carry several rdata, in an order the attacker chooses, + and only one of them verified. Reading a field off `rrsig[0]` therefore + reads attacker-controlled data. Two separate checks here used to do that. + """ + + @staticmethod + def _with_decoy(genuine: dns.rrset.RRset, owner: dns.name.Name, labels: str) -> dns.rrset.RRset: + """`genuine`, preceded by a copy whose Labels field has been rewritten.""" + text = genuine[0].to_text().split() + text[2] = labels + out = dns.rrset.RRset(owner, dns.rdataclass.IN, dns.rdatatype.RRSIG) + out.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.RRSIG, " ".join(text))) + for rr in genuine: + out.add(rr) + out.ttl = genuine.ttl + return out + + def test_a_decoy_rrsig_does_not_hide_a_wildcard_expansion(self, zone: SignedZone) -> None: + """RFC 4035 §5.3.4 keys off "the covering RRSIG RR" - the one that verified. + + A wildcard's signature verifies for every name the wildcard could + cover, so a replayed wildcard record needs a proof that no closer match + exists. A decoy claiming a larger Labels count would otherwise make the + answer look ordinary and skip that proof. + """ + wild = rrset_of("*.example.test.", "A", "6.6.6.6") + genuine = zone.sign(wild) + replayed = rrset_of("evil.example.test.", "A", "6.6.6.6") + validator = DNSSECValidator() + + # The replay verifies: that is the property the proof exists to contain. + assert validator.validate_rrset(replayed, genuine, zone.keyring()) is True + + tampered = self._with_decoy(genuine, replayed.name, "3") + assert tampered[0].labels == 3, "the decoy must sort first" + signature = validator.validated_rrsig(replayed, tampered, zone.keyring()) + assert signature is not None + assert signature.labels == 2, "the signature that verified is the wildcard's" + owner_labels = len(replayed.name.labels) - 1 + assert signature.labels < owner_labels, "so a wildcard proof is required" + assert not tampered[0].labels < owner_labels, "while the decoy would have skipped it" + + def test_the_resolver_still_demands_the_proof_end_to_end(self, zone: SignedZone) -> None: + wild = rrset_of("*.example.test.", "A", "6.6.6.6") + genuine = zone.sign(wild) + replayed = rrset_of("evil.example.test.", "A", "6.6.6.6") + tampered = self._with_decoy(genuine, replayed.name, "3") + + resolver = RecursiveResolver(dnssec=True, cache_enabled=False) + ctx = resolver._new_context() + response = make_response() + response.answer = [replayed, tampered] + response.authority = [] + with ( + patch.object(resolver, "_get_zone_keys", return_value=_zone_keys(zone)), + pytest.raises(DNSSECValidationError, match="wildcard-expanded"), + ): + resolver._validate_answer( + response, + replayed, + replayed.name, + dns.rdatatype.A, + ctx, + EXAMPLE, + ["9.9.9.9"], + ValidationState.SECURE, + None, + ) + + +class TestTheSignerMustContainTheOwner: + """RFC 4035 §5.3.1: the signer must be the zone that contains the RRset. + + Nothing else in the stack enforces this. dnspython checks the RRSIG's label + count and looks the signer up in the keyring, but never relates the signer + to the owner name, so without this check any zone can sign for any name. + Owning a single signed zone would be enough to authenticate forged data for + every domain on the internet. + """ + + def test_a_zone_cannot_sign_for_a_name_it_does_not_contain(self) -> None: + attacker = SignedZone.create("evil.example.") + forged = rrset_of("www.victim.test.", "A", "6.6.6.6") + # A genuine signature, made by a key the attacker really owns. + assert DNSSECValidator().validate_rrset(forged, attacker.sign(forged), attacker.keyring()) is False + + def test_a_zone_can_still_sign_for_names_it_does_contain(self) -> None: + zone = SignedZone.create("evil.example.") + own = rrset_of("www.evil.example.", "A", "1.2.3.4") + assert DNSSECValidator().validate_rrset(own, zone.sign(own), zone.keyring()) is True + + def test_the_apex_signs_for_itself(self) -> None: + zone = SignedZone.create("example.test.") + apex = rrset_of("example.test.", "A", "192.0.2.1") + assert DNSSECValidator().validate_rrset(apex, zone.sign(apex), zone.keyring()) is True + + def test_a_parent_signs_the_ds_of_its_child(self) -> None: + """The DS lives in the parent but is owned by the child's name.""" + parent = SignedZone.create("test.") + child = SignedZone.create("example.test.") + ds = parent.ds_rrset(child) + assert DNSSECValidator().validate_rrset(ds, parent.sign(ds), parent.keyring()) is True + + def test_the_resolver_rejects_a_cross_zone_forgery_end_to_end(self) -> None: + """The whole attack, through the public API, with require_dnssec on. + + A root, a zone the attacker controls and can get keys served for, + and one spoofed packet carrying the victim's name signed by that zone. + """ + root = SignedZone.create(".") + attacker = SignedZone.create("evil.") + qname = dns.name.from_text("www.victim.") + forged = rrset_of("www.victim.", "A", "6.6.6.6") + + def reply(answer): + message = make_response() + message.answer = list(answer) + message.authority = [] + return message + + def send(qname_, rdtype, nameservers, ctx, usable=None): + if rdtype == dns.rdatatype.DNSKEY: + z = root if qname_ == dns.name.root else attacker + return reply([z.dnskey_rrset, z.signed_dnskey()]), "198.41.0.4" + if rdtype == dns.rdatatype.DS: + ds = root.ds_rrset(attacker) + return reply([ds, root.sign(ds)]), "198.41.0.4" + return reply([forged, attacker.sign(forged)]), "198.41.0.4" + + resolver = RecursiveResolver( + dnssec=True, require_dnssec=True, cache_enabled=False, trust_anchors=(root.anchor_text(),) + ) + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(DNSSECValidationError): + resolver.resolve_answer(str(qname), "A") + + +class TestTheDSMatchedKeyMustSign: + """RFC 4035 §5.2: the key the DS matched must have signed the DNSKEY RRset. + + The DS is the only link the parent provides. If any key in the published + RRset can supply the self-signature instead, then compromising a + zone-signing key - shorter-lived, rotated more often, and not the key a + registry protects - is enough to publish a new key and have the whole zone + validate, with the real KSK sitting untouched in the same RRset satisfying + the DS match. + """ + + @staticmethod + def _published(*zones: SignedZone) -> dns.rrset.RRset: + rrset = dns.rrset.RRset(zones[0].name, dns.rdataclass.IN, dns.rdatatype.DNSKEY) + for z in zones: + rrset.add(z.dnskey) + rrset.ttl = 3600 + return rrset + + @staticmethod + def _signed_by(signer: SignedZone, rrset: dns.rrset.RRset) -> dns.rrset.RRset: + """The RRSIG ``signer`` would make over ``rrset``, using its own key.""" + return SignedZone( + name=signer.name, private_key=signer.private_key, dnskey=signer.dnskey, dnskey_rrset=rrset + ).sign(rrset) + + def test_a_dnskey_rrset_signed_only_by_a_non_ds_key_is_bogus(self, parent: SignedZone) -> None: + ksk = SignedZone.create("example.test.") + zsk = SignedZone.create("example.test.") + intruder = SignedZone.create("example.test.") + published = self._published(ksk, zsk, intruder) + state = DNSSECValidator().validate_dnskey( + ksk.name, published, self._signed_by(zsk, published), parent.ds_rdataset(ksk) + ) + assert state is ValidationState.BOGUS + + def test_the_same_rrset_signed_by_the_ds_matched_key_is_secure(self, parent: SignedZone) -> None: + """Only the signer changes, so that is what the verdict turns on.""" + ksk = SignedZone.create("example.test.") + zsk = SignedZone.create("example.test.") + published = self._published(ksk, zsk) + state = DNSSECValidator().validate_dnskey( + ksk.name, published, self._signed_by(ksk, published), parent.ds_rdataset(ksk) + ) + assert state is ValidationState.SECURE + + def test_any_one_of_several_ds_matched_keys_may_sign(self, parent: SignedZone) -> None: + """Two DS, two entry points: either signature is a valid path. + + RFC 6840 §5.11 - a validator should accept any single valid path, not + insist that every advertised one works. + """ + first = SignedZone.create("example.test.") + second = SignedZone.create("example.test.") + published = self._published(first, second) + ds = parent.ds_rdataset(first) + for rr in parent.ds_rdataset(second): + ds.add(rr, ttl=3600) + state = DNSSECValidator().validate_dnskey(first.name, published, self._signed_by(second, published), ds) + assert state is ValidationState.SECURE + + +class TestRevokedKeys: + """RFC 5011 §2.1: a revoked key must not be used for anything. + + Revocation is how the owner of a compromised key withdraws it. The private + key still works, so every signature it makes still verifies; the only thing + standing between an attacker holding that key and a validator trusting it + is the REVOKE bit. These use real keys and real signatures, so a validator + that ignores the bit passes them all. + """ + + def test_a_revoked_key_does_not_validate_data(self, zone: SignedZone) -> None: + revoked = zone.revoked() + data = rrset_of("example.test.", "A", "192.0.2.1") + # The signature is genuine: the same private key made it. + assert DNSSECValidator().validate_rrset(data, revoked.sign(data), revoked.keyring()) is False + + def test_the_same_key_before_revocation_does_validate(self, zone: SignedZone) -> None: + """The bit is what changes the answer, not the key material.""" + data = rrset_of("example.test.", "A", "192.0.2.1") + assert DNSSECValidator().validate_rrset(data, zone.sign(data), zone.keyring()) is True + + def test_a_revoked_key_is_not_a_secure_entry_point(self, zone: SignedZone, parent: SignedZone) -> None: + """Even where the parent publishes a DS matching the revoked form.""" + revoked = zone.revoked() + state = DNSSECValidator().validate_dnskey( + revoked.name, revoked.dnskey_rrset, revoked.signed_dnskey(), parent.ds_rdataset(revoked) + ) + assert state is ValidationState.BOGUS + + def test_a_revoked_key_cannot_anchor_the_root(self, zone: SignedZone) -> None: + revoked = zone.revoked() + validator = DNSSECValidator(trust_anchors=(revoked.anchor_text(),)) + assert validator.validate_root_dnskey(revoked.dnskey_rrset, revoked.signed_dnskey()) is False + + def test_a_live_sibling_key_still_works_alongside_a_revoked_one(self, parent: SignedZone) -> None: + """Revoking one key must not take the rest of the DNSKEY RRset with it.""" + live = SignedZone.create("example.test.") + revoked = SignedZone.create("example.test.").revoked() + published = dns.rrset.RRset(live.name, dns.rdataclass.IN, dns.rdatatype.DNSKEY) + published.add(live.dnskey) + published.add(revoked.dnskey) + published.ttl = 3600 + data = rrset_of("example.test.", "A", "192.0.2.1") + keyring = {live.name: published} + assert DNSSECValidator().validate_rrset(data, live.sign(data), keyring) is True + assert DNSSECValidator().validate_rrset(data, revoked.sign(data), keyring) is False + + class TestSignatureValidation: def test_a_genuine_signature_validates(self, zone: SignedZone) -> None: data = rrset_of("example.test.", "A", "192.0.2.1") @@ -123,18 +436,61 @@ class TestSignedDenialOfExistence: def test_signed_nsec_proves_nodata(self, zone: SignedZone) -> None: nsec = rrset_of("example.test.", "NSEC", "next.test. A RRSIG NSEC") authority = [nsec, zone.sign(nsec)] - assert DNSSECValidator().prove_nodata(EXAMPLE, dns.rdatatype.MX, authority, zone.keyring()) is True + assert ( + DNSSECValidator().prove_nodata(EXAMPLE, dns.rdatatype.MX, authority, zone.keyring()) + is ValidationState.SECURE + ) def test_an_unsigned_nsec_proves_nothing(self, zone: SignedZone) -> None: nsec = rrset_of("example.test.", "NSEC", "next.test. A RRSIG NSEC") - assert DNSSECValidator().prove_nodata(EXAMPLE, dns.rdatatype.MX, [nsec], zone.keyring()) is False + assert ( + DNSSECValidator().prove_nodata(EXAMPLE, dns.rdatatype.MX, [nsec], zone.keyring()) is ValidationState.BOGUS + ) def test_signed_nsec_proves_nxdomain(self, zone: SignedZone) -> None: covering = rrset_of("a.example.test.", "NSEC", "c.example.test. A RRSIG NSEC") wildcard = rrset_of("!.example.test.", "NSEC", "+.example.test. A RRSIG NSEC") authority = [covering, zone.sign(covering), wildcard, zone.sign(wildcard)] target = dns.name.from_text("b.example.test.") - assert DNSSECValidator().prove_nxdomain(target, authority, zone.keyring()) is True + assert DNSSECValidator().prove_nxdomain(target, authority, zone.keyring()) is ValidationState.SECURE + + def test_an_empty_non_terminal_is_proven_by_the_nsec_that_jumps_over_it(self, zone: SignedZone) -> None: + """The name holds no records while a name below it does, so the zone's + NSEC chain steps straight over it and no NSEC matches it. + + The record before it points *below* it, which is what proves it is an + empty non-terminal and therefore has no records of any type (RFC 4035 + §3.1.3.4.1). Without this, every ENT in an NSEC-signed zone - and there + are many, including whole TLDs - is reported BOGUS. + """ + nsec = rrset_of("po.example.test.", "NSEC", "a.pp.example.test. A RRSIG NSEC") + authority = [nsec, zone.sign(nsec)] + qname = dns.name.from_text("pp.example.test.") + assert ( + DNSSECValidator().prove_nodata(qname, dns.rdatatype.A, authority, zone.keyring()) is ValidationState.SECURE + ) + + def test_a_covering_nsec_that_does_not_descend_proves_no_nodata(self, zone: SignedZone) -> None: + """The name is denied, not shown to be empty: that is NXDOMAIN, not NODATA.""" + nsec = rrset_of("po.example.test.", "NSEC", "pq.example.test. A RRSIG NSEC") + authority = [nsec, zone.sign(nsec)] + qname = dns.name.from_text("pp.example.test.") + assert ( + DNSSECValidator().prove_nodata(qname, dns.rdatatype.A, authority, zone.keyring()) is ValidationState.BOGUS + ) + + def test_an_nsec_pointing_at_the_name_itself_proves_nothing(self, zone: SignedZone) -> None: + """A name with records owns an NSEC, and its predecessor points at it. + + Accepting that as an empty-non-terminal proof would deny the records of + every name in the zone. + """ + nsec = rrset_of("po.example.test.", "NSEC", "pp.example.test. A RRSIG NSEC") + authority = [nsec, zone.sign(nsec)] + qname = dns.name.from_text("pp.example.test.") + assert ( + DNSSECValidator().prove_nodata(qname, dns.rdatatype.A, authority, zone.keyring()) is ValidationState.BOGUS + ) def test_signed_nsec_proving_no_ds(self, zone: SignedZone) -> None: nsec = rrset_of("child.example.test.", "NSEC", "next.example.test. NS RRSIG NSEC") @@ -193,7 +549,7 @@ def referral(zone_name: str, ds_rrset, signer: SignedZone): } state = {"step": 0} - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): key = (qname, rdtype) if key in table: return table[key], "9.9.9.9" @@ -246,7 +602,7 @@ def dnskey_response(zone: SignedZone): } state = {"step": 0} - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): if (qname, rdtype) in table: return table[(qname, rdtype)], "9.9.9.9" state["step"] += 1 @@ -283,7 +639,7 @@ def test_require_dnssec_rejects_an_unsigned_answer(self) -> None: ] step = {"i": 0} - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): i = step["i"] step["i"] += 1 return chain[i] if i < len(chain) else (None, "") @@ -326,28 +682,36 @@ def test_the_signature_alone_verifies_for_a_foreign_owner(self) -> None: def test_a_wildcard_answer_with_a_covering_proof_is_accepted(self) -> None: zone, _, wild_sig = self._zone_with_wildcard() qname = dns.name.from_text("anything.example.test.") - assert DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, self._covering_nsec(zone), zone.keyring()) + assert ( + DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, self._covering_nsec(zone), zone.keyring()) + is ValidationState.SECURE + ) def test_a_wildcard_answer_with_no_proof_at_all_is_refused(self) -> None: zone, _, wild_sig = self._zone_with_wildcard() qname = dns.name.from_text("anything.example.test.") - assert not DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, [], zone.keyring()) + assert DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, [], zone.keyring()) is ValidationState.BOGUS def test_an_unsigned_proof_proves_nothing(self) -> None: zone, _, wild_sig = self._zone_with_wildcard() nsec = rrset_of("example.test.", "NSEC", "z.example.test. A RRSIG NSEC") qname = dns.name.from_text("anything.example.test.") - assert not DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, [nsec], zone.keyring()) + assert ( + DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, [nsec], zone.keyring()) is ValidationState.BOGUS + ) def test_a_proof_that_does_not_cover_the_name_is_refused(self) -> None: """An NSEC from a different part of the zone must not satisfy the check.""" zone, _, wild_sig = self._zone_with_wildcard() nsec = rrset_of("aa.example.test.", "NSEC", "ab.example.test. A RRSIG NSEC") qname = dns.name.from_text("zz.example.test.") - assert not DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, [nsec, zone.sign(nsec)], zone.keyring()) + assert ( + DNSSECValidator().prove_wildcard(qname, wild_sig[0].labels, [nsec, zone.sign(nsec)], zone.keyring()) + is ValidationState.BOGUS + ) @staticmethod - def _wildcard_chain(*, proof: bool): + def _wildcard_chain(*, proof: bool, opt_out: bool = False): """A signed zone answering `victim.test.` from its `*.test.` wildcard. With ``proof=False`` this is the attack: the wildcard's data and @@ -355,6 +719,10 @@ def _wildcard_chain(*, proof: bool): record, and no denial proof accompanies them. On the wire the RRSIG owner must match the RRset it covers, so the owner is rewritten too; the rdata (labels=2) is untouched, and that is what still verifies. + + With ``opt_out=True`` the denial is a single opt-out NSEC3 covering + everything, which is what the signed TLD zone that turned this up + really serves. """ root = SignedZone.create(".") leaf = SignedZone.create("test.") @@ -383,7 +751,17 @@ def dnskey_response(zone: SignedZone): answer_response = make_response() answer_response.answer.append(answer) answer_response.answer.append(answer_sig) - if proof: + if opt_out: + # Covers every hash, but with the opt-out flag: it denies signed + # delegations, not existence, so it cannot rule out a closer match. + nsec3 = rrset_of( + "00000000000000000000000000000000.test.", + "NSEC3", + "1 1 0 - ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ A RRSIG", + ) + answer_response.authority.append(nsec3) + answer_response.authority.append(leaf.sign(nsec3)) + elif proof: # Covers `victim.test.`, so no closer match could have existed. nsec = rrset_of("test.", "NSEC", "z.test. A RRSIG NSEC") answer_response.authority.append(nsec) @@ -395,7 +773,7 @@ def dnskey_response(zone: SignedZone): } state = {"step": 0} - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): if (qname, rdtype) in table: return table[(qname, rdtype)], "9.9.9.9" state["step"] += 1 @@ -423,6 +801,28 @@ def test_the_resolver_refuses_an_unproven_wildcard_answer(self) -> None: ): resolver.resolve("victim.test.", "A") + def test_an_opt_out_proof_leaves_the_wildcard_answer_unauthenticated(self) -> None: + """Found in the wild while testing 0.2.0, in a signed TLD zone. + + The zone denies the queried name with a single opt-out NSEC3. Opt-out + says nothing about names that are not signed delegations, so the + expansion cannot be authenticated: the data is returned, INSECURE, as + four independent public validators all return it. Calling it SECURE + would let a replayed wildcard RRset override the real contents of any + name inside an opt-out span. + """ + resolver, send = self._wildcard_chain(proof=False, opt_out=True) + with patch.object(resolver, "_send_query", side_effect=send): + result = resolver.resolve_answer("victim.test.", "A") + assert result.records == ["192.0.2.99"] + assert result.dnssec is ValidationState.INSECURE + + def test_require_dnssec_refuses_an_opt_out_wildcard_answer(self) -> None: + resolver, send = self._wildcard_chain(proof=False, opt_out=True) + resolver.require_dnssec = True + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(DNSSECInsecureError): + resolver.resolve("victim.test.", "A") + class TestSignedDenialThroughTheResolveLoop: """The denial proof must be demanded by the resolve loop, not just testable. @@ -458,7 +858,7 @@ def dnskey_response(zone: SignedZone): } state = {"step": 0} - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): if (qname, rdtype) in table: return table[(qname, rdtype)], "9.9.9.9" state["step"] += 1 @@ -470,18 +870,19 @@ def send(qname, rdtype, nameservers, ctx): return resolver, send @staticmethod - def _nxdomain(leaf: SignedZone, *, proof: bool): + def _nxdomain(leaf: SignedZone, *, proof: bool, expiration: int | None = None): response = make_response( authority=[("test.", 300, "SOA", ["ns.test. a.test. 1 3600 900 604800 300"])], rcode=dns.rcode.NXDOMAIN, ) - response.authority.append(leaf.sign(response.authority[0])) + expiry = EXPIRATION if expiration is None else expiration + response.authority.append(leaf.sign(response.authority[0], expiration=expiry)) if proof: # One NSEC from the apex covers both the missing name and the # wildcard that could otherwise have synthesised it. nsec = rrset_of("test.", "NSEC", "z.test. A RRSIG NSEC") response.authority.append(nsec) - response.authority.append(leaf.sign(nsec)) + response.authority.append(leaf.sign(nsec, expiration=expiry)) return response @staticmethod @@ -506,6 +907,32 @@ def test_an_unproven_nxdomain_in_a_signed_zone_is_refused(self) -> None: with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(DNSSECValidationError): resolver.resolve("missing.test.", "A") + def test_a_proven_nxdomain_is_not_cached_past_its_signature(self) -> None: + """RFC 4035 §5.3.3: the denial stops being authenticated when the proof does. + + The SOA asks for 300s of negative caching, but the RRSIGs expire in 30, + so 30 is what the entry gets. + """ + cache = DNSCache() + resolver, send = self._chain(lambda leaf: self._nxdomain(leaf, proof=True, expiration=int(time.time()) + 30)) + resolver.cache = cache + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(NXDOMAINError): + resolver.resolve("missing.test.", "A") + entry = cache.get_nxdomain("missing.test.") + assert entry is not None and entry.secure + assert 0 < entry.expiry - time.monotonic() <= 30 + + def test_a_long_lived_proof_leaves_the_soa_ttl_alone(self) -> None: + """The cap only ever shortens: a 30-day signature does not extend 300s.""" + cache = DNSCache() + resolver, send = self._chain(lambda leaf: self._nxdomain(leaf, proof=True)) + resolver.cache = cache + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(NXDOMAINError): + resolver.resolve("missing.test.", "A") + entry = cache.get_nxdomain("missing.test.") + assert entry is not None + assert 290 < entry.expiry - time.monotonic() <= 300 + def test_an_unproven_nodata_in_a_signed_zone_is_refused(self) -> None: resolver, send = self._chain(lambda leaf: self._nodata(leaf, proof=False)) with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(DNSSECValidationError): @@ -547,7 +974,7 @@ def dnskey_response(zone: SignedZone): } state = {"step": 0} - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): if (qname, rdtype) in table: return table[(qname, rdtype)], "9.9.9.9" state["step"] += 1 @@ -621,3 +1048,439 @@ def answer(leaf: SignedZone): pytest.raises(DNSSECInsecureError), ): resolver.resolve("test.", "A") + + +class TestLameServersDoNotProduceDNSSECVerdicts: + """One lame nameserver in a zone's NS set must not condemn the zone. + + Measured against live DNS before the fix: 4 of 25 ordinary domains flapped + between an answer and "DNSSEC validation failed", depending only on which + server the shuffle happened to pick first. Each had a server in its NS set + that answers DNSKEY with NOERROR, no answer section and the delegation in + AUTHORITY - a parent-side server, or a plain lame one. Being unable to + retrieve validation material is a resolution failure, never evidence of + tampering. + + Driven at `_query_once` rather than `_send_query`, so the sweep across + servers is the thing under test. + """ + + LAME = "9.9.9.9" + GOOD = "9.9.9.10" + + def _world(self, *, lame: set[str], mode: str = "empty"): + root = SignedZone.create(".") + child = SignedZone.create("test.") + ds = root.ds_rrset(child) + + referral = make_response( + authority=[("test.", 3600, "NS", ["ns1.test.", "ns2.test."])], + additional=[("ns1.test.", 3600, "A", [self.LAME]), ("ns2.test.", 3600, "A", [self.GOOD])], + aa=False, + ) + referral.authority.append(ds) + referral.authority.append(root.sign(ds)) + + answer = make_response() + data = rrset_of("example.test.", "A", "198.51.100.9") + answer.answer.append(data) + answer.answer.append(child.sign(data)) + + def dnskey_response(zone: SignedZone): + response = make_response() + response.answer.append(zone.dnskey_rrset) + response.answer.append(zone.signed_dnskey()) + return response + + def lame_response(): + """NOERROR, empty ANSWER, delegation in AUTHORITY.""" + response = make_response(authority=[("test.", 3600, "NS", ["ns1.test.", "ns2.test."])], aa=False) + response.authority.append(ds) + response.authority.append(root.sign(ds)) + return response + + def query_once(qname, rdtype, server, payload, timeout, ctx): + if server not in (self.LAME, self.GOOD): + if rdtype == dns.rdatatype.DNSKEY and qname == dns.name.root: + return dnskey_response(root) + return referral + if rdtype == dns.rdatatype.DNSKEY and qname == child.name: + if server in lame: + return lame_response() if mode == "empty" else make_response(rcode=dns.rcode.SERVFAIL, aa=False) + return dnskey_response(child) + return answer + + resolver = RecursiveResolver(max_resolution_time=5, trust_anchors=(root.anchor_text(),), cache_enabled=False) + return resolver, query_once + + def test_a_lame_first_server_does_not_make_the_zone_bogus(self) -> None: + resolver, query_once = self._world(lame={self.LAME}) + with ( + patch.object(resolver, "_order_servers", side_effect=lambda servers: list(servers)), + patch.object(resolver, "_query_once", side_effect=query_once), + ): + result = resolver.resolve_answer("example.test.", "A") + assert result.records == ["198.51.100.9"] + assert result.dnssec is ValidationState.SECURE + + def test_every_server_lame_is_a_retrieval_failure(self) -> None: + """Not a DNSSECError: that distinction is what consumers branch on.""" + resolver, query_once = self._world(lame={self.LAME, self.GOOD}) + with ( + patch.object(resolver, "_query_once", side_effect=query_once), + pytest.raises(DNSSECMaterialUnavailableError), + ): + resolver.resolve("example.test.", "A") + + def test_servfail_on_dnskey_is_a_retrieval_failure(self) -> None: + resolver, query_once = self._world(lame={self.LAME, self.GOOD}, mode="servfail") + with ( + patch.object(resolver, "_query_once", side_effect=query_once), + pytest.raises(DNSSECMaterialUnavailableError), + ): + resolver.resolve("example.test.", "A") + + def test_a_lame_server_on_the_ds_fetch_does_not_break_the_chain(self) -> None: + """The same sweep is needed wherever validation material is fetched. + + A server authoritative for both `test.` and `sub.test.` answers from + the child, so the resolver has to fetch `sub.test./DS` itself; one lame + server there used to surface as "broken chain of trust". + """ + root = SignedZone.create(".") + child = SignedZone.create("test.") + grandchild = SignedZone.create("sub.test.") + + ds = root.ds_rrset(child) + referral = make_response( + authority=[("test.", 3600, "NS", ["ns1.test.", "ns2.test."])], + additional=[("ns1.test.", 3600, "A", [self.LAME]), ("ns2.test.", 3600, "A", [self.GOOD])], + aa=False, + ) + referral.authority.append(ds) + referral.authority.append(root.sign(ds)) + + answer = make_response() + data = rrset_of("example.sub.test.", "A", "198.51.100.9") + answer.answer.append(data) + answer.answer.append(grandchild.sign(data)) + + sub_ds = child.ds_rrset(grandchild) + ds_answer = make_response() + ds_answer.answer.append(sub_ds) + ds_answer.answer.append(child.sign(sub_ds)) + + def dnskey_response(zone: SignedZone): + response = make_response() + response.answer.append(zone.dnskey_rrset) + response.answer.append(zone.signed_dnskey()) + return response + + def query_once(qname, rdtype, server, payload, timeout, ctx): + if server not in (self.LAME, self.GOOD): + if rdtype == dns.rdatatype.DNSKEY and qname == dns.name.root: + return dnskey_response(root) + return referral + if rdtype == dns.rdatatype.DNSKEY: + return dnskey_response(child if qname == child.name else grandchild) + if rdtype == dns.rdatatype.DS and qname == grandchild.name: + if server == self.LAME: + return make_response(authority=[("sub.test.", 3600, "NS", ["ns1.test."])], aa=False) + return ds_answer + return answer + + resolver = RecursiveResolver(max_resolution_time=5, trust_anchors=(root.anchor_text(),), cache_enabled=False) + with ( + patch.object(resolver, "_order_servers", side_effect=lambda servers: list(servers)), + patch.object(resolver, "_query_once", side_effect=query_once), + ): + result = resolver.resolve_answer("example.sub.test.", "A") + assert result.records == ["198.51.100.9"] + assert result.dnssec is ValidationState.SECURE + + +class TestParentServedInsecureChild: + """A signed parent answering for its own insecurely delegated child. + + A signed second-level zone can be authoritative for a child that has no + DS, and answer the child's records itself, unsigned and with AA set. There + is no RRSIG, so no signer to align on: without walking the delegation the + resolver has no way to tell "insecure child" from "signatures stripped", + and used to call every such answer BOGUS. The public validators all return + the answer, unauthenticated. + """ + + SERVER = "9.9.9.10" + + def _world(self, final_response, *, bitmap: str = "NS RRSIG NSEC"): + """Root -> signed `test.` -> an unsigned answer for `example.sub.test.`. + + ``bitmap`` is what `test.` says about `sub.test.`: with NS in it the + name is a delegation and the missing DS makes it insecure; without NS + it is an ordinary name in the signed zone and nothing excuses the + missing signature. + """ + root = SignedZone.create(".") + child = SignedZone.create("test.") + ds = root.ds_rrset(child) + + referral = make_response( + authority=[("test.", 3600, "NS", ["ns1.test."])], + additional=[("ns1.test.", 3600, "A", [self.SERVER])], + aa=False, + ) + referral.authority.append(ds) + referral.authority.append(root.sign(ds)) + + nsec = rrset_of("sub.test.", "NSEC", f"z.test. {bitmap}") + no_ds = make_response() + no_ds.authority.append(nsec) + no_ds.authority.append(child.sign(nsec)) + + def dnskey_response(zone: SignedZone): + response = make_response() + response.answer.append(zone.dnskey_rrset) + response.answer.append(zone.signed_dnskey()) + return response + + def query_once(qname, rdtype, server, payload, timeout, ctx): + if server != self.SERVER: + if rdtype == dns.rdatatype.DNSKEY and qname == dns.name.root: + return dnskey_response(root) + return referral + if rdtype == dns.rdatatype.DNSKEY: + return dnskey_response(child) + if rdtype == dns.rdatatype.DS: + return no_ds + return final_response + + resolver = RecursiveResolver(max_resolution_time=5, trust_anchors=(root.anchor_text(),), cache_enabled=False) + return resolver, query_once + + def test_an_unsigned_answer_for_an_insecure_child_is_insecure(self) -> None: + answer = make_response() + answer.answer.append(rrset_of("example.sub.test.", "A", "198.51.100.9")) + resolver, query_once = self._world(answer) + with patch.object(resolver, "_query_once", side_effect=query_once): + result = resolver.resolve_answer("example.sub.test.", "A") + assert result.records == ["198.51.100.9"] + assert result.dnssec is ValidationState.INSECURE + + def test_an_unsigned_negative_answer_for_an_insecure_child_is_accepted(self) -> None: + nodata = make_response( + authority=[("sub.test.", 300, "SOA", ["ns1.test. hm.test. 1 3600 900 604800 300"])], + ) + resolver, query_once = self._world(nodata) + with patch.object(resolver, "_query_once", side_effect=query_once), pytest.raises(NoAnswerError): + resolver.resolve("example.sub.test.", "A") + + def test_a_stripped_signature_inside_the_signed_zone_is_still_bogus(self) -> None: + """The downgrade must not become a way to launder tampering. + + Same unsigned answer, but the zone proves the name is not a delegation: + no NS in the NSEC bitmap, so nothing licenses treating it as insecure. + """ + answer = make_response() + answer.answer.append(rrset_of("example.sub.test.", "A", "198.51.100.9")) + resolver, query_once = self._world(answer, bitmap="A RRSIG NSEC") + with ( + patch.object(resolver, "_query_once", side_effect=query_once), + pytest.raises(DNSSECValidationError, match="no RRSIG"), + ): + resolver.resolve("example.sub.test.", "A") + + +class TestClockSkewOnInception: + """A signer whose clock runs ahead of ours must not produce BOGUS. + + Zones that re-sign continuously publish records whose inception is a + moment in the future as far as a slightly-slow validator is concerned. + With no tolerance the freshest records in such a zone are intermittently + unresolvable. PowerDNS and Unbound both allow slack; this is the + conservative end of what they do. + """ + + @staticmethod + def _signed_from(zone: SignedZone, rrset: dns.rrset.RRset, offset: int) -> dns.rrset.RRset: + """Sign ``rrset`` with an inception ``offset`` seconds from now.""" + now = int(time.time()) + rrsig = dns.dnssec.sign( + rrset, + zone.private_key, + zone.name, + zone.dnskey, + inception=now + offset, + expiration=now + 30 * 86400, + ) + out = dns.rrset.RRset(rrset.name, dns.rdataclass.IN, dns.rdatatype.RRSIG) + out.add(rrsig) + out.ttl = rrset.ttl + return out + + def test_an_inception_just_inside_the_skew_allowance_validates(self, zone: SignedZone) -> None: + from recursive_resolver.dnssec import CLOCK_SKEW + + data = rrset_of("example.test.", "A", "192.0.2.1") + sig = self._signed_from(zone, data, CLOCK_SKEW - 5) + assert DNSSECValidator().validate_rrset(data, sig, zone.keyring()) is True + + def test_an_inception_beyond_the_allowance_does_not(self, zone: SignedZone) -> None: + from recursive_resolver.dnssec import CLOCK_SKEW + + data = rrset_of("example.test.", "A", "192.0.2.1") + sig = self._signed_from(zone, data, CLOCK_SKEW + 3600) + assert DNSSECValidator().validate_rrset(data, sig, zone.keyring()) is False + + def test_an_expired_signature_is_still_refused(self, zone: SignedZone) -> None: + """The slack applies to inception only; expiry stays strict.""" + now = int(time.time()) + data = rrset_of("example.test.", "A", "192.0.2.1") + rrsig = dns.dnssec.sign( + data, + zone.private_key, + zone.name, + zone.dnskey, + inception=now - 30 * 86400, + expiration=now - 3600, + ) + sig = dns.rrset.RRset(data.name, dns.rdataclass.IN, dns.rdatatype.RRSIG) + sig.add(rrsig) + sig.ttl = data.ttl + assert DNSSECValidator().validate_rrset(data, sig, zone.keyring()) is False + + def test_the_skew_is_configurable(self, zone: SignedZone) -> None: + data = rrset_of("example.test.", "A", "192.0.2.1") + sig = self._signed_from(zone, data, 600) + assert DNSSECValidator(clock_skew=0).validate_rrset(data, sig, zone.keyring()) is False + assert DNSSECValidator(clock_skew=1200).validate_rrset(data, sig, zone.keyring()) is True + + +class TestSignedDNAME: + """RFC 6672 §8: "A validating resolver MUST understand DNAME". + + The synthesized CNAME must not be signed (§3.3) - the DNAME's own + signature is what authenticates the redirection, and the CNAME follows + from it by construction. A validator that demands an RRSIG on the CNAME + calls every DNAME in a signed zone forged. + """ + + QNAME = dns.name.from_text("foo.sub.example.test.") + + @staticmethod + def _dname(zone: SignedZone): + return rrset_of("sub.example.test.", "DNAME", "target.example.test.") + + def _validate(self, resolver, zone, answer): + response = make_response() + response.answer = list(answer) + response.authority = [] + ctx = resolver._new_context() + classification = resolver._classify_response(response, self.QNAME, dns.rdatatype.A, zone.name) + assert classification["type"] == "cname" + with patch.object(resolver, "_get_zone_keys", return_value=_zone_keys(zone)): + return resolver._validate_answer( + response, + classification["cname_rrset"], + self.QNAME, + dns.rdatatype.CNAME, + ctx, + zone.name, + ["9.9.9.9"], + ValidationState.SECURE, + None, + classification.get("dname_rrset"), + ) + + def test_a_signed_dname_authenticates_the_redirection(self, zone: SignedZone) -> None: + resolver = RecursiveResolver(dnssec=True, cache_enabled=False) + dname = self._dname(zone) + state, _ttl = self._validate(resolver, zone, [dname, zone.sign(dname)]) + assert state is ValidationState.SECURE + + def test_the_servers_unsigned_synthesized_cname_does_not_spoil_it(self, zone: SignedZone) -> None: + resolver = RecursiveResolver(dnssec=True, cache_enabled=False) + dname = self._dname(zone) + synthesized = rrset_of(str(self.QNAME), "CNAME", "foo.target.example.test.") + state, _ttl = self._validate(resolver, zone, [dname, zone.sign(dname), synthesized]) + assert state is ValidationState.SECURE + + def test_an_unsigned_dname_in_a_signed_zone_never_validates(self, zone: SignedZone) -> None: + """Stripping the DNAME's signature must not be survivable. + + Which error comes out depends on what the delegation check can reach - + an unsigned subtree is INSECURE, a signed one BOGUS, an unreachable + parent "material unavailable" - but SECURE is never among them. + """ + resolver = RecursiveResolver(dnssec=True, cache_enabled=False) + with pytest.raises((DNSSECValidationError, DNSSECMaterialUnavailableError)): + self._validate(resolver, zone, [self._dname(zone)]) + + def test_a_dname_signed_by_another_zone_is_bogus(self, zone: SignedZone) -> None: + other = SignedZone.create("elsewhere.test.") + resolver = RecursiveResolver(dnssec=True, cache_enabled=False) + dname = self._dname(zone) + with pytest.raises(DNSSECValidationError): + self._validate(resolver, zone, [dname, other.sign(dname)]) + + +class TestQueryingAWildcardNameDirectly: + """RFC 4592 §2.2.1: the wildcard name can be asked for by name. + + The answer is an exact match, not an expansion, and carries no denial + records. Its RRSIG always covers one label fewer than the owner has, so a + naive expansion test reads every such answer as synthesised and demands a + proof that cannot exist. + """ + + def test_the_wildcard_owner_validates_without_a_denial(self, zone: SignedZone) -> None: + wild = rrset_of("*.example.test.", "A", "192.0.2.7") + resolver = RecursiveResolver(dnssec=True, cache_enabled=False) + ctx = resolver._new_context() + response = make_response() + response.answer = [wild, zone.sign(wild)] + response.authority = [] + with patch.object(resolver, "_get_zone_keys", return_value=_zone_keys(zone)): + state, _ttl = resolver._validate_answer( + response, + wild, + wild.name, + dns.rdatatype.A, + ctx, + zone.name, + ["9.9.9.9"], + ValidationState.SECURE, + None, + ) + assert state is ValidationState.SECURE + + def test_a_name_the_wildcard_expanded_to_still_needs_its_proof(self, zone: SignedZone) -> None: + """Only the literal owner is exempt; expansion is unchanged.""" + wild = rrset_of("*.example.test.", "A", "192.0.2.7") + expanded = rrset_of("host.example.test.", "A", "192.0.2.7") + # What a server sends for an expansion: the answer owned by the queried + # name, carrying the wildcard's signature, whose Labels count is one + # short of the owner. + signature = dns.rrset.RRset(expanded.name, dns.rdataclass.IN, dns.rdatatype.RRSIG) + for rr in zone.sign(wild): + signature.add(rr) + signature.ttl = 300 + resolver = RecursiveResolver(dnssec=True, cache_enabled=False) + ctx = resolver._new_context() + response = make_response() + response.answer = [expanded, signature] + response.authority = [] + with ( + patch.object(resolver, "_get_zone_keys", return_value=_zone_keys(zone)), + pytest.raises(DNSSECValidationError, match="wildcard-expanded"), + ): + resolver._validate_answer( + response, + expanded, + expanded.name, + dns.rdatatype.A, + ctx, + zone.name, + ["9.9.9.9"], + ValidationState.SECURE, + None, + ) diff --git a/tests/test_internals.py b/tests/test_internals.py index 5d738c6..7886e92 100644 --- a/tests/test_internals.py +++ b/tests/test_internals.py @@ -6,6 +6,7 @@ from __future__ import annotations +import ipaddress import subprocess import sys import threading @@ -28,6 +29,7 @@ QueryBudgetExceededError, RecursiveResolver, ResolutionTimeoutError, + ResolverError, ServfailError, UnsupportedRdtypeError, ValidationState, @@ -98,6 +100,31 @@ def test_multicast_and_unspecified_are_rejected(self) -> None: assert f.is_allowed("224.0.0.1") is False assert f.is_allowed("::") is False + def test_ipv6_transition_prefixes_are_rejected_by_our_own_list(self) -> None: + """Each wraps an IPv4 address, here 169.254.169.254 or 127.0.0.1. + + Stdlib classification catches these on a current interpreter, but did + not before CVE-2024-4032, so the built-in list must catch them on its + own. Checked with classification switched off. + """ + f = AddressFilter() + f._networks = tuple(n for n in f._networks if n.version == 6) + for address in ( + "2002:a9fe:a9fe::1", # 6to4 + "2001:0:53aa:64c:0:5faa:a9fe:a9fe", # Teredo + "64:ff9b::a9fe:a9fe", # NAT64 well-known + "64:ff9b:1::a9fe:a9fe", # NAT64 local-use + "::0.0.0.1", # IPv4-compatible + ): + assert any(ipaddress.ip_address(address) in network for network in f._networks), ( + f"{address} is not in any built-in blocked network" + ) + + def test_real_public_ipv6_nameservers_are_still_allowed(self) -> None: + f = AddressFilter() + assert f.is_allowed("2001:4860:4860::8888") is True + assert f.is_allowed("2620:fe::fe") is True + class TestBudgetEdges: def test_referral_budget(self) -> None: @@ -208,8 +235,19 @@ def test_meta_rdtypes_are_refused(self) -> None: offline_resolver().resolve("example.com", "AXFR") assert "meta" in str(exc.value) - def test_any_is_permitted(self) -> None: - assert offline_resolver()._parse_rdtype("ANY") == dns.rdatatype.ANY + def test_any_is_refused(self) -> None: + """An `Answer` holds one RRset, so there is nothing ANY could return. + + Letting it through was worse than refusing: no RRset ever matched the + query type, so a fully populated answer read as NODATA and was cached + as one, denying the name for every later query of any type. + """ + with pytest.raises(UnsupportedRdtypeError, match="ANY cannot be queried"): + offline_resolver()._parse_rdtype("ANY") + + def test_a_resolve_call_for_any_is_refused_too(self) -> None: + with pytest.raises(UnsupportedRdtypeError): + offline_resolver().resolve("example.com", "ANY") def test_relative_names_are_made_absolute(self) -> None: assert offline_resolver()._normalize_qname("example.com", "A").is_absolute() @@ -343,7 +381,7 @@ def test_require_authoritative_can_be_relaxed(self) -> None: assert classification["type"] == "answer" def test_negative_ttl_without_an_soa(self) -> None: - assert offline_resolver()._negative_ttl(make_response(), EXAMPLE) is None + assert offline_resolver()._negative_ttl(make_response(), EXAMPLE, EXAMPLE) is None def test_delegation_is_not_cached_without_a_ttl(self) -> None: resolver = RecursiveResolver(dnssec=False, cache_enabled=True) @@ -363,6 +401,21 @@ def test_ns_resolution_stops_when_the_budget_is_gone(self) -> None: ctx.budget.spend_query("x.", "A") assert resolver._resolve_ns_names([dns.name.from_text("ns.example.com.")], ctx, 1, limit=5) == [] + def test_the_aaaa_lookup_is_skipped_once_the_budget_is_gone(self) -> None: + """The A lookup can spend the last query; asking for AAAA anyway overruns.""" + resolver = offline_resolver(limits=Limits(max_queries=1), ipv4_only=False) + ctx = resolver._new_context() + asked = [] + + def resolve(ns_name, rdtype, ctx_, depth, path): + asked.append(rdtype) + ctx_.budget.spend_query(str(ns_name), dns.rdatatype.to_text(rdtype)) + raise ServfailError(str(ns_name), dns.rdatatype.to_text(rdtype)) + + with patch.object(resolver, "_resolve_iterative", side_effect=resolve): + assert resolver._resolve_ns_names([dns.name.from_text("ns.example.com.")], ctx, 1, limit=5) == [] + assert asked == [dns.rdatatype.A], "AAAA was queried with no budget left" + def test_all_servers_erroring_raises_servfail(self) -> None: resolver = offline_resolver() @@ -556,7 +609,7 @@ def test_cached_delegation_with_only_filtered_addresses_restarts_from_root(self) resolver.cache.put_delegation( Delegation(zone=dns.name.from_text("com."), addresses=["127.0.0.1", "10.0.0.1"]), ttl=3600 ) - zone, servers, _state, _ds = resolver._starting_point(EXAMPLE, dns.rdatatype.A) + zone, servers, _state, _ds, _names = resolver._starting_point(EXAMPLE, dns.rdatatype.A) assert zone == dns.name.root assert servers == resolver._root_addresses @@ -589,7 +642,7 @@ def test_negative_ttl_ignores_unrelated_authority_records(self) -> None: ], aa=False, ) - assert resolver._negative_ttl(response, EXAMPLE) is None + assert resolver._negative_ttl(response, EXAMPLE, EXAMPLE) is None class TestDSQueries: @@ -600,14 +653,14 @@ def test_warm_delegation_cache_does_not_route_ds_to_the_child(self) -> None: DS query was then sent to the child, which correctly answers NODATA.""" resolver = RecursiveResolver(dnssec=False, cache_enabled=True) resolver.cache.put_delegation(Delegation(zone=EXAMPLE, addresses=["5.5.5.5"]), ttl=3600) - zone, servers, _state, _ds = resolver._starting_point(EXAMPLE, dns.rdatatype.DS) + zone, servers, _state, _ds, _names = resolver._starting_point(EXAMPLE, dns.rdatatype.DS) assert zone != EXAMPLE, "a DS query must not start at the child zone" assert servers != ["5.5.5.5"] def test_a_query_still_uses_the_child_delegation(self) -> None: resolver = RecursiveResolver(dnssec=False, cache_enabled=True) resolver.cache.put_delegation(Delegation(zone=EXAMPLE, addresses=["5.5.5.5"]), ttl=3600) - zone, servers, _state, _ds = resolver._starting_point(EXAMPLE, dns.rdatatype.A) + zone, servers, _state, _ds, _names = resolver._starting_point(EXAMPLE, dns.rdatatype.A) assert zone == EXAMPLE assert servers == ["5.5.5.5"] @@ -615,13 +668,13 @@ def test_ds_query_uses_the_parents_cached_delegation(self) -> None: resolver = RecursiveResolver(dnssec=False, cache_enabled=True) resolver.cache.put_delegation(Delegation(zone=dns.name.from_text("com."), addresses=["192.5.6.30"]), ttl=3600) resolver.cache.put_delegation(Delegation(zone=EXAMPLE, addresses=["5.5.5.5"]), ttl=3600) - zone, servers, _state, _ds = resolver._starting_point(EXAMPLE, dns.rdatatype.DS) + zone, servers, _state, _ds, _names = resolver._starting_point(EXAMPLE, dns.rdatatype.DS) assert zone == dns.name.from_text("com.") assert servers == ["192.5.6.30"] def test_ds_at_the_root_has_no_parent_to_climb_to(self) -> None: resolver = RecursiveResolver(dnssec=False, cache_enabled=True) - zone, _servers, _state, _ds = resolver._starting_point(dns.name.root, dns.rdatatype.DS) + zone, _servers, _state, _ds, _names = resolver._starting_point(dns.name.root, dns.rdatatype.DS) assert zone == dns.name.root def test_ds_in_a_referral_authority_is_the_answer(self) -> None: @@ -727,3 +780,28 @@ def test_no_ns_names_means_no_fallback(self) -> None: resolver = offline_resolver() ctx = resolver._new_context() assert resolver._fallback_nameservers([], set(), ctx, 0) == [] + + +class TestConstructorRejectsOutOfRangeEDNS: + """Everything leaving this package is a `ResolverError`. + + An OPT record carries the payload size in the class field, so a value + outside 16 bits reached dnspython as a bad rdclass and came back as a bare + `ValueError` from inside the query path, long after the caller could tell + what they had done wrong. + """ + + @pytest.mark.parametrize("payload", [70000, -3, 65536]) + def test_an_out_of_range_payload_is_refused_at_construction(self, payload: int) -> None: + with pytest.raises(ValueError, match="edns_payload"): + RecursiveResolver(edns_payload=payload) + + @pytest.mark.parametrize("payload", [0, 512, 1232, 65535]) + def test_the_usable_range_is_accepted(self, payload: int) -> None: + assert RecursiveResolver(edns_payload=payload, dnssec=False).edns_payload == payload + + def test_nothing_but_a_resolver_error_escapes_resolve(self) -> None: + """The contract the guard exists to keep.""" + resolver = offline_resolver(edns_payload=1232) + with patch.object(resolver, "_send_query", return_value=(None, "")), pytest.raises(ResolverError): + resolver.resolve("example.com", "A") diff --git a/tests/test_protocol_harness.py b/tests/test_protocol_harness.py new file mode 100644 index 0000000..3e713e8 --- /dev/null +++ b/tests/test_protocol_harness.py @@ -0,0 +1,123 @@ +"""The real-world testing protocol's own guards (see TESTING.md). + +The harnesses in `scripts/` are what stands between a DNSSEC regression and a +release, so they need to keep working even though they normally run against +live DNS. These tests pin the two pieces of judgement they encode - which +invariant violations count, and which outcome changes a fault may legitimately +cause - and keep the modules importable. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import dns.flags +import dns.name +import dns.rcode +import dns.rdatatype +from conftest import make_response + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from audit import Fetch, Ledger, audited # noqa: E402 +from cassette import apply_fault, transition_allowed # noqa: E402 + +from recursive_resolver import DNSSECValidationError, RecursiveResolver # noqa: E402 +from recursive_resolver.exceptions import DNSSECMaterialUnavailableError # noqa: E402 + + +def _fetch(*, material=True, got=True, offered=("9.9.9.9", "1.1.1.1"), asked=("9.9.9.9", "1.1.1.1")) -> Fetch: + fetch = Fetch(qname="example.test.", rdtype="DNSKEY", material=material, offered=list(offered)) + fetch.asked = set(asked) + fetch.got_response = got + return fetch + + +class TestInvariants: + def test_a_clean_resolution_violates_nothing(self) -> None: + ledger = Ledger(fetches=[_fetch()], queries=[("example.test.", "A", "9.9.9.9", 1232, True)]) + assert ledger.violations(None) == [] + + def test_i1_a_dnssec_verdict_while_material_was_unavailable(self) -> None: + ledger = Ledger(fetches=[_fetch(got=False)]) + outcome = DNSSECValidationError("example.test.", "A", "no valid DNSKEY") + assert any(v.startswith("I1") for v in ledger.violations(outcome)) + + def test_i2_a_query_sent_without_edns_while_validating(self) -> None: + ledger = Ledger(queries=[("example.test.", "DNSKEY", "9.9.9.9", None, True)]) + assert any(v.startswith("I2") for v in ledger.violations(None)) + + def test_i2_ignores_a_resolver_that_is_not_validating(self) -> None: + ledger = Ledger(queries=[("example.test.", "A", "9.9.9.9", None, False)]) + assert ledger.violations(None) == [] + + def test_i3_giving_up_before_every_server_was_asked(self) -> None: + ledger = Ledger(fetches=[_fetch(got=False, asked=("9.9.9.9",))]) + violations = ledger.violations(DNSSECMaterialUnavailableError("example.test.", "DNSKEY")) + assert any(v.startswith("I3") for v in violations) + + def test_i4_an_answer_accepted_without_the_aa_bit(self) -> None: + ledger = Ledger(classifications=[("example.test.", False, "answer")]) + assert any(v.startswith("I4") for v in ledger.violations(None)) + + def test_i5_material_reported_unavailable_when_every_fetch_answered(self) -> None: + ledger = Ledger(fetches=[_fetch()]) + outcome = DNSSECMaterialUnavailableError("example.test.", "DNSKEY") + assert any(v.startswith("I5") for v in ledger.violations(outcome)) + + def test_the_audit_records_what_the_resolver_did_and_leaves_no_trace(self) -> None: + """A harness that permanently rewires the object under test is a liability.""" + resolver = RecursiveResolver(cache_enabled=False, dnssec=False) + answer = make_response(answer=[("example.test.", 300, "A", ["198.51.100.9"])]) + + # The transport is wired up first, so the audit wraps it rather than + # being replaced by it: the order the harnesses use. + transport = resolver._query_once = lambda *a, **k: answer + with audited(resolver) as ledger: + resolver._send_query( + dns.name.from_text("example.test."), dns.rdatatype.A, ["9.9.9.9"], resolver._new_context() + ) + assert [f.qname for f in ledger.fetches] == ["example.test."] + assert [f.asked for f in ledger.fetches] == [{"9.9.9.9"}] + assert resolver._query_once is transport + assert not {"_send_query", "_classify_response"} & set(resolver.__dict__) + + +class TestPerturbationRules: + """Which outcome changes a single-server fault may legitimately cause.""" + + def test_an_availability_fault_must_not_change_the_verdict(self) -> None: + assert not transition_allowed("ok/secure", "bogus", "timeout") + assert not transition_allowed("ok/secure", "ok/insecure", "empty-answer") + + def test_losing_a_server_may_cost_the_material(self) -> None: + assert transition_allowed("ok/secure", "unavailable", "servfail") + + def test_stripping_signatures_may_legitimately_look_like_tampering(self) -> None: + assert transition_allowed("ok/secure", "bogus", "strip-rrsig") + + def test_stripping_signatures_must_not_silently_downgrade(self) -> None: + assert not transition_allowed("ok/secure", "ok/insecure", "strip-rrsig") + + def test_reordering_alone_must_reproduce_the_outcome(self) -> None: + assert transition_allowed("ok/secure", "ok/secure", "") + assert not transition_allowed("ok/insecure", "ok/secure", "") + + +class TestFaults: + def test_each_fault_does_what_it_says(self) -> None: + signed = make_response( + answer=[ + ("example.test.", 300, "A", ["198.51.100.9"]), + ("example.test.", 300, "RRSIG", ["A 8 2 300 20990101000000 20200101000000 1 example.test. AAAA"]), + ] + ) + assert not apply_fault(make_response(answer=[("a.test.", 300, "A", ["1.2.3.4"])]), "empty-answer", 1232).answer + stripped = apply_fault(signed, "strip-rrsig", 1232) + assert [r.rdtype for r in stripped.answer] == [dns.rdatatype.A] + assert apply_fault(make_response(), "no-aa", 1232).flags & dns.flags.AA == 0 + assert apply_fault(make_response(), "servfail", 1232).rcode() == dns.rcode.SERVFAIL + # FORMERR emulates a server that dislikes the OPT record, so a query + # sent without EDNS must come back untouched. + assert apply_fault(make_response(), "formerr", None).rcode() == dns.rcode.NOERROR diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 72a71a0..225a199 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -2,11 +2,14 @@ from __future__ import annotations +import time from unittest.mock import patch import dns.name import dns.rcode +import dns.rdataclass import dns.rdatatype +import dns.rrset import pytest from conftest import make_response, referral, root_to_com, sequence @@ -135,6 +138,35 @@ def counting(*args, **kwargs): assert answer.records == ["9.9.9.9"] assert calls == 3, "should not have re-walked from the root" + def test_an_inline_cname_target_is_cached_under_its_own_name(self) -> None: + """The target was answered authoritatively, so it is worth keeping. + + Caching it under the target's name rather than the queried one is what + lets a later lookup of the target itself hit, and it is the reason the + second walk can be skipped at all. + """ + resolver = _resolver(cache_enabled=True) + inline = make_response( + answer=[ + ("www.example.com.", 300, "CNAME", ["cdn.example.com."]), + ("cdn.example.com.", 300, "A", ["9.9.9.9"]), + ] + ) + responses = [ + (root_to_com(), "198.41.0.4"), + (referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "1.2.3.4"}), "192.5.6.30"), + (inline, "1.2.3.4"), + ] + with patch.object(resolver, "_send_query", side_effect=sequence(responses)): + assert resolver.resolve("www.example.com", "A") == ["9.9.9.9"] + + assert resolver.cache is not None + cached = resolver.cache.get_answer(dns.name.from_text("cdn.example.com."), dns.rdatatype.A) + assert cached is not None, "the inline target was not cached" + # No further queries: the target now answers straight from the cache. + with patch.object(resolver, "_send_query", side_effect=AssertionError("should not query")): + assert resolver.resolve("cdn.example.com", "A") == ["9.9.9.9"] + def test_cname_loop_detected(self) -> None: resolver = _resolver(max_cname_chain=5) @@ -339,6 +371,22 @@ def send(qname, rdtype, nameservers, ctx): with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ServfailError): resolver.resolve("example.com", "A") + def test_a_budget_that_runs_out_mid_referral_is_a_timeout_not_a_servfail(self) -> None: + """Same empty result, two causes. Reporting the wrong one hides the reason.""" + resolver = _resolver() + + def send(qname, rdtype, nameservers, ctx): + if nameservers and nameservers[0] == "198.41.0.4": + return root_to_com(), "198.41.0.4" + if nameservers == ["192.5.6.30"]: + # Spend the budget just as the glueless names come up. + ctx.budget.deadline = time.monotonic() - 1 + return referral("example.com.", ["ns1.broken.net."]), "192.5.6.30" + return (None, "") + + with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ResolutionTimeoutError): + resolver.resolve("example.com", "A") + class TestNXDOMAINRetry: def test_sibling_nameserver_is_tried_before_accepting_nxdomain(self) -> None: @@ -380,3 +428,325 @@ def test_effective_timeout_is_clamped_to_the_deadline(self) -> None: resolver = _resolver(timeout=5.0, max_resolution_time=1.0) ctx = resolver._new_context() assert 0 < resolver._effective_timeout(ctx) <= 1.01 + + +class TestCacheHitsReportRemainingLifetime: + """A cache hit must report what is left, not the TTL it arrived with. + + A caller that re-caches on the reported value would otherwise extend the + lifetime on every hit, and for authenticated data that means outliving the + signature the TTL was capped to. + """ + + def test_the_reported_ttl_counts_down(self) -> None: + resolver = _resolver(cache_enabled=True) + rrset = dns.rrset.from_text("a.test.", 300, "IN", "A", "1.2.3.4") + assert resolver.cache is not None + resolver.cache.put_answer("a.test.", "A", rrset, 300) + first = resolver._check_cache(dns.name.from_text("a.test."), dns.rdatatype.A) + assert first is not None and first.ttl <= 300 + entry = resolver.cache.get_answer("a.test.", "A") + assert entry is not None + # Age the entry by rewriting its expiry, then look again. + with resolver.cache._lock: + key = ("A", dns.name.from_text("a.test."), int(dns.rdatatype.A), dns.rdataclass.IN) + resolver.cache._cache[key].expiry -= 250 + later = resolver._check_cache(dns.name.from_text("a.test."), dns.rdatatype.A) + assert later is not None + assert later.ttl < first.ttl, "the reported TTL did not count down" + assert later.ttl <= 50 + + +class TestDNAME: + """RFC 6672. Two MUSTs: §3.4 "Recursive caching name servers MUST perform + CNAME synthesis on behalf of clients", and §8 "A validating resolver MUST + understand DNAME".""" + + QNAME = "foo.sub.example.com." + + @staticmethod + def _dname(owner: str = "sub.example.com.", target: str = "target.example.com.", ttl: int = 300): + return (owner, ttl, "DNAME", [target]) + + def _chain_to(self, first, final_name="foo.target.example.com."): + return [ + (root_to_com(), "198.41.0.4"), + (referral("example.com.", ["ns1.example.com."], {"ns1.example.com.": "1.2.3.4"}), "192.5.6.30"), + (first, "1.2.3.4"), + (make_response(answer=[(final_name, 300, "A", ["192.0.2.9"])]), "1.2.3.4"), + ] + + def test_a_dname_alone_is_followed(self) -> None: + """The server sent no synthesized CNAME, so we make one ourselves.""" + resolver = _resolver() + responses = self._chain_to(make_response(answer=[self._dname()])) + with patch.object(resolver, "_send_query", side_effect=sequence(responses)): + answer = resolver.resolve_answer(self.QNAME, "A") + assert answer.records == ["192.0.2.9"] + assert answer.canonical_name == dns.name.from_text("foo.target.example.com.") + + def test_a_dname_with_the_servers_synthesized_cname_is_followed(self) -> None: + first = make_response(answer=[self._dname(), (self.QNAME, 300, "CNAME", ["foo.target.example.com."])]) + resolver = _resolver() + with patch.object(resolver, "_send_query", side_effect=sequence(self._chain_to(first))): + assert resolver.resolve(self.QNAME, "A") == ["192.0.2.9"] + + def test_the_substitution_replaces_only_the_dname_owners_labels(self) -> None: + resolver = _resolver() + rrset = dns.rrset.from_text("sub.example.com.", 300, "IN", "DNAME", "target.example.com.") + assert resolver._dname_target(dns.name.from_text("a.b.sub.example.com."), rrset) == dns.name.from_text( + "a.b.target.example.com." + ) + + def test_a_dname_does_not_apply_to_its_own_owner(self) -> None: + """RFC 6672 §2.4: the owner name itself is not rewritten.""" + resolver = _resolver() + response = make_response(answer=[self._dname()]) + assert ( + resolver._find_dname(response, dns.name.from_text("sub.example.com."), dns.name.from_text("example.com.")) + is None + ) + + def test_an_out_of_bailiwick_dname_is_ignored(self) -> None: + """A DNAME rewrites a whole subtree; only the zone that holds it may.""" + resolver = _resolver() + response = make_response(answer=[("sub.elsewhere.test.", 300, "DNAME", ["t.elsewhere.test."])]) + assert ( + resolver._find_dname( + response, dns.name.from_text("foo.sub.elsewhere.test."), dns.name.from_text("example.com.") + ) + is None + ) + + def test_the_longest_matching_dname_wins(self) -> None: + resolver = _resolver() + response = make_response( + answer=[self._dname("example.com.", "a.test."), self._dname("sub.example.com.", "b.test.")] + ) + found = resolver._find_dname( + response, dns.name.from_text("foo.sub.example.com."), dns.name.from_text("example.com.") + ) + assert found is not None and found.name == dns.name.from_text("sub.example.com.") + + def test_a_substitution_that_overflows_a_name_is_not_applied(self) -> None: + """RFC 6672 §3.4.1: an over-long result is a YXDOMAIN, not a redirect.""" + resolver = _resolver() + long_target = ".".join("x" * 60 for _ in range(4)) + ".test." + rrset = dns.rrset.from_text("sub.example.com.", 300, "IN", "DNAME", long_target) + deep = dns.name.from_text(".".join("y" * 60 for _ in range(3)) + ".sub.example.com.") + assert resolver._dname_target(deep, rrset) is None + + def test_the_synthesized_cname_carries_the_dnames_ttl(self) -> None: + """RFC 6672 §3.1.""" + resolver = _resolver() + rrset = dns.rrset.from_text("sub.example.com.", 77, "IN", "DNAME", "target.example.com.") + synthesized = resolver._synthesize_cname( + dns.name.from_text(self.QNAME), dns.name.from_text("foo.target.example.com."), rrset + ) + assert synthesized.ttl == 77 + assert synthesized[0].target == dns.name.from_text("foo.target.example.com.") + + +class TestANYIsRefused: + def test_resolve_for_any_raises(self) -> None: + """Silently answering NODATA also cached a denial for every type.""" + with pytest.raises(UnsupportedRdtypeError, match="ANY cannot be queried"): + _resolver().resolve("example.com", "ANY") + + def test_nothing_is_cached_by_the_refusal(self) -> None: + resolver = _resolver(cache_enabled=True) + with pytest.raises(UnsupportedRdtypeError): + resolver.resolve("example.com", "ANY") + assert resolver.cache is not None and len(resolver.cache) == 0 + + +class TestNonDelegatingNSSetIsAnError: + """An NS set pointing sideways or upwards is a redirection, not a denial.""" + + def test_an_upward_ns_set_is_not_nodata(self) -> None: + resolver = _resolver(require_authoritative=False) + response = make_response(authority=[("com.", 300, "NS", ["ns1.gtld.test."])], aa=True) + classification = resolver._classify_response( + response, dns.name.from_text("example.com."), dns.rdatatype.A, dns.name.from_text("example.com.") + ) + assert classification["type"] == "error" + + def test_a_delegation_at_the_qname_is_still_nodata_for_a_ds(self) -> None: + """The parent handing back the child's NS set with no DS beside it.""" + resolver = _resolver() + response = make_response(authority=[("example.com.", 300, "NS", ["ns1.example.com."])], aa=True) + classification = resolver._classify_response( + response, dns.name.from_text("example.com."), dns.rdatatype.DS, dns.name.from_text("com.") + ) + assert classification["type"] == "nodata" + + +class TestNegativeAnswerSOAMustBelongToTheZone: + def test_an_soa_from_above_the_zone_does_not_mark_a_negative_answer(self) -> None: + resolver = _resolver() + response = make_response(authority=[("com.", 300, "SOA", ["ns.com. a.com. 1 2 3 4 60"])], aa=True) + assert ( + resolver._marks_negative( + response.authority[0], dns.name.from_text("example.com."), dns.name.from_text("example.com.") + ) + is False + ) + assert ( + resolver._negative_ttl(response, dns.name.from_text("example.com."), dns.name.from_text("example.com.")) + is None + ) + + def test_the_zones_own_soa_does(self) -> None: + resolver = _resolver() + response = make_response( + authority=[("example.com.", 300, "SOA", ["ns.example.com. a.example.com. 1 2 3 4 60"])], aa=True + ) + assert ( + resolver._negative_ttl(response, dns.name.from_text("example.com."), dns.name.from_text("example.com.")) + == 60 + ) + + +class TestGlueTTLBoundsTheDelegation: + """Addresses expire with the glue that carried them, not with the NS set.""" + + def test_short_lived_glue_shortens_the_cached_delegation(self) -> None: + resolver = _resolver(cache_enabled=True) + response = make_response( + authority=[("example.com.", 86400, "NS", ["ns1.example.com."])], + additional=[("ns1.example.com.", 60, "A", ["1.2.3.4"])], + aa=False, + ) + resolver._cache_delegation( + dns.name.from_text("example.com."), + [dns.name.from_text("ns1.example.com.")], + ["1.2.3.4"], + response, + ValidationState.INSECURE, + ) + assert resolver.cache is not None + delegation = resolver.cache.get_delegation(dns.name.from_text("example.com.")) + assert delegation is not None + entry = resolver.cache._cache[("DG", dns.name.from_text("example.com."), 0, 0)] + import time as _time + + assert entry.expiry - _time.monotonic() <= 61, "the day-long NS TTL outlived the minute-long glue" + + def test_long_lived_glue_leaves_the_ns_ttl_alone(self) -> None: + resolver = _resolver(cache_enabled=True) + response = make_response( + authority=[("example.com.", 300, "NS", ["ns1.example.com."])], + additional=[("ns1.example.com.", 86400, "A", ["1.2.3.4"])], + aa=False, + ) + resolver._cache_delegation( + dns.name.from_text("example.com."), + [dns.name.from_text("ns1.example.com.")], + ["1.2.3.4"], + response, + ValidationState.INSECURE, + ) + assert resolver.cache is not None + entry = resolver.cache._cache[("DG", dns.name.from_text("example.com."), 0, 0)] + import time as _time + + assert 290 < entry.expiry - _time.monotonic() <= 300 + + +class TestGluelessNSUsesBothFamilies: + def test_aaaa_is_queried_when_ipv6_is_allowed(self) -> None: + resolver = _resolver(ipv4_only=False) + ctx = resolver._new_context() + asked: list[str] = [] + + def resolve_iterative(name, rdtype, ctx_, depth, chain): + asked.append(dns.rdatatype.to_text(rdtype)) + raise NoAnswerError(str(name), dns.rdatatype.to_text(rdtype)) + + with patch.object(resolver, "_resolve_iterative", side_effect=resolve_iterative): + resolver._resolve_ns_names([dns.name.from_text("ns1.example.com.")], ctx, 1, limit=5) + assert asked == ["A", "AAAA"] + + def test_only_a_is_queried_by_default(self) -> None: + resolver = _resolver() + ctx = resolver._new_context() + asked: list[str] = [] + + def resolve_iterative(name, rdtype, ctx_, depth, chain): + asked.append(dns.rdatatype.to_text(rdtype)) + raise NoAnswerError(str(name), dns.rdatatype.to_text(rdtype)) + + with patch.object(resolver, "_resolve_iterative", side_effect=resolve_iterative): + resolver._resolve_ns_names([dns.name.from_text("ns1.example.com.")], ctx, 1, limit=5) + assert asked == ["A"] + + +class TestClassificationEdges: + """The branches the happy paths never reach.""" + + def test_a_dname_whose_substitution_overflows_is_not_a_redirect(self) -> None: + """Found, but unusable: fall through rather than redirect nowhere.""" + resolver = _resolver() + long_target = ".".join("x" * 60 for _ in range(4)) + ".test." + deep = ".".join("y" * 60 for _ in range(3)) + ".sub.example.com." + response = make_response( + answer=[("sub.example.com.", 300, "DNAME", [long_target])], + authority=[("example.com.", 300, "SOA", ["ns.example.com. a.example.com. 1 2 3 4 60"])], + aa=True, + ) + classification = resolver._classify_response( + response, dns.name.from_text(deep), dns.rdatatype.A, dns.name.from_text("example.com.") + ) + assert classification["type"] == "nodata" + + def test_an_empty_authoritative_response_is_nodata(self) -> None: + """No answer, no CNAME, no DNAME, no SOA, no NS set.""" + resolver = _resolver() + classification = resolver._classify_response( + make_response(aa=True), + dns.name.from_text("example.com."), + dns.rdatatype.A, + dns.name.from_text("example.com."), + ) + assert classification["type"] == "nodata" + + def test_an_empty_non_authoritative_response_is_an_error(self) -> None: + """Nothing in it, and no authority to say so.""" + resolver = _resolver() + classification = resolver._classify_response( + make_response(aa=False), + dns.name.from_text("example.com."), + dns.rdatatype.A, + dns.name.from_text("example.com."), + ) + assert classification["type"] == "error" + assert "without AA bit" in classification["detail"] + + def test_a_shorter_dname_does_not_displace_a_longer_one(self) -> None: + resolver = _resolver() + response = make_response( + answer=[ + ("sub.example.com.", 300, "DNAME", ["b.test."]), + ("example.com.", 300, "DNAME", ["a.test."]), + ] + ) + found = resolver._find_dname( + response, dns.name.from_text("foo.sub.example.com."), dns.name.from_text("example.com.") + ) + assert found is not None and found.name == dns.name.from_text("sub.example.com.") + + def test_a_delegation_with_no_glue_keeps_the_ns_ttl(self) -> None: + resolver = _resolver(cache_enabled=True) + response = make_response(authority=[("example.com.", 300, "NS", ["ns1.elsewhere.test."])], aa=False) + resolver._cache_delegation( + dns.name.from_text("example.com."), + [dns.name.from_text("ns1.elsewhere.test.")], + ["1.2.3.4"], + response, + ValidationState.INSECURE, + ) + assert resolver.cache is not None + entry = resolver.cache._cache[("DG", dns.name.from_text("example.com."), 0, 0)] + import time as _time + + assert 290 < entry.expiry - _time.monotonic() <= 300 diff --git a/tests/test_security.py b/tests/test_security.py index a51c2d1..598e9a7 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -11,7 +11,9 @@ from unittest.mock import patch import dns.dnssec +import dns.exception import dns.flags +import dns.message import dns.name import dns.rcode import dns.rdata @@ -131,7 +133,7 @@ def test_hostile_glue_is_never_queried(self) -> None: resolver = offline_resolver() queried: list[list[str]] = [] - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): queried.append(list(nameservers)) if len(queried) == 1: return root_to_com(), "198.41.0.4" @@ -155,7 +157,7 @@ def test_resolved_ns_addresses_are_filtered_too(self) -> None: """Glueless NS hostnames resolving to private IPs are also rejected.""" resolver = offline_resolver() - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): if qname == dns.name.from_text("ns.attacker.test."): return make_response(answer=[("ns.attacker.test.", 300, "A", ["192.168.1.1"])]), "1.2.3.4" if nameservers and nameservers[0] == "198.41.0.4": @@ -174,7 +176,7 @@ def test_glueless_fanout_is_bounded(self) -> None: resolver = offline_resolver(limits=Limits(max_queries=40), max_depth=10) sent = 0 - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): nonlocal sent sent += 1 assert sent < 5000, "runaway fan-out" @@ -238,7 +240,7 @@ def test_upward_referral_does_not_crash(self) -> None: """Regression: this used to raise dns.name.NoParent out of resolve().""" resolver = offline_resolver() - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): return referral(".", ["a.root-servers.net."], {"a.root-servers.net.": "198.41.0.4"}), "1.2.3.4" # Must surface as a ResolverError, never dns.name.NoParent. @@ -349,7 +351,7 @@ def test_nodata_with_ns_in_authority_is_nodata_not_referral(self) -> None: resolver = RecursiveResolver(dnssec=False, cache_enabled=True) sent = 0 - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): nonlocal sent sent += 1 if sent == 1: @@ -403,6 +405,76 @@ def test_payload_ladder(self) -> None: assert resolver._payload_for_attempt(1) == 512 assert resolver._payload_for_attempt(2) is None + def test_the_ladder_never_drops_edns_while_validating(self) -> None: + """DNSSEC needs EDNS0 to carry DO (RFC 4035 §3.2.1). + + Without the OPT record the answer comes back with no RRSIGs, which the + validator can only read as BOGUS: a validation failure of our own making + against a perfectly good zone. + """ + resolver = offline_resolver(edns_payload=1232) + assert resolver._payload_for_attempt(2, True) == 512 + + def test_every_sweep_carries_the_do_bit_for_a_validating_query(self) -> None: + resolver = RecursiveResolver(timeout=0.5, cache_enabled=False) + sent: list[dns.message.Message] = [] + + def capture(query, server, timeout=None, **kwargs): + sent.append(query) + raise dns.exception.Timeout("forced") + + with ( + patch("dns.query.udp", side_effect=capture), + patch("dns.query.udp_with_fallback", side_effect=lambda q, s, timeout=None, **kw: (capture(q, s), False)), + ): + ctx = resolver._new_context() + resolver._send_query(dns.name.from_text("example.com."), dns.rdatatype.DNSKEY, ["9.9.9.9"], ctx) + + assert len(sent) == resolver.max_retries + 1 + assert not [i + 1 for i, query in enumerate(sent) if not query.ednsflags & dns.flags.DO] + + def test_an_edns_incapable_server_is_abandoned_while_validating(self) -> None: + """Its answers can never be validated, so a DO-less retry is pointless. + + Querying it without DO and then judging the unsigned result BOGUS is the + worst of both: no signatures, and a DNSSEC verdict pinned on the zone. + """ + resolver = RecursiveResolver(cache_enabled=False) + payloads: list[int | None] = [] + + def query_once(qname, rdtype, server, payload, timeout, ctx): + payloads.append(payload) + return make_response(rcode=dns.rcode.FORMERR, aa=False) + + zone = dns.name.from_text("example.com.") + with patch.object(resolver, "_query_once", side_effect=query_once): + ctx = resolver._new_context() + response, _ = resolver._send_query( + zone, dns.rdatatype.DNSKEY, ["9.9.9.9"], ctx, usable=resolver._usable_dnskey(zone) + ) + + assert response is None + assert payloads == [1232], "the server must be abandoned, not retried without DO" + + def test_a_servfail_still_reaches_the_caller_while_validating(self) -> None: + """Abandoning the server must not hide the failure it reported. + + SERVFAIL shares a branch with the EDNS-incapable rcodes. Without DO + there is nothing to gain from re-asking, but the response still has to + come back, or a broken zone would surface as a timeout. + """ + resolver = RecursiveResolver(cache_enabled=False) + + def query_once(qname, rdtype, server, payload, timeout, ctx): + return make_response(rcode=dns.rcode.SERVFAIL, aa=False) + + with patch.object(resolver, "_query_once", side_effect=query_once): + ctx = resolver._new_context() + response, _ = resolver._send_query(dns.name.from_text("example.com."), dns.rdatatype.A, ["9.9.9.9"], ctx) + + assert response is not None + assert response.rcode() == dns.rcode.SERVFAIL + @pytest.mark.parametrize( "rcode", [dns.rcode.FORMERR, dns.rcode.NOTIMP, dns.rcode.SERVFAIL, 16], @@ -470,7 +542,7 @@ def query_once(qname, rdtype, server, payload, timeout, ctx): def test_all_servers_failing_raises_servfail_not_success(self) -> None: resolver = offline_resolver() - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): return make_response(rcode=dns.rcode.REFUSED, aa=False), nameservers[0] with patch.object(resolver, "_send_query", side_effect=send), pytest.raises(ServfailError): @@ -491,7 +563,7 @@ def test_nxdomain_in_a_signed_single_ns_zone(self) -> None: resolver = RecursiveResolver(dnssec=True, cache_enabled=False) attempted: list[list[str]] = [] - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): attempted.append(list(nameservers)) return (None, "") if not nameservers else (make_response(aa=True), nameservers[0]) @@ -523,7 +595,7 @@ def test_zone_nameservers_survive_server_pruning(self) -> None: resolver = RecursiveResolver(dnssec=False, cache_enabled=False) calls = 0 - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): nonlocal calls calls += 1 assert nameservers, "queried with an empty nameserver list" @@ -551,7 +623,7 @@ def test_cached_delegation_carries_its_ds(self) -> None: ), ttl=3600, ) - zone, servers, state, ds = resolver._starting_point(dns.name.from_text("example.com."), dns.rdatatype.A) + zone, servers, state, ds, _names = resolver._starting_point(dns.name.from_text("example.com."), dns.rdatatype.A) assert zone == dns.name.from_text("com.") assert servers == ["192.5.6.30"] assert ds is sentinel @@ -565,7 +637,9 @@ def test_secure_delegation_without_ds_restarts_from_root(self) -> None: Delegation(zone=dns.name.from_text("com."), addresses=["192.5.6.30"], secure=True, ds=None), ttl=3600, ) - zone, servers, _state, _ds = resolver._starting_point(dns.name.from_text("example.com."), dns.rdatatype.A) + zone, servers, _state, _ds, _names = resolver._starting_point( + dns.name.from_text("example.com."), dns.rdatatype.A + ) assert zone == dns.name.root assert servers == resolver._root_addresses @@ -661,7 +735,7 @@ class TestCrossZoneCNAMEPoisoning: FORGED = '"v=DKIM1; k=rsa; p=ATTACKERKEY"' def _send(self, resolver): - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): if nameservers == resolver._root_addresses: if str(qname).endswith("attacker.test."): return referral("attacker.test.", ["ns.attacker.test."], {"ns.attacker.test.": self.ATTACKER_IP}), ( @@ -700,7 +774,7 @@ def test_an_inline_target_inside_the_zone_is_still_used(self) -> None: resolver = RecursiveResolver(dnssec=False, cache_enabled=False) calls = [] - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): calls.append(str(qname)) if nameservers == resolver._root_addresses: return referral("example.com.", ["ns.example.com."], {"ns.example.com.": "192.5.6.30"}), "198.41.0.4" @@ -748,7 +822,7 @@ class TestNegativeCacheScoping: def test_a_sibling_of_a_missing_name_is_not_denied(self) -> None: resolver = RecursiveResolver(dnssec=False) - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): if nameservers == resolver._root_addresses: return referral("example.com.", ["ns.example.com."], {"ns.example.com.": "192.5.6.30"}), "198.41.0.4" if str(qname) == "missing.example.com.": @@ -811,7 +885,7 @@ class TestReturnedDataIsTheCallersOwn: def offline_resolver(): resolver = RecursiveResolver(dnssec=False) - def send(qname, rdtype, nameservers, ctx): + def send(qname, rdtype, nameservers, ctx, usable=None): if nameservers == resolver._root_addresses: return referral("example.com.", ["ns.example.com."], {"ns.example.com.": "192.5.6.30"}), "198.41.0.4" return make_response(answer=[("example.com.", 300, "A", ["1.2.3.4"])]), "192.5.6.30" @@ -883,3 +957,93 @@ def test_a_genuine_mismatch_is_still_bogus(self) -> None: ds = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.DS) ds.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, "12345 8 2 " + "AB" * 32), ttl=300) assert validator.validate_dnskey(dns.name.from_text("z.test."), keys, None, ds) is ValidationState.BOGUS + + +class TestSpoofedPacketsDoNotRetireANameserver: + """RFC 5452 §9: a wrong-ID datagram is junk, not an answer, and not a fault. + + The randomised query ID and source port exist so an off-path attacker's + guesses are discarded. Treating the first wrong guess as a protocol error + hands that attacker the outcome anyway: the server gets abandoned, or its + EDNS support written off, on the strength of a packet nobody authenticated. + + These run against a real UDP socket, so what is exercised is the flag this + resolver passes rather than a stubbed-out reimplementation of it. + """ + + @staticmethod + @contextlib.contextmanager + def _server(spoofs: int): + """A nameserver that emits ``spoofs`` junk packets before answering.""" + import socket + import threading + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + finished = threading.Event() + + def serve() -> None: + try: + data, addr = sock.recvfrom(4096) + query = dns.message.from_wire(data) + for _ in range(spoofs): + # A well-formed response to a *different* query: exactly + # what a blind spoofer's guess looks like on the wire. + junk = dns.message.make_query("guessed.example.", "A") + junk.flags |= dns.flags.QR + sock.sendto(junk.to_wire(), addr) + reply = dns.message.make_response(query) + reply.flags |= dns.flags.AA + reply.answer.append(dns.rrset.from_text("a.test.", 300, "IN", "A", "1.2.3.4")) + sock.sendto(reply.to_wire(), addr) + except Exception: # pragma: no cover - the test asserts on the client side + pass + finally: + finished.set() + + threading.Thread(target=serve, daemon=True).start() + try: + yield port + finally: + finished.wait(timeout=5) + sock.close() + + @staticmethod + @contextlib.contextmanager + def _on_port(port: int, tcp_fallback: bool): + """Send to the ephemeral test port while leaving the resolver's code intact. + + Only the entry point the resolver actually calls is redirected, so + ``udp_with_fallback`` still reaches the real ``udp`` underneath. + """ + name = "dns.query.udp_with_fallback" if tcp_fallback else "dns.query.udp" + real = dns.query.udp_with_fallback if tcp_fallback else dns.query.udp + + def send(query, where, *args, **kwargs): + kwargs["port"] = port + return real(query, where, *args, **kwargs) + + with patch(name, side_effect=send): + yield + + def _ask(self, port: int, **kwargs): + resolver = offline_resolver(dnssec=False, **kwargs) + ctx = resolver._new_context() + with self._on_port(port, resolver.use_tcp_fallback): + return resolver._query_once(dns.name.from_text("a.test."), dns.rdatatype.A, "127.0.0.1", 1232, 4.0, ctx) + + def test_a_spoofed_reply_before_the_real_one_is_discarded(self) -> None: + with self._server(spoofs=1) as port: + response = self._ask(port) + assert [str(rr) for rrset in response.answer for rr in rrset] == ["1.2.3.4"] + + def test_a_burst_of_spoofed_replies_is_discarded(self) -> None: + with self._server(spoofs=5) as port: + response = self._ask(port) + assert response.answer, "a flood of wrong-ID packets denied a real answer" + + def test_the_same_holds_without_tcp_fallback(self) -> None: + with self._server(spoofs=1) as port: + response = self._ask(port, use_tcp_fallback=False) + assert response.answer From a80ff02ff72c8cad7a05aad07e7adb882b1bf003 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Wed, 2 Sep 2026 15:39:16 +0200 Subject: [PATCH 2/5] review fixes --- Makefile | 31 +++++++++--- SECURITY.md | 2 +- TESTING.md | 52 ++++++++++++++------ scripts/audit.py | 17 +++++-- scripts/cassette.py | 55 +++++++++++++++++---- scripts/mutation_check.py | 60 ++++++++++++++++++----- scripts/verdict_harness.py | 39 +++++++++++++-- src/recursive_resolver/addresses.py | 24 +++++----- src/recursive_resolver/cli.py | 8 +++- src/recursive_resolver/dnssec.py | 12 +++-- tests/signed_zone.py | 7 +-- tests/test_cli.py | 17 +++++++ tests/test_dnssec.py | 12 ----- tests/test_protocol_harness.py | 74 ++++++++++++++++++++++++++++- 14 files changed, 325 insertions(+), 85 deletions(-) diff --git a/Makefile b/Makefile index 5653547..4c3800f 100644 --- a/Makefile +++ b/Makefile @@ -54,17 +54,36 @@ CORPUS ?= corpus.csv ADVERSARIAL ?= adversarial.csv CASSETTES ?= cassettes.jsonl -test-corpus: ## Build the adversarial corpus (network, ~10 min) - uv run python scripts/collect_domains_diverse.py -o $(CORPUS) --limit 30000 - uv run python scripts/collect_domains_adversarial.py --csv $(CORPUS) -o $(ADVERSARIAL) +# The later stages read the files the earlier ones write, so those files are the +# dependencies - not the phony targets. A phony prerequisite would rebuild the +# 30,000-name corpus on every verdict run; a file one reuses what is there and +# collects only when it is missing. Delete the file to force a fresh collection. +# +# A collector killed halfway leaves a partial file, which Make would otherwise +# treat as a finished one and every later stage would read as the corpus. +.DELETE_ON_ERROR: + +$(CORPUS): + uv run python scripts/collect_domains_diverse.py -o $@ --limit 30000 + +$(ADVERSARIAL): $(CORPUS) + uv run python scripts/collect_domains_adversarial.py --csv $< -o $@ + +$(CASSETTES): $(ADVERSARIAL) + uv run python scripts/cassette.py record --csv $< -o $@ --types A,MX + +test-corpus: $(ADVERSARIAL) ## Build the adversarial corpus (network, ~10 min) -test-verdicts: ## Verdict differential and flap detection (network, ~1h) +test-verdicts: $(ADVERSARIAL) ## Verdict differential and flap detection (network, ~1h) uv run python scripts/verdict_harness.py --csv $(ADVERSARIAL) -o verdicts.csv --escalate 8 -test-record: ## Record cassettes for offline replay (network) - uv run python scripts/cassette.py record --csv $(ADVERSARIAL) -o $(CASSETTES) --types A,MX +test-record: $(CASSETTES) ## Record cassettes for offline replay (network) +# The only target here that must never reach the network, so it asks for the +# cassettes rather than depending on them: a missing file is an instruction to +# the operator, not a licence to spend ten minutes recording one. test-offline: ## Replay cassettes under every order and fault, then mutate (no network) + @test -f $(CASSETTES) || { echo "$(CASSETTES) not found; run 'make test-record' first (needs network)" >&2; exit 1; } uv run python scripts/cassette.py replay --cassettes $(CASSETTES) uv run python scripts/cassette.py perturb --cassettes $(CASSETTES) uv run python scripts/mutation_check.py --cassettes $(CASSETTES) diff --git a/SECURITY.md b/SECURITY.md index a139641..c4aa666 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,7 +18,7 @@ This library is designed to be pointed at domain names chosen by an attacker. Th | Attack | Control | |---|---| -| **DNS-driven SSRF**: glue pointing at `127.0.0.1`, RFC1918, or `169.254.169.254` to probe internal networks or a cloud metadata endpoint | Every candidate nameserver address must be globally routable and none of loopback, link-local, multicast, reserved or private, per the IANA special-purpose registries as tracked by Python's `ipaddress`. A short explicit list adds ranges classification does not refuse, or did not always refuse: Azure's `168.63.129.16`, 6to4 relay anycast, ORCHIDv2, deprecated site-local, and the IPv6 transition prefixes that carry an IPv4 address inside them (6to4, Teredo, NAT64, IPv4-compatible), whose stdlib classification changed with CVE-2024-4032. IPv4-mapped IPv6 is unwrapped first so it cannot bypass the v4 rules | +| **DNS-driven SSRF**: glue pointing at `127.0.0.1`, RFC1918, or `169.254.169.254` to probe internal networks or a cloud metadata endpoint | Every candidate nameserver address must be globally routable and none of loopback, link-local, multicast, reserved or private, per the IANA special-purpose registries as tracked by Python's `ipaddress`. A short explicit list adds ranges classification does not refuse, or did not always refuse: Azure's `168.63.129.16`, 6to4 relay anycast, ORCHIDv2, deprecated site-local, and the IPv6 transition prefixes that carry an IPv4 address inside them (6to4, Teredo, NAT64, IPv4-compatible). CVE-2024-4032 changed how the standard library classifies several of those prefixes; the others a current stdlib already refuses, and are listed as explicit policy so the control behaves the same on every supported Python. IPv4-mapped IPv6 is unwrapped first so it cannot bypass the v4 rules | | **NXNSAttack / Non-Responsive Delegation amplification** (CVE-2020-8616, CVE-2020-12662, CVE-2022-3204): a zone that answers every query with a glueless referral to many fresh NS names, using the resolver as a DDoS amplifier | A budget shared across the whole resolution and all sub-resolutions: 64 queries, 5 failed NS targets, 130 referrals, 13 NS names per referral (randomly sampled, not a prefix) | | **Referral loops and lame delegation** | A referral must name a zone strictly below the zone queried; sideways and upward referrals are rejected | | **Cache poisoning via out-of-bailiwick glue** | Bailiwick is evaluated against the zone we queried, never against a zone name supplied in the response | diff --git a/TESTING.md b/TESTING.md index a675ffa..65c3d5c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,8 +6,8 @@ validator that could be made to accept forged data by anyone who owned a signed domain, and that returned a false `DNSSECValidationError` on ordinary names. This document is the protocol built in response: what to run, against what -corpus, and what has to hold. Some of it exists and has been run; some of it is -built but unexercised; some is a proposal. Each section says which. +corpus, and what has to hold. Layers 0 to 4 and 6 exist and were run for this +release; layers 5 and 7 are proposals and say so in their headings. ## Why none of the existing testing found it @@ -63,7 +63,7 @@ per-resolution ledger. Production code carries no test hooks. | I1 | A DNSSEC verdict requires retrieved material: if any fetch of validation material came back empty-handed, the resolution must end in `DNSSECMaterialUnavailableError`, never a `DNSSECError`. | | I2 | Every query sent while validating carries EDNS0, so it can carry DO (RFC 4035 §3.2.1). | | I3 | No zone is judged on one server's word: a failed material fetch must have asked every usable address in the NS set. | -| I4 | Nothing is accepted from a response the resolver itself classified as unusable (no AA, wrong class, out of bailiwick). | +| I4 | Nothing is accepted from a response the resolver itself classified as unusable (no AA, wrong class, out of bailiwick), judged against the resolver's own `require_authoritative` setting. | | I5 | `DNSSECMaterialUnavailableError` is only raised when a fetch really did return nothing, so it cannot become a way to hide a validation failure. | I1 alone catches this release's first bug the moment it fires, on any corpus, @@ -114,12 +114,19 @@ is reached. The references do not expose a verdict, so each is asked twice: `SERVFAIL` that becomes an answer under CD=1 is bogus, `NOERROR` with AD is secure, `NOERROR` without AD is insecure. +The panel is first reduced to a verdict of its own. Each reference falls into +one of secure, bogus or insecure, and a group counts only with a two-thirds +majority behind it: with five validators, the odd one running a stale cache, a +negative trust anchor or an algorithm it will not verify cannot decide +anything on its own. A panel with no two-thirds group is `references-disagree`, +reported as a disagreement rather than quietly resolved in our favour. + Rules, each a failure rather than a report line: | Ours | Required of the references | |---|---| -| `SECURE` | at least one says secure | -| `INSECURE` | none says secure. The dangerous direction is a signature we failed to notice | +| `SECURE` | secure holds the majority. A lone secure among four insecure is a disagreement, not agreement | +| `INSECURE` | secure does not hold the majority, or insecure also does. The dangerous direction is a signature we failed to notice | | `BOGUS` | all but at most one refuse (SERVFAIL without CD, data with CD) | | unavailable / failed | flagged when the references all resolved it | @@ -135,9 +142,11 @@ off rather than DNSSEC off in every config but one. ## Layer 4: cassette replay with systematic perturbation -Built and unit-tested, but not exercised against a recorded corpus for this -release: every mutation run below reports `cassettes skipped`. What follows is -what it does, not a result it has produced. +Run for this release: 397 cassettes over 23,320 recorded responses, 19,453 +perturbed replays, no failures. Five of the mutations below are caught here +independently of the unit suite - the retrieval-failure family, which is the +class this layer exists for. The other forty-five survive it, and are meant to: +they need input the honest internet does not send. ```bash python scripts/cassette.py record --csv adversarial.csv -o cassettes.jsonl --types A,MX @@ -162,7 +171,17 @@ What must hold, checked on every replay along with the Layer 0 invariants: unavailable; - a **signature-removing** fault may additionally produce BOGUS, because data that should be signed and is not is what tampering looks like; it must never - produce a weaker verdict than the baseline. + produce a weaker verdict than the baseline; +- an exception that is not a `ResolverError` fails outright, whatever the + fault and whether or not it reproduces. The README promises none escapes, + and this is where that promise meets 19,000 adversarial replays. + +`record` drops any name whose cassette does not reproduce its own baseline +offline, and says which. Three of 400 did not: zones whose nameservers disagree +with each other, so replaying with a different one first walks into a branch +the recording never entered. Keeping them would mean `replay` failing forever +and `perturb` measuring every scenario against an outcome that was never +recorded. The retrieval bugs 0.2.0 fixes are the shape this enumerates: each depends on which server answered first, which is exactly what forcing every order removes. @@ -461,8 +480,13 @@ audit wants repeating whenever this code changes. The mutation catalogue in `scripts/mutation_check.py` now holds 50 entries, one per defect found across all three methods. Each reintroduces the original bug -into a copy of the package and confirms the suite still fails. Every one is -currently caught. That is the only evidence that the tests written alongside a -fix actually test it - and it caught one case where a mutation preserved the -file's byte length, landed in the same mtime second, and let Python reuse stale -bytecode so the mutant never ran at all. +into a copy of the package and confirms the suite still fails. That is the only +evidence that the tests written alongside a fix actually test it. + +Getting there needed a fix to the harness itself. One mutation preserved the +file's byte length and landed in the same mtime second as the `.pyc` copied +alongside it, so Python reused the stale bytecode: the mutant never executed, +and the report called it caught. The copy now excludes `__pycache__` and +`*.pyc`, so every mutant is compiled from the mutated source. Re-run after the +fix: that mutant executes, and the suite fails on it like the rest. All 50 are +caught, and all 50 now demonstrably ran. diff --git a/scripts/audit.py b/scripts/audit.py index 5a69142..800a6b8 100644 --- a/scripts/audit.py +++ b/scripts/audit.py @@ -21,7 +21,9 @@ I3 No zone is judged on one server's word. A fetch of validation material that failed must have asked every usable address in the NS set. I4 Nothing is accepted from a response the resolver itself classified as - unusable (non-authoritative answer, wrong class, out of bailiwick). + unusable (non-authoritative answer, wrong class, out of bailiwick). Held + to the resolver's own policy: with ``require_authoritative=False`` a + non-authoritative answer is a configured choice, not a violation. I5 ``DNSSECMaterialUnavailableError`` is only raised when a fetch really did return nothing: it must not become a way to hide a validation failure. """ @@ -66,6 +68,10 @@ class Ledger: fetches: list[Fetch] = field(default_factory=list) queries: list[tuple[str, str, str, int | None, bool]] = field(default_factory=list) classifications: list[tuple[str, bool, str]] = field(default_factory=list) + # I4 measures the resolver against its own rule, so it has to know what + # that rule is. `require_authoritative=False` is one of the configurations + # the verdict harness sweeps, and there every non-AA answer is deliberate. + require_authoritative: bool = True def failed_material_fetches(self) -> list[Fetch]: return [f for f in self.fetches if f.material and not f.got_response] @@ -95,9 +101,10 @@ def violations(self, outcome: BaseException | None) -> list[str]: ) # I4 - for qname, authoritative, kind in self.classifications: - if kind in ("answer", "nxdomain", "nodata") and not authoritative: - out.append(f"I4: accepted a {kind} for {qname} without the AA bit") + if self.require_authoritative: + for qname, authoritative, kind in self.classifications: + if kind in ("answer", "nxdomain", "nodata") and not authoritative: + out.append(f"I4: accepted a {kind} for {qname} without the AA bit") # I5 if isinstance(outcome, DNSSECMaterialUnavailableError) and not failed: @@ -109,7 +116,7 @@ def violations(self, outcome: BaseException | None) -> list[str]: @contextmanager def audited(resolver: RecursiveResolver): """Record one resolver's activity. Yields the :class:`Ledger`.""" - ledger = Ledger() + ledger = Ledger(require_authoritative=resolver.require_authoritative) real_send = resolver._send_query real_query = resolver._query_once real_classify = resolver._classify_response diff --git a/scripts/cassette.py b/scripts/cassette.py index 200298c..5b6588b 100644 --- a/scripts/cassette.py +++ b/scripts/cassette.py @@ -86,7 +86,10 @@ TIMEOUT = "timeout" MESH_LIMIT = 6 # addresses probed per (qname, type); keeps root fan-out bounded -CLOSURE_ROUNDS = 3 # passes of "replay, then record what the replay could not answer" +CLOSURE_ROUNDS = 6 # passes of "replay, then record what the replay could not answer" +# Each pass reaches one level further, so a CNAME redirected into a zone the +# original resolution never entered needs several: root, TLD, that zone's +# nameservers, then the name itself. AVAILABILITY_FAULTS = ("timeout", "servfail", "formerr", "empty-answer", "no-aa") SIGNATURE_FAULTS = ("strip-rrsig", "strip-dnssec") @@ -114,7 +117,15 @@ def outcome_of(answer, exc) -> str: return "nodata" if isinstance(exc, DNSSECError): return "bogus" - return f"error/{type(exc).__name__}" + if isinstance(exc, ResolverError): + # A real resolver error this function has no separate bucket for - + # MaxDepthError, CNAMELoopError and the like. Legitimate, and a fault + # can legitimately turn one into another. + return f"error/{type(exc).__name__}" + # Not a ResolverError at all. README: "Every failure is a `ResolverError`. + # Nothing from dnspython escapes." This is the layer with 19,000 adversarial + # replays in it, so it is the place that promise gets tested. + return f"leaked/{type(exc).__name__}" def key_of(qname, rdtype, server: str) -> str: @@ -183,6 +194,14 @@ def query_once(qname, rdtype_, server, payload, timeout_, ctx): break for key in gaps: fetch_into(entries, key, timeout) + + # A cassette that cannot reproduce its own baseline tests nothing: `replay` + # fails on it forever, and `perturb` would measure every scenario against + # an outcome the recording never had. Drop it here rather than write a + # cassette every later layer has to special-case. + replayed, _violations = replay(case) + if replayed != baseline: + return None return case @@ -291,6 +310,10 @@ def apply_fault(response: dns.message.Message, kind: str, payload: int | None) - def transition_allowed(baseline: str, observed: str, fault_kind: str) -> bool: """Is this a legitimate consequence of the injected fault?""" + # Never, whatever else is true. An exception that is not a ResolverError is + # a broken promise, not an outcome, and reproducing it does not make it one. + if baseline.startswith("leaked/") or observed.startswith("leaked/"): + return False if observed == baseline: return True if observed == "unavailable": @@ -348,15 +371,21 @@ def work(item): done += 1 if done % 50 == 0: print(f" recorded {done}/{len(items)} in {time.time() - started:.0f}s", file=sys.stderr) - return case + return item, case with ThreadPoolExecutor(max_workers=args.workers) as pool: - cases = [c for c in pool.map(work, items) if c] + attempted = list(pool.map(work, items)) + cases = [case for _item, case in attempted if case] + dropped = [item for item, case in attempted if not case] with open(args.output, "w", encoding="utf-8") as handle: for case in cases: handle.write(json.dumps(case) + "\n") sizes = sum(len(c["entries"]) for c in cases) print(f"\n{len(cases)} cassettes, {sizes} recorded responses, {time.time() - started:.0f}s", file=sys.stderr) + if dropped: + print(f" {len(dropped)} not recorded (no stable offline reproduction):", file=sys.stderr) + for domain, rdtype in dropped[:20]: + print(f" {domain}/{rdtype}", file=sys.stderr) return 0 cases = [json.loads(line) for line in Path(args.cassettes).read_text(encoding="utf-8").splitlines() if line] @@ -392,13 +421,19 @@ def scenarios(case: dict): for fault in FAULTS: yield ("fault", server, fault) - def run_case(case: dict) -> tuple[int, list[str]]: + def run_case(case: dict) -> tuple[int, list[str], str]: local: list[str] = [] ran = 0 - # A cassette that does not reproduce cannot judge a perturbation. + # A cassette that does not reproduce cannot judge a perturbation: every + # scenario would be measured against an outcome the recording never + # had. Set the whole cassette aside rather than compare to the wrong + # baseline. `replay` is the layer that gates reproduction, and reports + # these by name; here they are counted so the coverage is visible. base, base_violations = replay(case) if base_violations: local.append(f"{case['domain']}/{case['rdtype']} baseline: {base_violations}") + if base != case["baseline"]: + return ran, local, f"{case['domain']}/{case['rdtype']}: recorded {case['baseline']}, replayed {base}" for kind, server, fault in scenarios(case): ran += 1 if kind == "order": @@ -415,17 +450,21 @@ def run_case(case: dict) -> tuple[int, list[str]]: local.append(f"{case['domain']}/{case['rdtype']} [{label}] {base} -> {observed}") for violation in violations: local.append(f"{case['domain']}/{case['rdtype']} [{label}] {violation}") - return ran, local + return ran, local, "" with ThreadPoolExecutor(max_workers=args.workers) as pool: results = list(pool.map(run_case, cases)) total = sum(r[0] for r in results) - for _ran, local in results: + skipped = [r[2] for r in results if r[2]] + for _ran, local, _skip in results: failures.extend(local) print(f"\n{len(cases)} cassettes, {total} perturbed replays in {time.time() - started:.0f}s", file=sys.stderr) for outcome, count in counts.most_common(): print(f" {outcome:22s} {count:7d}", file=sys.stderr) + print(f" not reproduced, so not perturbed: {len(skipped)}", file=sys.stderr) + for skip in skipped[:20]: + print(f" {skip}", file=sys.stderr) print(f" failures: {len(failures)}", file=sys.stderr) for failure in failures[:60]: print(f" {failure}", file=sys.stderr) diff --git a/scripts/mutation_check.py b/scripts/mutation_check.py index b856b4d..378a310 100644 --- a/scripts/mutation_check.py +++ b/scripts/mutation_check.py @@ -688,31 +688,54 @@ def apply_mutation(target: Path, edits: list[tuple[str, str]]) -> None: + """Apply every edit exactly once, across both files. + + Every edit has to land. Applying some and reporting success would run the + suites against a half-mutated build, which proves nothing about the defect + the entry names: an edit that no longer matches is a stale catalogue entry + and has to be said out loud. + """ + for old, _new in edits: + # An empty search string matches anything, so the rewrite would land at + # offset 0 and break the file: caught by every layer, and a test of + # nothing. + if not old: + raise SystemExit("mutation has an empty search string") + + pending = list(edits) for name in ("resolver.py", "dnssec.py"): path = target / "recursive_resolver" / name text = path.read_text(encoding="utf-8") - changed = False - for old, new in edits: - # An empty search string matches anything, so the rewrite would - # land at offset 0 and break the file: caught by every layer, and - # a test of nothing. - if not old: - raise SystemExit("mutation has an empty search string") + applied = [] + for old, new in pending: if old in text: text = text.replace(old, new, 1) - changed = True - if changed: + applied.append((old, new)) + if applied: path.write_text(text, encoding="utf-8") - return - raise SystemExit(f"mutation no longer applies: {edits[0][0][:60]!r}") + pending = [edit for edit in pending if edit not in applied] + + if pending: + raise SystemExit(f"mutation no longer applies: {pending[0][0][:60]!r}") + +# pytest exits 0 when everything passed and 1 when a test failed. Anything else +# is the harness itself going wrong - a collection error, a usage mistake, an +# interpreter that will not start - and reading it as "the mutant was caught" +# turns a broken run into a clean report. +PYTEST_EXIT_CODES = frozenset({0, 1}) -def run(cmd: list[str], env_src: Path, cwd: Path) -> bool: + +def run(cmd: list[str], env_src: Path, cwd: Path, expected: frozenset[int] | None = None) -> bool: """True when the command passes (i.e. the mutant survived this layer).""" import os env = dict(os.environ, RR_SRC=str(env_src), PYTHONPATH=str(env_src)) result = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=str(cwd)) + if expected is not None and result.returncode not in expected: + raise SystemExit( + f"{cmd[0]} exited {result.returncode}, which is neither pass nor fail:\n{result.stdout[-2000:]}" + ) return result.returncode == 0 @@ -727,6 +750,18 @@ def main() -> int: if not mutations: raise SystemExit(f"no mutation named {args.only!r}") + # An unreadable cassette file makes every perturb run fail, which reads as + # "every mutant caught by the cassettes" - the most flattering possible + # result, produced by the harness being broken. + if args.cassettes: + cassettes = Path(args.cassettes) + if not cassettes.is_file(): + raise SystemExit(f"cassette file not found: {args.cassettes}") + try: + cassettes.open("rb").close() + except OSError as exc: + raise SystemExit(f"cannot read {args.cassettes}: {exc}") from None + survivors: list[str] = [] print(f"{'mutation':<44}{'unit suite':<14}{'cassettes':<14}caught by") for label, description, edits in mutations: @@ -758,6 +793,7 @@ def main() -> int: ], target, target, + expected=PYTEST_EXIT_CODES, ) cassette_ok = True if args.cassettes: diff --git a/scripts/verdict_harness.py b/scripts/verdict_harness.py index 678a9e5..b355c6c 100644 --- a/scripts/verdict_harness.py +++ b/scripts/verdict_harness.py @@ -57,6 +57,7 @@ from recursive_resolver import ( # noqa: E402 DNSSECError, DNSSECInsecureError, + InvalidNameError, NoAnswerError, NXDOMAINError, RecursiveResolver, @@ -185,10 +186,20 @@ def ascii_name(domain: str, rdtype: str) -> str: dnspython's default IDNA codec is IDNA 2003, ours is IDNA 2008, and for some internationalised names the two encode to *different domains*. Comparing verdicts means comparing them for the same name. + + There is no public entry point for this: the resolver normalises on its way + into `resolve`, and the whole point here is the name it would actually put + on the wire. Only the failure it defines is caught, so a name this build + refuses is compared as written and said so, rather than every error being + swallowed into a silently different query. """ try: return str(_NORMALISER._normalize_qname(domain, rdtype)) - except Exception: # noqa: BLE001 - an unencodable name is compared as given + except InvalidNameError as exc: + print( + f" note {domain}/{rdtype}: not a name we can encode ({exc}); asking the references as written", + file=sys.stderr, + ) return domain @@ -265,19 +276,39 @@ def compare(ours: Outcome, references: dict[str, str]) -> str: return "" +def positive_int(text: str) -> int: + """`--runs 0` leaves `outcomes` empty, and `outcomes[0]` then raises.""" + value = int(text) + if value < 1: + raise argparse.ArgumentTypeError(f"must be 1 or more, got {value}") + return value + + +def server_list(text: str) -> list[str]: + """An empty list means a ThreadPoolExecutor with no workers, which raises. + + Use ``--no-references`` to skip the reference resolvers; an empty + ``--references`` is a typo, not a way to ask for that. + """ + servers = [s.strip() for s in text.split(",") if s.strip()] + if not servers: + raise argparse.ArgumentTypeError("needs at least one server; use --no-references to skip them") + return servers + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--csv", required=True, help="Corpus CSV (domain,category[,tags])") parser.add_argument("-o", "--output", default="verdicts.csv", help="Output CSV path") parser.add_argument("--types", default=DEFAULT_TYPES, help=f"Record types (default {DEFAULT_TYPES})") - parser.add_argument("--runs", type=int, default=1, help="Resolutions per name before escalation") + parser.add_argument("--runs", type=positive_int, default=1, help="Resolutions per name before escalation") parser.add_argument("--escalate", type=int, default=8, help="Resolutions for anomalous names") parser.add_argument("--sample", type=int, default=0, help="Random sample of N names (0 = all)") parser.add_argument("--seed", type=int, default=20260830, help="Sampling seed") parser.add_argument("--workers", type=int, default=16, help="Concurrent names") parser.add_argument("--timeout", type=float, default=3.0, help="Per-query timeout") parser.add_argument("--no-references", action="store_true", help="Skip the reference resolvers") - parser.add_argument("--references", default=",".join(REFERENCES), help="Reference resolver IPs") + parser.add_argument("--references", type=server_list, default=",".join(REFERENCES), help="Reference resolver IPs") parser.add_argument("--config", default="default", help=f"Resolver options from: {','.join(CONFIGS)}") parser.add_argument("--append", action="store_true", help="Append to the output file instead of replacing it") args = parser.parse_args() @@ -285,7 +316,7 @@ def main() -> int: if args.config not in CONFIGS: raise SystemExit(f"unknown config {args.config!r}; choose from {', '.join(CONFIGS)}") options = CONFIGS[args.config] - reference_servers = [s.strip() for s in args.references.split(",") if s.strip()] + reference_servers = args.references types = [t.strip().upper() for t in args.types.split(",") if t.strip()] with open(args.csv, newline="", encoding="utf-8") as handle: diff --git a/src/recursive_resolver/addresses.py b/src/recursive_resolver/addresses.py index dd1af54..536e1ef 100644 --- a/src/recursive_resolver/addresses.py +++ b/src/recursive_resolver/addresses.py @@ -13,15 +13,16 @@ allocates, and it covers CGNAT, the documentation prefixes, ``0.0.0.0/8``, ``240.0.0.0/4`` and benchmarking space. -A short built-in list then adds the ranges that classification does not refuse, -or did not always refuse. Two kinds: addresses that look public but serve -instance metadata (Azure's ``168.63.129.16``), and the IPv6 transition prefixes -that carry an IPv4 address inside them. Those last matter because a resolver -running on a patched interpreter and one running on 3.10.14 do not agree about -them: CVE-2024-4032 corrected ``is_private``/``is_global`` for exactly this -family, so ``2002:a9fe:a9fe::1`` - link-local metadata wrapped in 6to4 - was -globally routable to an older stdlib. Listing them makes the control the same -on every supported version. +A short built-in list then adds the ranges classification does not refuse, or +did not always refuse. Two kinds: addresses that look public but serve instance +metadata (Azure's ``168.63.129.16``), and the IPv6 transition prefixes that +carry an IPv4 address inside them. CVE-2024-4032 corrected +``is_private``/``is_global`` for several of those prefixes, so an interpreter +older than 3.10.14 does not agree with a current one about, say, +``2002:a9fe:a9fe::1`` - link-local metadata wrapped in 6to4. The rest of the +family a current stdlib does refuse; they are listed anyway, as policy, because +none of them is a nameserver location and the control should not read +differently on different supported versions. The order of the checks below (specific categories before ``is_private``, since loopback and link-local are subsets of it in Python's model), the ``is_global`` @@ -53,8 +54,9 @@ # Deprecated IPv6 site-local (RFC 3879); some networks still route it. "fec0::/10", # IPv6 transition prefixes: each embeds an IPv4 address, so reaching one - # reaches whatever that address is. Not a nameserver location either way, - # and stdlib classification of them varies by patch level. + # reaches whatever that address is. Not a nameserver location either way. + # Classification refuses these on a current stdlib; some of them only since + # CVE-2024-4032, so they are stated here rather than assumed. "2002::/16", # 6to4 (RFC 3056) "2001::/32", # Teredo (RFC 4380) "64:ff9b::/96", # NAT64 well-known prefix (RFC 6052) diff --git a/src/recursive_resolver/cli.py b/src/recursive_resolver/cli.py index cac214b..744ca09 100644 --- a/src/recursive_resolver/cli.py +++ b/src/recursive_resolver/cli.py @@ -28,10 +28,16 @@ def _json_dnssec(answer: Answer | None, args: argparse.Namespace) -> str | None: ``--no-dnssec`` leaves the default INSECURE on the Answer, which in JSON reads as "we looked and the zone is unsigned". Nothing was looked at, so say so. + + No answer still means no verdict. A trace that failed has nothing to report + a state for, and "disabled" there would describe the configuration where + the caller is reading a result. """ + if answer is None: + return None if args.no_dnssec: return "disabled" - return answer.dnssec.value if answer is not None else None + return answer.dnssec.value def _note_dnssec(answer: Answer, args: argparse.Namespace) -> None: diff --git a/src/recursive_resolver/dnssec.py b/src/recursive_resolver/dnssec.py index 17c5555..b8e2535 100644 --- a/src/recursive_resolver/dnssec.py +++ b/src/recursive_resolver/dnssec.py @@ -216,6 +216,10 @@ class DNSSECValidator: so it is rejected rather than silently treated as "use the defaults". max_nsec3_iterations: Reject NSEC3 records above this iteration count. + clock_skew: Seconds of slack allowed on an RRSIG's inception, for + signers whose clock runs ahead of ours (60 by default). Applies to + inception only: expiration is checked against the real time, so + slack can never keep an expired signature alive. Raises: ValueError: If ``trust_anchors`` is empty. @@ -706,10 +710,10 @@ def prove_nxdomain( Returns: SECURE when the name error is proven, BOGUS when nothing proves it, and INSECURE when either cover an RFC 5155 §8.4 proof needs - the - next closer name's or the wildcard's - is **opt-out** only. Opt-out asserts that a range contains no signed - delegations, not that it contains no names (RFC 5155 §6), so a name - inside it may exist as an unsigned delegation and the name error is - not proven. This is not a hypothetical: in an opt-out TLD every + next closer name's or the wildcard's - is **opt-out** only. Opt-out + asserts that a range contains no signed delegations, not that it + contains no names (RFC 5155 §6), so a name inside it may exist as an + unsigned delegation and the name error is not proven. This is not a hypothetical: in an opt-out TLD every unsigned domain sits in such a range, and the records needed are public and correctly signed, so treating the result as authenticated would let anyone forge an authenticated "does not exist" for them. diff --git a/tests/signed_zone.py b/tests/signed_zone.py index 3e6ed31..dd205dc 100644 --- a/tests/signed_zone.py +++ b/tests/signed_zone.py @@ -111,11 +111,8 @@ def revoked(self) -> SignedZone: against it. That is the point: a validator that ignores the bit keeps trusting a key its owner has publicly withdrawn. """ - revoked_key = dns.rdata.from_text( - dns.rdataclass.IN, - dns.rdatatype.DNSKEY, - f"{self.dnskey.flags | REVOKE} {self.dnskey.protocol} {self.dnskey.algorithm} " - f"{dns.rdata._base64ify(self.dnskey.key, chunksize=0)}", + revoked_key = dns.dnssec.make_dnskey( + self.private_key.public_key(), self.dnskey.algorithm, flags=self.dnskey.flags | REVOKE ) rrset = dns.rrset.RRset(self.name, dns.rdataclass.IN, dns.rdatatype.DNSKEY) rrset.add(revoked_key) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3823e5b..b575699 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -294,6 +294,23 @@ def test_text_mode_still_reports(self, capsys: pytest.CaptureFixture) -> None: assert err.strip() == "; fully validated" assert out.strip() == "v=spf1 -all" + def test_a_failed_trace_reports_no_verdict_even_with_no_dnssec(self, capsys: pytest.CaptureFixture) -> None: + """No answer, no verdict. "disabled" would describe the wrong thing.""" + step = TraceStep(server="1.2.3.4", qname="example.com.", rdtype="A", response_type="timeout") + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.trace_answer.return_value = (None, [step]) + main(["--trace", "--json", "--no-dnssec", "example.com"]) + payload = json.loads(capsys.readouterr().out) + assert payload["dnssec"] is None + assert payload["error"] == "ResolutionIncomplete" + + def test_a_completed_trace_still_says_disabled(self, capsys: pytest.CaptureFixture) -> None: + step = TraceStep(server="1.2.3.4", qname="example.com.", rdtype="A", response_type="answer") + with patch("recursive_resolver.cli.RecursiveResolver") as cls: + cls.return_value.trace_answer.return_value = (_answer(dnssec=ValidationState.INSECURE), [step]) + main(["--trace", "--json", "--no-dnssec", "example.com"]) + assert json.loads(capsys.readouterr().out)["dnssec"] == "disabled" + def test_trace_mode_reports_before_the_records(self, capsys: pytest.CaptureFixture) -> None: answer = _answer() step = TraceStep(server="1.2.3.4", qname="example.com.", rdtype="A", response_type="answer") diff --git a/tests/test_dnssec.py b/tests/test_dnssec.py index 635c237..99f86c3 100644 --- a/tests/test_dnssec.py +++ b/tests/test_dnssec.py @@ -1559,18 +1559,6 @@ def test_a_labels_count_leaving_no_next_closer_is_refused(self) -> None: assert v.prove_wildcard(self.QNAME, 3, [], {}) is ValidationState.BOGUS -def _predecessor(digest: str) -> str: - """The immediately preceding base32hex hash of the same length.""" - chars = list(digest) - for i in range(len(chars) - 1, -1, -1): - idx = B32HEX.index(chars[i]) - if idx > 0: - chars[i] = B32HEX[idx - 1] - return "".join(chars) - chars[i] = B32HEX[-1] - return "".join(chars) - - class TestNSEC3NXDOMAINProof: """RFC 5155 §8.4 needs three things, and dropping any one must fail. diff --git a/tests/test_protocol_harness.py b/tests/test_protocol_harness.py index 3e713e8..ecd9dac 100644 --- a/tests/test_protocol_harness.py +++ b/tests/test_protocol_harness.py @@ -21,10 +21,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) from audit import Fetch, Ledger, audited # noqa: E402 -from cassette import apply_fault, transition_allowed # noqa: E402 +from cassette import apply_fault, outcome_of, transition_allowed # noqa: E402 +from verdict_harness import SECURE, Outcome, compare # noqa: E402 from recursive_resolver import DNSSECValidationError, RecursiveResolver # noqa: E402 -from recursive_resolver.exceptions import DNSSECMaterialUnavailableError # noqa: E402 +from recursive_resolver.exceptions import ( # noqa: E402 + DNSSECMaterialUnavailableError, + MaxDepthError, +) def _fetch(*, material=True, got=True, offered=("9.9.9.9", "1.1.1.1"), asked=("9.9.9.9", "1.1.1.1")) -> Fetch: @@ -61,6 +65,24 @@ def test_i4_an_answer_accepted_without_the_aa_bit(self) -> None: ledger = Ledger(classifications=[("example.test.", False, "answer")]) assert any(v.startswith("I4") for v in ledger.violations(None)) + def test_i4_is_silent_when_the_resolver_does_not_require_authority(self) -> None: + """`lax-aa` is one of the configurations the verdict harness sweeps. + + Reporting a violation there flags the resolver for doing exactly what + it was told, and a gating harness that cries wolf gets ignored. + """ + ledger = Ledger(classifications=[("example.test.", False, "answer")], require_authoritative=False) + assert ledger.violations(None) == [] + + def test_the_ledger_takes_the_policy_from_the_resolver(self) -> None: + lax = RecursiveResolver(dnssec=False, cache_enabled=False, require_authoritative=False) + with audited(lax) as ledger: + pass + assert ledger.require_authoritative is False + with audited(RecursiveResolver(dnssec=False, cache_enabled=False)) as ledger: + pass + assert ledger.require_authoritative is True + def test_i5_material_reported_unavailable_when_every_fetch_answered(self) -> None: ledger = Ledger(fetches=[_fetch()]) outcome = DNSSECMaterialUnavailableError("example.test.", "DNSKEY") @@ -104,6 +126,20 @@ def test_reordering_alone_must_reproduce_the_outcome(self) -> None: assert transition_allowed("ok/secure", "ok/secure", "") assert not transition_allowed("ok/insecure", "ok/secure", "") + def test_a_leaked_exception_is_never_an_allowed_outcome(self) -> None: + """README: every failure is a ResolverError, nothing from dnspython escapes. + + `error/` is the escape hatch for resolver errors this harness has no + bucket for, and a fault may legitimately turn one into another. A + non-ResolverError is not in that category, and reproducing it under + every server order does not make it legitimate. + """ + assert outcome_of(None, MaxDepthError("a.test.", "A", 30)) == "error/MaxDepthError" + assert outcome_of(None, TypeError("boom")) == "leaked/TypeError" + assert transition_allowed("error/MaxDepthError", "nodata", "timeout") + assert not transition_allowed("nodata", "leaked/TypeError", "timeout") + assert not transition_allowed("leaked/TypeError", "leaked/TypeError", "") + class TestFaults: def test_each_fault_does_what_it_says(self) -> None: @@ -121,3 +157,37 @@ def test_each_fault_does_what_it_says(self) -> None: # FORMERR emulates a server that dislikes the OPT record, so a query # sent without EDNS must come back untouched. assert apply_fault(make_response(), "formerr", None).rcode() == dns.rcode.NOERROR + + +class TestReferencePanel: + """A verdict needs a two-thirds majority of the panel, not one supporter. + + Five public validators do not always agree, and the odd one running a stale + cache or a negative trust anchor must not be able to ratify our answer on + its own. These pin the quorum, because the rule is the whole reason the + differential can fail a release. + """ + + @staticmethod + def _panel(*verdicts: str) -> dict[str, str]: + return {f"ref{i}": v for i, v in enumerate(verdicts)} + + def test_a_lone_secure_does_not_ratify_our_secure(self) -> None: + panel = self._panel(SECURE, "insecure", "insecure", "insecure", "insecure") + assert compare(Outcome(SECURE, ""), panel) == "we-say-secure-they-do-not" + + def test_a_two_thirds_secure_majority_does(self) -> None: + panel = self._panel(SECURE, SECURE, SECURE, SECURE, "insecure") + assert compare(Outcome(SECURE, ""), panel) == "" + + def test_an_evenly_split_panel_decides_nothing(self) -> None: + panel = self._panel(SECURE, SECURE, "insecure", "insecure") + assert compare(Outcome(SECURE, ""), panel) == "references-disagree" + + def test_a_secure_majority_against_our_insecure_is_the_dangerous_direction(self) -> None: + panel = self._panel(SECURE, SECURE, SECURE, SECURE, "bogus") + assert compare(Outcome("insecure", ""), panel) == "we-say-insecure-they-say-secure" + + def test_a_lone_secure_against_our_insecure_is_not(self) -> None: + panel = self._panel(SECURE, "insecure", "insecure", "insecure", "insecure") + assert compare(Outcome("insecure", ""), panel) == "" From 9bf1706f5dde8911749c731e037780fdbde01228 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Fri, 4 Sep 2026 09:13:50 +0200 Subject: [PATCH 3/5] more review fixes --- CHANGELOG.md | 9 +- TESTING.md | 63 ++++++++--- scripts/cassette.py | 24 +++-- scripts/mutation_check.py | 46 ++++++-- scripts/verdict_harness.py | 93 +++++++++++++---- src/recursive_resolver/resolver.py | 57 ++++++++++ tests/test_dnssec_signed.py | 121 +++++++++++++++++++++ tests/test_protocol_harness.py | 162 ++++++++++++++++++++++++++++- 8 files changed, 521 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 399392a..bc9d843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,8 +43,13 @@ a signed domain of their own. Upgrade before relying on a `SECURE` verdict from ### Fixed - One unreachable or out-of-sync nameserver no longer condemns a zone. DNSKEY - and DS fetches, bare referrals and failed denials all sweep the NS set - (RFC 4035 §5.5); a stale cached delegation re-resolves its NS names. + and DS fetches, bare referrals, failed denials and answers carrying no + signature at all now sweep the NS set (RFC 4035 §5.5); a stale cached + delegation re-resolves its NS names. The last of those is a nameserver + serving a stale *unsigned* copy of a signed zone: every type it answers + reads as forged and its DNSKEY NODATA proves nothing, so landing on it made + the whole zone intermittently BOGUS. One of `nic.bj`'s four nameservers is + such a copy. - A single spoofed UDP packet no longer retires a healthy nameserver (RFC 5452 §9). - DNAME is implemented: CNAME synthesis when the server omits it, and the diff --git a/TESTING.md b/TESTING.md index 65c3d5c..e0cc153 100644 --- a/TESTING.md +++ b/TESTING.md @@ -126,7 +126,7 @@ Rules, each a failure rather than a report line: | Ours | Required of the references | |---|---| | `SECURE` | secure holds the majority. A lone secure among four insecure is a disagreement, not agreement | -| `INSECURE` | secure does not hold the majority, or insecure also does. The dangerous direction is a signature we failed to notice | +| `INSECURE` | neither secure nor bogus holds the majority, unless insecure does too. Two dangerous directions: a signature we failed to notice, and data a majority refuses that we hand over | | `BOGUS` | all but at most one refuse (SERVFAIL without CD, data with CD) | | unavailable / failed | flagged when the references all resolved it | @@ -432,15 +432,47 @@ Re-running the whole pass after the fix: zero verdict flaps, zero disagreements, zero invariant violations. Ten BOGUS verdicts remain, all of them the deliberately-broken test zones, all agreed by every reference. +### What the 42,000-name run found + +A corpus ten times the size - 42,536 names over 33,534 zones, 85,067 lookups +for breadth and 19,143 more at full escalation over the 2,127 names the probe +flagged - found one defect, and it was the deviation the section below used to +list as accepted. + +A nameserver serving a stale **unsigned** copy of a signed zone answers +authoritatively with no RRSIG for any type, and NODATA for DNSKEY. Landing on +it made every record type in the zone read as forged, and the DNSKEY NODATA +unprovable, so the zone was BOGUS on roughly half of all lookups. `nic.bj` is +the live example: one of its four nameservers, `dns1.jenysas.bj`, is such a +copy, and 16 of 36 lookups failed against it. RFC 4035 §5.5 covers exactly +this, and the resolver already applied it to a denial whose proof does not +validate - just never to a positive answer or to an unsigned denial. Extending +the sweep costs 3 extra queries per 500 lookups, measured with both arms +interleaved over the same names, because the case it has to sweep past is rare +and short when it happens. + +The run also found a hole in the flap detector, which matters more than the +defect. A verdict change is excused when the zone handed back different data, +and "different data" was measured over every outcome - including the ones that +leave as an exception and therefore carry no records at all. A BOGUS always +has an empty record set, so every secure-to-bogus alternation looked like a +zone changing underneath us. `nic.bj` flapping on four record types was +reported across 120,000 lookups as **zero verdict flaps**. Only the reference +panel caught it, and only because the panel happened to agree. The rule now +counts a dataset only from outcomes that carried one. + +Re-running the escalated pass after both fixes: zero verdict flaps under the +stricter rule, zero invariant violations, and four disagreements down from +eight - the four remaining are two zones whose parents publish no DS, where +the panel splits against itself. + +The lesson is the one the gentle re-run already argued, sharpened: breadth +found the zone, but the detector that was supposed to gate on it had been +silently excusing the exact shape it exists to catch. A green gate is only +evidence if something has recently made it go red. + ### Disagreements left standing -- **A zone whose NS set mixes signed and unsigned copies.** RFC 4035 §5.5 says - a validator with other servers to try SHOULD try one before concluding an - answer is forged. This resolver does not: the retry would have to replace the - *answer*, handing the caller the RRset from the server that did sign, which - is a change to the answer path rather than to a verdict. The one zone in the - corpus that shows it has 3 of its 4 nameservers unreachable, and the public - validators disagreed with themselves on it within a single run. - **A CNAME chain load balanced across a signed and an unsigned target.** A 14-hop chain whose last hop varies per query. The verdict follows the branch the query got, correctly - the chain state is the weakest link over every @@ -478,15 +510,18 @@ would have missed, plus the opt-out NODATA above, because only real DNS is broken in the ways real DNS is broken. A release wants all three, and the audit wants repeating whenever this code changes. -The mutation catalogue in `scripts/mutation_check.py` now holds 50 entries, one -per defect found across all three methods. Each reintroduces the original bug -into a copy of the package and confirms the suite still fails. That is the only -evidence that the tests written alongside a fix actually test it. +The mutation catalogue in `scripts/mutation_check.py` now holds 52 entries: one +per defect found across all three methods, plus a plausible weakening of each +control that replaced one. Each reintroduces the bug into a copy of the package +and confirms the suite still fails. That is the only evidence that the tests +written alongside a fix actually test it. The newest pair covers the unsigned- +copy sweep in both directions - removing it, and letting it fire on a referral, +whose NS RRset is unsigned by design. Getting there needed a fix to the harness itself. One mutation preserved the file's byte length and landed in the same mtime second as the `.pyc` copied alongside it, so Python reused the stale bytecode: the mutant never executed, and the report called it caught. The copy now excludes `__pycache__` and `*.pyc`, so every mutant is compiled from the mutated source. Re-run after the -fix: that mutant executes, and the suite fails on it like the rest. All 50 are -caught, and all 50 now demonstrably ran. +fix: that mutant executes, and the suite fails on it like the rest. All 52 are +caught, and all 52 now demonstrably ran. diff --git a/scripts/cassette.py b/scripts/cassette.py index 5b6588b..1987050 100644 --- a/scripts/cassette.py +++ b/scripts/cassette.py @@ -132,6 +132,14 @@ def key_of(qname, rdtype, server: str) -> str: return f"{qname}|{dns.rdatatype.to_text(rdtype)}|{server}" +def positive_int(text: str) -> int: + """ThreadPoolExecutor refuses a worker count below one.""" + value = int(text) + if value < 1: + raise argparse.ArgumentTypeError(f"must be 1 or more, got {value}") + return value + + # ── record ────────────────────────────────────────────────────────────────── @@ -161,7 +169,11 @@ def query_once(qname, rdtype_, server, payload, timeout_, ctx): except ResolverError as exc: baseline = outcome_of(None, exc) records = [] - except Exception: # noqa: BLE001 - anything else is not a recordable case + except Exception as exc: # noqa: BLE001 - not a ResolverError, so not an outcome + # `outcome_of` has a bucket for this and `transition_allowed` refuses + # it, but neither is reached if the name never becomes a cassette. + # Dropping it silently makes the corpus smaller and says nothing. + print(f" {domain}/{rdtype}: leaked {type(exc).__name__}: {exc}", file=sys.stderr) return None # Complete the mesh: every server that was offered, for every question, so @@ -201,6 +213,7 @@ def query_once(qname, rdtype_, server, payload, timeout_, ctx): # cassette every later layer has to special-case. replayed, _violations = replay(case) if replayed != baseline: + print(f" {domain}/{rdtype}: recorded {baseline}, replayed {replayed}", file=sys.stderr) return None return case @@ -340,7 +353,7 @@ def main() -> int: rec.add_argument("--types", default="A,MX") rec.add_argument("--sample", type=int, default=200) rec.add_argument("--seed", type=int, default=20260830) - rec.add_argument("--workers", type=int, default=12) + rec.add_argument("--workers", type=positive_int, default=12) rec.add_argument("--timeout", type=float, default=3.0) rep = sub.add_parser("replay", help="Replay cassettes offline") @@ -349,7 +362,7 @@ def main() -> int: per = sub.add_parser("perturb", help="Replay under every order and fault") per.add_argument("--cassettes", required=True) per.add_argument("--max-servers", type=int, default=6, help="Servers to enumerate per case") - per.add_argument("--workers", type=int, default=4) + per.add_argument("--workers", type=positive_int, default=4) args = parser.parse_args() @@ -383,9 +396,8 @@ def work(item): sizes = sum(len(c["entries"]) for c in cases) print(f"\n{len(cases)} cassettes, {sizes} recorded responses, {time.time() - started:.0f}s", file=sys.stderr) if dropped: - print(f" {len(dropped)} not recorded (no stable offline reproduction):", file=sys.stderr) - for domain, rdtype in dropped[:20]: - print(f" {domain}/{rdtype}", file=sys.stderr) + names = ", ".join(f"{d}/{t}" for d, t in dropped[:20]) + print(f" {len(dropped)} not recorded, reasons above: {names}", file=sys.stderr) return 0 cases = [json.loads(line) for line in Path(args.cassettes).read_text(encoding="utf-8").splitlines() if line] diff --git a/scripts/mutation_check.py b/scripts/mutation_check.py index 378a310..8d83d1c 100644 --- a/scripts/mutation_check.py +++ b/scripts/mutation_check.py @@ -684,6 +684,24 @@ ) ], ), + ( + "stale-unsigned-server-condemns-the-zone", + "0.1.0: one nameserver serving an unsigned copy makes the whole zone bogus", + [ + ( + """ if not ctx.dnssec or self._validator is None or state is not ValidationState.SECURE: + return False + if kind not in ("answer", "cname", "nodata", "nxdomain"): + return False""", + """ return False""", + ) + ], + ), + ( + "an-unsigned-referral-is-swept-past", + "the same control over-firing: a delegation NS RRset is unsigned by design", + [(' if kind not in ("answer", "cname", "nodata", "nxdomain"):', " if False:")], + ), ] @@ -702,21 +720,25 @@ def apply_mutation(target: Path, edits: list[tuple[str, str]]) -> None: if not old: raise SystemExit("mutation has an empty search string") - pending = list(edits) + # Indices, not the (old, new) pairs themselves: two identical edits are two + # rewrites to make, and comparing by value would strike both off the list + # the first time one of them matched. + pending = set(range(len(edits))) for name in ("resolver.py", "dnssec.py"): path = target / "recursive_resolver" / name text = path.read_text(encoding="utf-8") - applied = [] - for old, new in pending: + applied = set() + for index in sorted(pending): + old, new = edits[index] if old in text: text = text.replace(old, new, 1) - applied.append((old, new)) + applied.add(index) if applied: path.write_text(text, encoding="utf-8") - pending = [edit for edit in pending if edit not in applied] + pending -= applied if pending: - raise SystemExit(f"mutation no longer applies: {pending[0][0][:60]!r}") + raise SystemExit(f"mutation no longer applies: {edits[min(pending)][0][:60]!r}") # pytest exits 0 when everything passed and 1 when a test failed. Anything else @@ -753,14 +775,18 @@ def main() -> int: # An unreadable cassette file makes every perturb run fail, which reads as # "every mutant caught by the cassettes" - the most flattering possible # result, produced by the harness being broken. + cassettes = "" if args.cassettes: - cassettes = Path(args.cassettes) - if not cassettes.is_file(): + path = Path(args.cassettes) + if not path.is_file(): raise SystemExit(f"cassette file not found: {args.cassettes}") try: - cassettes.open("rb").close() + path.open("rb").close() except OSError as exc: raise SystemExit(f"cannot read {args.cassettes}: {exc}") from None + # `run` starts the child with cwd=REPO, so a path relative to wherever + # this was invoked from would not resolve there. + cassettes = str(path.resolve()) survivors: list[str] = [] print(f"{'mutation':<44}{'unit suite':<14}{'cassettes':<14}caught by") @@ -798,7 +824,7 @@ def main() -> int: cassette_ok = True if args.cassettes: cassette_ok = run( - [args.python, str(REPO / "scripts/cassette.py"), "perturb", "--cassettes", args.cassettes], + [args.python, str(REPO / "scripts/cassette.py"), "perturb", "--cassettes", cassettes], target, REPO, ) diff --git a/scripts/verdict_harness.py b/scripts/verdict_harness.py index b355c6c..afd1e13 100644 --- a/scripts/verdict_harness.py +++ b/scripts/verdict_harness.py @@ -61,6 +61,7 @@ NoAnswerError, NXDOMAINError, RecursiveResolver, + ResolverError, ValidationState, ) from recursive_resolver.exceptions import DNSSECMaterialUnavailableError # noqa: E402 @@ -97,6 +98,10 @@ NODATA = "nodata" UNAVAILABLE = "unavailable" FAILED = "failed" +# Not an outcome. README: "Every failure is a `ResolverError`. Nothing from +# dnspython escapes." Anything else that reaches the caller is a broken promise, +# and filing it under FAILED would let it pass as one more unreachable zone. +LEAKED = "leaked" # Outcomes that say "we could not get there", not "here is what the data means". # A name that alternates between a verdict and one of these is reporting the @@ -104,18 +109,27 @@ # is reporting a defect in here. RETRIEVAL_KINDS = frozenset({UNAVAILABLE, FAILED}) -# Disagreements that mean we read the same data differently, as opposed to not -# having got the data at all. Only these gate a release. -VERDICT_DISAGREEMENTS = frozenset( +# Disagreements that mean we could not get the data, or that the panel could +# not agree with itself. Everything else `compare` can return is a statement +# about how *we* read data we did have, and gates the release. +# +# Listed this way round on purpose. The set used to name the gating labels, so +# a label added to `compare` and not added here was silently filed under "the +# internet, not us" and let a release through. Defaulting to gating means the +# mistake is a spurious failure rather than a missed one. +INFORMATIONAL_DISAGREEMENTS = frozenset( { - "we-say-secure-they-do-not", - "we-say-insecure-they-say-secure", - "false-bogus", - "we-answered-they-refused", + "references-disagree", + "material-unavailable-but-they-resolved", + "we-failed-they-resolved", } ) +def gates_release(disagreement: str) -> bool: + return bool(disagreement) and disagreement not in INFORMATIONAL_DISAGREEMENTS + + @dataclass class Outcome: kind: str @@ -136,6 +150,31 @@ def fingerprint(self) -> tuple: """ return (self.kind, self.records, self.chain) + def carried_data(self) -> bool: + """Did this outcome actually come with data to compare? + + Everything that leaves as an exception - BOGUS, NODATA, NXDOMAIN, a + retrieval failure - has an empty record set, which is the absence of + evidence about the zone's contents and not evidence that they changed. + """ + return bool(self.records or self.chain) + + +def data_is_unstable(outcomes: list[Outcome]) -> bool: + """Did the zone hand back two different datasets across these runs? + + Different answers between runs mean the zone itself is unstable, so a + verdict that changes with them is following the data, not wobbling. + + Only outcomes that came with data can establish that. Counting the empty + record set of a BOGUS as a second dataset excused precisely the flap this + harness exists to catch: every secure-to-bogus alternation looked like an + unstable zone, and `nic.bj` flipping between SECURE and BOGUS on four + record types was reported as 0 verdict flaps. + """ + carried = [o for o in outcomes if o.carried_data()] + return len({o.fingerprint()[1:] for o in carried}) > 1 + @dataclass class Row: @@ -177,6 +216,8 @@ def our_outcome(resolver: RecursiveResolver, domain: str, rdtype: str) -> tuple[ return Outcome(NXDOMAIN, str(exc)), violations if isinstance(exc, NoAnswerError): return Outcome(NODATA, str(exc)), violations + if not isinstance(exc, ResolverError): + return Outcome(LEAKED, f"{type(exc).__name__}: {exc}"), violations return Outcome(FAILED, f"{type(exc).__name__}: {exc}"), violations @@ -261,7 +302,13 @@ def compare(ours: Outcome, references: dict[str, str]) -> str: return "" if secure else "we-say-secure-they-do-not" if ours.kind == INSECURE: # The dangerous direction: a signature we failed to notice. - return "we-say-insecure-they-say-secure" if secure and not insecure else "" + if secure and not insecure: + return "we-say-insecure-they-say-secure" + # The other one. We hand the data over; a majority of the panel refuses + # to. Whatever they caught, we did not, and the caller gets the answer. + if bogus and not insecure: + return "we-say-insecure-they-say-bogus" + return "" if ours.kind == BOGUS: return "" if len(bogus) >= max(2, len(verdicts) - 1) else "false-bogus" if ours.kind in (NXDOMAIN, NODATA): @@ -276,8 +323,14 @@ def compare(ours: Outcome, references: dict[str, str]) -> str: return "" +def leaked(outcomes: list[Outcome]) -> bool: + """Did any resolution raise something that is not a ResolverError?""" + return any(o.kind == LEAKED for o in outcomes) + + def positive_int(text: str) -> int: - """`--runs 0` leaves `outcomes` empty, and `outcomes[0]` then raises.""" + """`--runs 0` leaves `outcomes` empty and `outcomes[0]` then raises; + `--workers 0` reaches ThreadPoolExecutor, which refuses it.""" value = int(text) if value < 1: raise argparse.ArgumentTypeError(f"must be 1 or more, got {value}") @@ -305,7 +358,7 @@ def main() -> int: parser.add_argument("--escalate", type=int, default=8, help="Resolutions for anomalous names") parser.add_argument("--sample", type=int, default=0, help="Random sample of N names (0 = all)") parser.add_argument("--seed", type=int, default=20260830, help="Sampling seed") - parser.add_argument("--workers", type=int, default=16, help="Concurrent names") + parser.add_argument("--workers", type=positive_int, default=16, help="Concurrent names") parser.add_argument("--timeout", type=float, default=3.0, help="Per-query timeout") parser.add_argument("--no-references", action="store_true", help="Skip the reference resolvers") parser.add_argument("--references", type=server_list, default=",".join(REFERENCES), help="Reference resolver IPs") @@ -358,7 +411,7 @@ def work(item: tuple[str, str, str, str]) -> Row: ) ) - disagreement = compare(outcomes[0], references) + disagreement = "we-leaked-a-non-resolver-error" if leaked(outcomes) else compare(outcomes[0], references) # Escalate anything suspicious: a disagreement, a violation, or a # verdict that is not simply "it resolved". suspicious = bool(disagreement or violations or len(distinct) > 1 or outcomes[0].kind in (BOGUS, UNAVAILABLE)) @@ -368,9 +421,12 @@ def work(item: tuple[str, str, str, str]) -> Row: violations.extend(extra) distinct = {o.key() for o in outcomes} # Re-judge on the most severe outcome seen. - severity = [BOGUS, FAILED, UNAVAILABLE, SECURE, INSECURE, NODATA, NXDOMAIN] + severity = [LEAKED, BOGUS, FAILED, UNAVAILABLE, SECURE, INSECURE, NODATA, NXDOMAIN] worst = min(outcomes, key=lambda o: severity.index(o.kind) if o.kind in severity else 99) - disagreement = compare(worst, references) or disagreement + if leaked(outcomes): + disagreement = "we-leaked-a-non-resolver-error" + else: + disagreement = compare(worst, references) or disagreement with lock: done += 1 @@ -378,10 +434,7 @@ def work(item: tuple[str, str, str, str]) -> Row: print(f" {done}/{len(work_items)} in {time.time() - started:.0f}s", file=sys.stderr, flush=True) counted = collections.Counter(o.key() for o in outcomes) - # Different answers between runs mean the zone itself is unstable, so a - # verdict that changes with them is following the data, not wobbling. - answered = [o for o in outcomes if o.kind not in RETRIEVAL_KINDS] - unstable_data = len({o.fingerprint()[1:] for o in answered}) > 1 + unstable_data = data_is_unstable(outcomes) return Row( domain=domain, rdtype=rdtype, @@ -440,8 +493,8 @@ def work(item: tuple[str, str, str, str]) -> Row: verdict_flaps = [r for r in rows if r.verdict_flap] availability_flaps = [r for r in rows if r.flapped and not r.verdict_flap] - verdict_disagreements = [r for r in rows if r.disagreement in VERDICT_DISAGREEMENTS] - availability_disagreements = [r for r in rows if r.disagreement and r.disagreement not in VERDICT_DISAGREEMENTS] + verdict_disagreements = [r for r in rows if gates_release(r.disagreement)] + availability_disagreements = [r for r in rows if r.disagreement and not gates_release(r.disagreement)] violated = [r for r in rows if r.violations] print(f"\n{len(rows)} lookups over {len(corpus)} names in {time.time() - started:.0f}s", file=sys.stderr) @@ -463,7 +516,7 @@ def work(item: tuple[str, str, str, str]) -> Row: print(f" VERDICT FLAP {row.domain}/{row.rdtype}: {row.ours}", file=sys.stderr) for row in verdict_disagreements + availability_disagreements: print( - f" {'DISAGREE' if row.disagreement in VERDICT_DISAGREEMENTS else 'note'} " + f" {'DISAGREE' if gates_release(row.disagreement) else 'note'} " f"{row.domain}/{row.rdtype}: {row.disagreement} ours={row.ours} refs={row.references}", file=sys.stderr, ) diff --git a/src/recursive_resolver/resolver.py b/src/recursive_resolver/resolver.py index 68d9822..4f479dd 100644 --- a/src/recursive_resolver/resolver.py +++ b/src/recursive_resolver/resolver.py @@ -576,6 +576,22 @@ def _resolve_iterative( kind = classification["type"] self._record_trace(ctx, server, qname, rdtype_text, classification, response, current_zone, chain_state) + if self._serves_an_unsigned_copy(response, kind, chain_state, ctx): + siblings = [ns for ns in current_nameservers if ns != server] + if siblings: + logger.debug( + "Unsigned %s for %s/%s from %s in signed zone %s, trying siblings", + kind, + qname, + rdtype_text, + server, + current_zone, + ) + current_nameservers = siblings + continue + # Every server answers unsigned, so this is the zone and not + # one stale copy of it. Fall through and judge what we have. + if kind == "answer": return self._handle_answer( response, @@ -1792,6 +1808,47 @@ def check(response: dns.message.Message) -> bool: return check + def _serves_an_unsigned_copy( + self, + response: dns.message.Message, + kind: str, + state: ValidationState, + ctx: _Context, + ) -> bool: + """Did this server answer for a signed zone without a single signature? + + Same rule as :meth:`_usable_denial`, one level up. RFC 4035 §5.5: "If + the resolver has other servers to try, it SHOULD try one of them before + concluding the answer is Bogus." A server holding a stale *unsigned* + copy of a zone its siblings serve signed answers authoritatively with + no RRSIG anywhere, so every type it is asked for reads as forged and + its NODATA proves nothing. Found in the wild on a signed ccTLD + registry whose four nameservers included one such copy: landing on it + made the whole zone intermittently bogus, on the say-so of the one + machine that was wrong. + + The test is syntactic and costs no crypto: under a chain of trust that + says the zone is signed, every positive answer and every denial carries + a signature, so a response with none at all is evidence about the + server rather than about the data. Nothing is relaxed - the sibling's + answer is validated exactly as this one would have been, and a zone + whose every server answers unsigned still ends BOGUS, because the + caller runs out of servers and judges the last response it holds. + + Referrals are excluded: the delegation NS RRset is unsigned by design + (RFC 4035 §2.2), and :meth:`_advance_dnssec` fetches the DS from the + parent's own NS set rather than trusting what the referral carried. + """ + if not ctx.dnssec or self._validator is None or state is not ValidationState.SECURE: + return False + if kind not in ("answer", "cname", "nodata", "nxdomain"): + return False + return not any( + rrset.rdtype == dns.rdatatype.RRSIG + for section in (response.answer, response.authority) + for rrset in section + ) + def _negative_ttl( self, response: dns.message.Message, qname: dns.name.Name, current_zone: dns.name.Name ) -> int | None: diff --git a/tests/test_dnssec_signed.py b/tests/test_dnssec_signed.py index 863cfe5..f58e1a5 100644 --- a/tests/test_dnssec_signed.py +++ b/tests/test_dnssec_signed.py @@ -1290,6 +1290,127 @@ def test_a_stripped_signature_inside_the_signed_zone_is_still_bogus(self) -> Non resolver.resolve("example.sub.test.", "A") +class TestAStaleUnsignedServerDoesNotCondemnTheZone: + """RFC 4035 §5.5: try another server before concluding an answer is Bogus. + + The same rule `_verify_denial` applies to a proof that does not validate, + one level up. Found on `nic.bj`, whose four nameservers include one + serving a stale *unsigned* copy of the zone: it answers authoritatively + with no RRSIG for any type, and NODATA for DNSKEY. Landing on it made + every type in the zone read as forged and the DNSKEY NODATA unprovable, + while every other validating resolver simply asked a sibling. + + Driven at `_query_once` so the real sweep across the NS set runs, and the + stale server is the one the sweep has to get past rather than one the test + arranged to be skipped. + """ + + STALE = "9.9.9.10" + GOOD = "9.9.9.11" + + def _world(self, stale_response, good_response): + root = SignedZone.create(".") + child = SignedZone.create("test.") + ds = root.ds_rrset(child) + + referral = make_response( + authority=[("test.", 3600, "NS", ["ns1.test.", "ns2.test."])], + additional=[("ns1.test.", 3600, "A", [self.STALE]), ("ns2.test.", 3600, "A", [self.GOOD])], + aa=False, + ) + referral.authority.append(ds) + referral.authority.append(root.sign(ds)) + + def dnskey_response(zone: SignedZone): + response = make_response() + response.answer.append(zone.dnskey_rrset) + response.answer.append(zone.signed_dnskey()) + return response + + def query_once(qname, rdtype, server, payload, timeout, ctx): + if server not in (self.STALE, self.GOOD): + if rdtype == dns.rdatatype.DNSKEY and qname == dns.name.root: + return dnskey_response(root) + return referral + if server == self.STALE: + return stale_response + if rdtype == dns.rdatatype.DNSKEY and qname == child.name: + return dnskey_response(child) + return good_response(child) + + resolver = RecursiveResolver(max_resolution_time=5, trust_anchors=(root.anchor_text(),), cache_enabled=False) + return resolver, query_once + + @staticmethod + def _unsigned_answer() -> dns.message.Message: + response = make_response() + response.answer.append(rrset_of("test.", "A", "198.51.100.9")) + return response + + @staticmethod + def _signed_answer(child: SignedZone) -> dns.message.Message: + answer = rrset_of("test.", "A", "192.0.2.10") + response = make_response() + response.answer.append(answer) + response.answer.append(child.sign(answer)) + return response + + def test_an_unsigned_answer_from_one_server_is_retried_elsewhere(self) -> None: + # The sweep shuffles the NS set, so this runs both orders. + for _ in range(8): + resolver, query_once = self._world(self._unsigned_answer(), self._signed_answer) + with patch.object(resolver, "_query_once", side_effect=query_once): + result = resolver.resolve_answer("test.", "A") + assert result.records == ["192.0.2.10"], "took the stale server's unsigned answer" + assert result.dnssec is ValidationState.SECURE + + def test_an_unsigned_nodata_from_one_server_is_retried_elsewhere(self) -> None: + """The `nic.bj/DNSKEY` case: a NODATA the stale copy cannot prove. + + Sweeping only on a failed *proof* does not reach this: the sibling + holds real DNSKEY records, so it never sends a denial for the + denial-sweep predicate to accept, and the zone came out "unproven + nodata in a signed zone" with the records sitting on the next server. + """ + stale = make_response(authority=[("test.", 300, "SOA", ["ns1.test. hm.test. 1 3600 900 604800 300"])]) + for _ in range(8): + resolver, query_once = self._world(stale, self._signed_answer) + with patch.object(resolver, "_query_once", side_effect=query_once): + result = resolver.resolve_answer("test.", "DNSKEY") + assert result.dnssec is ValidationState.SECURE + assert result.records, "no DNSKEY came back" + + def test_a_zone_every_server_serves_unsigned_is_still_bogus(self) -> None: + """Sweeping must not turn a zone with no signatures anywhere into a pass.""" + resolver, query_once = self._world(self._unsigned_answer(), lambda _child: self._unsigned_answer()) + with ( + patch.object(resolver, "_query_once", side_effect=query_once), + pytest.raises(DNSSECValidationError, match="no RRSIG"), + ): + resolver.resolve("test.", "A") + + def test_a_referral_is_not_swept_past_for_want_of_a_signature(self) -> None: + """The delegation NS RRset is unsigned by design (RFC 4035 §2.2). + + Treating an unsigned referral as a stale copy would sweep the whole + parent NS set on every delegation in a signed zone and then fail. + """ + resolver, _ = self._world(self._unsigned_answer(), self._signed_answer) + ctx = resolver._new_context() + bare = make_response(authority=[("sub.test.", 3600, "NS", ["ns1.sub.test."])], aa=False) + assert resolver._serves_an_unsigned_copy(bare, "referral", ValidationState.SECURE, ctx) is False, ( + "a referral was mistaken for a stale unsigned copy" + ) + + def test_an_insecure_chain_is_left_alone(self) -> None: + """Nothing to sweep for when the chain never claimed the zone was signed.""" + resolver, _ = self._world(self._unsigned_answer(), self._signed_answer) + ctx = resolver._new_context() + assert ( + resolver._serves_an_unsigned_copy(self._unsigned_answer(), "answer", ValidationState.INSECURE, ctx) is False + ) + + class TestClockSkewOnInception: """A signer whose clock runs ahead of ours must not produce BOGUS. diff --git a/tests/test_protocol_harness.py b/tests/test_protocol_harness.py index ecd9dac..76d5184 100644 --- a/tests/test_protocol_harness.py +++ b/tests/test_protocol_harness.py @@ -9,8 +9,12 @@ from __future__ import annotations +import ast +import inspect import sys +import textwrap from pathlib import Path +from unittest.mock import patch import dns.flags import dns.name @@ -22,9 +26,28 @@ from audit import Fetch, Ledger, audited # noqa: E402 from cassette import apply_fault, outcome_of, transition_allowed # noqa: E402 -from verdict_harness import SECURE, Outcome, compare # noqa: E402 +from verdict_harness import ( # noqa: E402 + BOGUS, + FAILED, + INFORMATIONAL_DISAGREEMENTS, + INSECURE, + LEAKED, + NODATA, + NXDOMAIN, + SECURE, + Outcome, + compare, + data_is_unstable, + gates_release, + leaked, + our_outcome, +) -from recursive_resolver import DNSSECValidationError, RecursiveResolver # noqa: E402 +from recursive_resolver import ( # noqa: E402 + DNSSECValidationError, + RecursiveResolver, + ServfailError, +) from recursive_resolver.exceptions import ( # noqa: E402 DNSSECMaterialUnavailableError, MaxDepthError, @@ -191,3 +214,138 @@ def test_a_secure_majority_against_our_insecure_is_the_dangerous_direction(self) def test_a_lone_secure_against_our_insecure_is_not(self) -> None: panel = self._panel(SECURE, "insecure", "insecure", "insecure", "insecure") assert compare(Outcome("insecure", ""), panel) == "" + + def test_a_bogus_majority_against_our_insecure_is_a_disagreement(self) -> None: + """We return the data; four of five refuse it. Whatever they caught, we did not.""" + panel = self._panel("bogus", "bogus", "bogus", "servfail", "insecure") + assert compare(Outcome("insecure", ""), panel) == "we-say-insecure-they-say-bogus" + + def test_a_lone_bogus_against_our_insecure_is_not(self) -> None: + """One operator's SERVFAIL is an operator, not a finding.""" + panel = self._panel("bogus", "insecure", "insecure", "insecure", "insecure") + assert compare(Outcome("insecure", ""), panel) == "" + + +class TestEveryDisagreementIsClassified: + """A label `compare` can return must be a deliberate gate-or-not decision. + + The labels used to be enumerated in the *gating* set, so one added to + `compare` and forgotten there was filed under "the internet, not us" and + let a release through. This reads the labels out of the source, so adding + one without deciding what it means fails here rather than in six months. + """ + + @staticmethod + def _labels() -> set[str]: + source = inspect.getsource(compare) + tree = ast.parse(textwrap.dedent(source)) + return { + node.value.value + for node in ast.walk(tree) + if isinstance(node, ast.Return) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + and node.value.value + } | { + branch.value + for node in ast.walk(tree) + if isinstance(node, ast.Return) and isinstance(node.value, ast.IfExp) + for branch in (node.value.body, node.value.orelse) + if isinstance(branch, ast.Constant) and isinstance(branch.value, str) and branch.value + } + + def test_the_labels_are_the_ones_we_think(self) -> None: + assert self._labels() == { + "references-disagree", + "we-say-secure-they-do-not", + "we-say-insecure-they-say-secure", + "we-say-insecure-they-say-bogus", + "false-bogus", + "we-answered-they-refused", + "material-unavailable-but-they-resolved", + "we-failed-they-resolved", + } + + def test_the_leak_label_is_classified_too(self) -> None: + """It never comes out of `compare`, so the source scan cannot see it.""" + assert gates_release("we-leaked-a-non-resolver-error") + assert "we-leaked-a-non-resolver-error" not in INFORMATIONAL_DISAGREEMENTS + + def test_the_ones_that_are_about_us_gate_the_release(self) -> None: + about_us = self._labels() - INFORMATIONAL_DISAGREEMENTS + assert about_us, "compare returns nothing that would fail a release" + for label in about_us: + assert gates_release(label), f"{label} is reported but does not gate" + + def test_no_disagreement_is_not_a_gate(self) -> None: + assert not gates_release("") + + +class TestOnlyRealDataExcusesAFlap: + """A flap is excused when the zone handed back two different datasets. + + An outcome that leaves as an exception carries no records, and that is the + absence of evidence about the zone's contents, not evidence that they + changed. Treating it as a second dataset excused the one flap that matters + most: `nic.bj` alternating between SECURE and BOGUS on four record types + was reported across 120,000 lookups as zero verdict flaps. + """ + + def test_secure_alternating_with_bogus_is_a_flap(self) -> None: + outcomes = [ + Outcome(SECURE, "", ("81.91.239.11",), ()), + Outcome(BOGUS, "zone is signed but the answer carries no RRSIG"), + Outcome(SECURE, "", ("81.91.239.11",), ()), + ] + assert data_is_unstable(outcomes) is False + + def test_two_different_answers_are_the_zone_being_unstable(self) -> None: + outcomes = [ + Outcome(SECURE, "", ("192.0.2.1",), ()), + Outcome(INSECURE, "", ("198.51.100.1",), ()), + ] + assert data_is_unstable(outcomes) is True + + def test_the_same_records_down_two_different_cname_chains_are_unstable(self) -> None: + """The case the rule was written for: a traffic manager landing either side.""" + outcomes = [ + Outcome(SECURE, "", ("192.0.2.1",), ("a.example.test.",)), + Outcome(INSECURE, "", ("192.0.2.1",), ("b.example.test.",)), + ] + assert data_is_unstable(outcomes) is True + + def test_a_retrieval_failure_alongside_one_answer_excuses_nothing(self) -> None: + outcomes = [Outcome(SECURE, "", ("192.0.2.1",), ()), Outcome(FAILED, "timeout")] + assert data_is_unstable(outcomes) is False + + def test_two_denials_are_not_two_datasets(self) -> None: + assert data_is_unstable([Outcome(NXDOMAIN, "gone"), Outcome(NODATA, "none")]) is False + + +class TestALeakedExceptionFailsTheRun: + """README: every failure is a ResolverError, nothing from dnspython escapes. + + The cassette layer refuses to call one an outcome. This layer sees far more + of the real internet, and used to file a leak under `failed` - which reads + as one more unreachable zone and never gates. + """ + + def test_a_resolver_error_is_a_failure_not_a_leak(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=False) + with patch.object(resolver, "resolve_answer", side_effect=ServfailError("a.test.", "A")): + outcome, _violations = our_outcome(resolver, "a.test.", "A") + assert outcome.kind == "failed" + assert not leaked([outcome]) + + def test_anything_else_is_a_leak(self) -> None: + resolver = RecursiveResolver(dnssec=False, cache_enabled=False) + with patch.object(resolver, "resolve_answer", side_effect=TypeError("boom")): + outcome, _violations = our_outcome(resolver, "a.test.", "A") + assert outcome.kind == LEAKED + assert leaked([outcome]) + + def test_a_leak_gates_even_when_the_panel_agrees_with_it(self) -> None: + """No reference verdict excuses it, so it does not go through `compare`.""" + panel = {"ref0": "servfail", "ref1": "servfail", "ref2": "servfail"} + assert compare(Outcome(LEAKED, ""), panel) == "" + assert gates_release("we-leaked-a-non-resolver-error") From ef837b68e22f276dad5b42d8cdbc83b3728c1dfc Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Fri, 4 Sep 2026 09:14:49 +0200 Subject: [PATCH 4/5] release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc9d843..cf074e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.2.0] - 2026-09-01 +## [0.2.0] - 2026-09-03 A security release. Four defects in DNSSEC validation allowed forged data to be accepted as authenticated; the most direct required only that the attacker own From 64b33a5b4c9935e55593d0008a53c755936f1cd7 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Fri, 4 Sep 2026 10:07:10 +0200 Subject: [PATCH 5/5] last fixes --- CHANGELOG.md | 2 +- scripts/cassette.py | 14 +++++++++- scripts/mutation_check.py | 6 +++++ tests/test_dnssec_signed.py | 51 +++++++++++++++++++++++++++---------- 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf074e2..4620c78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,7 +94,7 @@ a signed domain of their own. Upgrade before relying on a `SECURE` verdict from retrieved. Deliberately not a `DNSSECError`, so "refuse to use this data" handling stays correct while retrieval failures fall through to retries. - `TESTING.md` and the harnesses it describes, including a mutation catalogue - of 50 reintroduced defects that confirms the suite still catches each one. + of 52 reintroduced defects that confirms the suite still catches each one. ## [0.1.0] - 2026-08-09 diff --git a/scripts/cassette.py b/scripts/cassette.py index 1987050..2524cb2 100644 --- a/scripts/cassette.py +++ b/scripts/cassette.py @@ -53,6 +53,7 @@ import sys import threading import time +import traceback from concurrent.futures import ThreadPoolExecutor from pathlib import Path @@ -484,4 +485,15 @@ def run_case(case: dict) -> tuple[int, list[str], str]: if __name__ == "__main__": - raise SystemExit(main()) + # 0 is "reproduced", 1 is "did not". Anything the harness itself gets wrong + # - a truncated cassette file, a JSONDecodeError, an import that fails - + # must not exit 1, because the mutation runner reads 1 as "this layer + # caught the mutant" and a broken harness would report a clean sweep. + try: + status = main() + except SystemExit: + raise + except Exception: + traceback.print_exc() + status = 2 + raise SystemExit(status) diff --git a/scripts/mutation_check.py b/scripts/mutation_check.py index 8d83d1c..fb00803 100644 --- a/scripts/mutation_check.py +++ b/scripts/mutation_check.py @@ -747,6 +747,11 @@ def apply_mutation(target: Path, edits: list[tuple[str, str]]) -> None: # turns a broken run into a clean report. PYTEST_EXIT_CODES = frozenset({0, 1}) +# The same rule for the cassette harness: 0 reproduced, 1 did not. It exits 2 +# for anything else, so a truncated cassette file or a JSONDecodeError is a +# loud failure here instead of one more mutant reported as caught. +CASSETTE_EXIT_CODES = frozenset({0, 1}) + def run(cmd: list[str], env_src: Path, cwd: Path, expected: frozenset[int] | None = None) -> bool: """True when the command passes (i.e. the mutant survived this layer).""" @@ -827,6 +832,7 @@ def main() -> int: [args.python, str(REPO / "scripts/cassette.py"), "perturb", "--cassettes", cassettes], target, REPO, + expected=CASSETTE_EXIT_CODES, ) caught = [name for name, ok in (("unit", unit_ok), ("cassettes", cassette_ok)) if not ok] diff --git a/tests/test_dnssec_signed.py b/tests/test_dnssec_signed.py index f58e1a5..35c9d58 100644 --- a/tests/test_dnssec_signed.py +++ b/tests/test_dnssec_signed.py @@ -1341,6 +1341,25 @@ def query_once(qname, rdtype, server, payload, timeout, ctx): resolver = RecursiveResolver(max_resolution_time=5, trust_anchors=(root.anchor_text(),), cache_enabled=False) return resolver, query_once + def _first_server(self, resolver: RecursiveResolver, first: str): + """Pin the order of the zone's own NS set, leaving every other set alone. + + `_order_servers` shuffles, so letting it decide makes the test a coin + flip: half the time the good server answers first, the sweep never runs, + and the assertions pass anyway. Only the two-address set for `test.` is + pinned - the root and TLD sets keep their own ordering, or the referral + that sets up the whole case never arrives. + """ + real = resolver._order_servers + pair = {self.STALE, self.GOOD} + + def ordered(nameservers: list[str]) -> list[str]: + if set(nameservers) == pair: + return [first, *(s for s in pair if s != first)] + return real(nameservers) + + return patch.object(resolver, "_order_servers", side_effect=ordered) + @staticmethod def _unsigned_answer() -> dns.message.Message: response = make_response() @@ -1355,14 +1374,16 @@ def _signed_answer(child: SignedZone) -> dns.message.Message: response.answer.append(child.sign(answer)) return response - def test_an_unsigned_answer_from_one_server_is_retried_elsewhere(self) -> None: - # The sweep shuffles the NS set, so this runs both orders. - for _ in range(8): - resolver, query_once = self._world(self._unsigned_answer(), self._signed_answer) - with patch.object(resolver, "_query_once", side_effect=query_once): - result = resolver.resolve_answer("test.", "A") - assert result.records == ["192.0.2.10"], "took the stale server's unsigned answer" - assert result.dnssec is ValidationState.SECURE + @pytest.mark.parametrize("first", [STALE, GOOD], ids=["stale-first", "good-first"]) + def test_an_unsigned_answer_from_one_server_is_retried_elsewhere(self, first: str) -> None: + resolver, query_once = self._world(self._unsigned_answer(), self._signed_answer) + with ( + self._first_server(resolver, first), + patch.object(resolver, "_query_once", side_effect=query_once), + ): + result = resolver.resolve_answer("test.", "A") + assert result.records == ["192.0.2.10"], "took the stale server's unsigned answer" + assert result.dnssec is ValidationState.SECURE def test_an_unsigned_nodata_from_one_server_is_retried_elsewhere(self) -> None: """The `nic.bj/DNSKEY` case: a NODATA the stale copy cannot prove. @@ -1373,12 +1394,14 @@ def test_an_unsigned_nodata_from_one_server_is_retried_elsewhere(self) -> None: nodata in a signed zone" with the records sitting on the next server. """ stale = make_response(authority=[("test.", 300, "SOA", ["ns1.test. hm.test. 1 3600 900 604800 300"])]) - for _ in range(8): - resolver, query_once = self._world(stale, self._signed_answer) - with patch.object(resolver, "_query_once", side_effect=query_once): - result = resolver.resolve_answer("test.", "DNSKEY") - assert result.dnssec is ValidationState.SECURE - assert result.records, "no DNSKEY came back" + resolver, query_once = self._world(stale, self._signed_answer) + with ( + self._first_server(resolver, self.STALE), + patch.object(resolver, "_query_once", side_effect=query_once), + ): + result = resolver.resolve_answer("test.", "DNSKEY") + assert result.dnssec is ValidationState.SECURE + assert result.records, "no DNSKEY came back" def test_a_zone_every_server_serves_unsigned_is_still_bogus(self) -> None: """Sweeping must not turn a zone with no signatures anywhere into a pass."""