From da5423cb06a5d75b5fa2e6f6813c169ec4af10aa Mon Sep 17 00:00:00 2001 From: Christian Poveda Date: Thu, 13 Jun 2024 10:44:50 -0500 Subject: [PATCH] Add conformance tests for NSEC3 --- .../conformance-tests/src/name_server.rs | 1 + .../src/name_server/rfc5155.rs | 376 ++++++++++++++++++ conformance/packages/dns-test/src/fqdn.rs | 7 +- conformance/packages/dns-test/src/record.rs | 26 +- .../packages/dns-test/src/zone_file/mod.rs | 1 + 5 files changed, 401 insertions(+), 10 deletions(-) create mode 100644 conformance/packages/conformance-tests/src/name_server/rfc5155.rs diff --git a/conformance/packages/conformance-tests/src/name_server.rs b/conformance/packages/conformance-tests/src/name_server.rs index a2e4ef2b..cc9690ca 100644 --- a/conformance/packages/conformance-tests/src/name_server.rs +++ b/conformance/packages/conformance-tests/src/name_server.rs @@ -1,2 +1,3 @@ mod rfc4035; +mod rfc5155; mod scenarios; diff --git a/conformance/packages/conformance-tests/src/name_server/rfc5155.rs b/conformance/packages/conformance-tests/src/name_server/rfc5155.rs new file mode 100644 index 00000000..8e92de04 --- /dev/null +++ b/conformance/packages/conformance-tests/src/name_server/rfc5155.rs @@ -0,0 +1,376 @@ +use std::collections::BTreeMap; +use std::net::Ipv4Addr; + +use dns_test::client::{Client, DigSettings, DigStatus}; +use dns_test::name_server::NameServer; +use dns_test::record::{Record, RecordType, NSEC3}; +use dns_test::{Network, Result, FQDN}; + +/// An NSEC3 RR is said to "match" a name if the owner name of the NSEC3 RR is the same as the +/// hashed owner name of that name. +fn find_match<'a>(name_hash: &str, records: &'a BTreeMap) -> Option<&'a NSEC3> { + records.get(name_hash) +} + +/// An NSEC3 RR is said to cover a name if the hash of the name or "next closer" name falls between +/// the owner name and the next hashed owner name of the NSEC3. In other words, if it proves the +/// nonexistence of the name, either directly or by proving the nonexistence of an ancestor of the +/// name. +fn find_cover<'a>(name_hash: &str, records: &'a BTreeMap) -> Option<&'a NSEC3> { + let (hash, candidate) = records + // Find the greater hash that is less or equal than the name's hash. + .range(..=name_hash.to_owned()) + .last() + // If no value is less or equal than the name's hash, it means that the name's hash is out + // of range and the last record covers it. + .or_else(|| records.last_key_value())?; + + // If the found hash is exactly the name's hash, return None as it wouldn't be proving its + // nonexistence. Otherwise return the RR with that hash. + (hash != name_hash).then_some(candidate) +} + +/// This proof consists of (up to) two different NSEC3 RRs: +/// - An NSEC3 RR that matches the closest (provable) encloser. +/// - An NSEC3 RR that covers the "next closer" name to the closest encloser. +fn closest_encloser_proof<'a>( + closest_encloser_hash: &str, + next_closer_name_hash: &str, + records: &'a BTreeMap, +) -> Option<(&'a NSEC3, &'a NSEC3)> { + Some(( + find_match(closest_encloser_hash, records)?, + find_cover(next_closer_name_hash, records)?, + )) +} + +fn query_nameserver( + records: impl IntoIterator, + qname: &FQDN, + qtype: RecordType, +) -> Result<(BTreeMap, DigStatus, Vec)> { + let network = Network::new()?; + let mut ns = NameServer::new(&dns_test::SUBJECT, FQDN::ROOT, &network)?; + + for record in records { + ns.add(record); + } + + let ns = ns.sign()?; + // Extract the NSEC3 RRs from the signed zonefile and sort them by the hash embedded in the + // last label of each record's owner. + let nsec3_rrs = ns + .signed_zone_file() + .records + .iter() + .cloned() + .filter_map(|rr| { + let mut nsec3_rr = rr.try_into_nsec3().ok()?; + nsec3_rr.next_hashed_owner_name = nsec3_rr.next_hashed_owner_name.to_uppercase(); + Some((nsec3_rr.fqdn.last_label().to_uppercase(), nsec3_rr)) + }) + .collect::>(); + + let ns = ns.start()?; + + let client = Client::new(&network)?; + let output = client.dig( + *DigSettings::default().dnssec().authentic_data(), + ns.ipv4_addr(), + qtype, + qname, + )?; + + let nsec3_rrs_response = output + .authority + .into_iter() + .filter_map(|rr| rr.try_into_nsec3().ok()) + .collect::>(); + + Ok((nsec3_rrs, output.status, nsec3_rrs_response)) +} + +#[track_caller] +fn find_records<'a>( + records: &[NSEC3], + records_and_err_msgs: impl IntoIterator, +) { + for (record, err_msg) in records_and_err_msgs { + records.iter().find(|&rr| rr == record).expect(err_msg); + } +} + +const ALICE_FQDN: &str = "alice.com."; +const BOB_FQDN: &str = "bob.alice.com."; +const CHARLIE_FQDN: &str = "charlie.alice.com."; +const WILDCARD_FQDN: &str = "*.alice.com."; + +// These hashes are computed with 1 iteration of SHA-1 without salt and must be recomputed if +// those parameters were to change. +const ALICE_HASH: &str = "LLKH4L6I60VHAPP6VRM3DFR9RI8AK9I0"; /* h(alice.com.) */ +const CHARLIE_HASH: &str = "99P1CCPQ2N64LIRMT2838O4HK0QFA51B"; /* h(charlie.alice.com.) */ +const WILDCARD_HASH: &str = "19GBV5V1BO0P51H34JQDH1C8CIAA5RAQ"; /* h(*.alice.com.) */ + +// This test checks that name servers produce a name error response compliant with section 7.2.2. +// of RFC5155. +#[test] +#[ignore] +fn name_error_response() -> Result<()> { + let alice_fqdn = FQDN(ALICE_FQDN)?; + let bob_fqdn = FQDN(BOB_FQDN)?; + // The queried name + let qname = FQDN(CHARLIE_FQDN)?; + + let (nsec3_rrs, status, nsec3_rrs_response) = query_nameserver( + [ + Record::a(alice_fqdn, Ipv4Addr::new(1, 2, 3, 4)), + Record::a(bob_fqdn, Ipv4Addr::new(1, 2, 3, 5)), + ], + &qname, + RecordType::A, + )?; + + assert!(status.is_nxdomain()); + + // Closest Encloser Proof + // + // The closest encloser of a name is its longest existing ancestor. In this scenario, the + // closest encloser of `charlie.alice.com.` is `alice.com.` as this is the longest ancestor with an + // existing RR. + // + // The next closer name of a name is the name one label longer than its closest encloser. In + // this scenario, the closest encloser is `alice.com.` which means that the next closer name is `charlie.alice.com.` + + // If this panics, it probably means that the precomputed hashes must be recomputed. + let (closest_encloser_rr, next_closer_name_rr) = + closest_encloser_proof(ALICE_HASH, CHARLIE_HASH, &nsec3_rrs) + .expect("Cannot find a closest encloser proof in the zonefile"); + + // Wildcard at the closet encloser RR: Must cover the wildcard at the closest encloser of + // QNAME. + // + // In this scenario, the closest encloser is `alice.com.`, so the wildcard at the closer + // encloser is `*.alice.com.`. + // + // This NSEC3 RR must cover the hash of the wildcard at the closests encloser. + + // if this panics, it probably means that the precomputed hashes must be recomputed. + let wildcard_rr = + find_cover(WILDCARD_HASH, &nsec3_rrs).expect("No RR in the zonefile covers the wildcard"); + + // Now we check that the response has the three NSEC3 RRs. + find_records( + &nsec3_rrs_response, + [ + ( + closest_encloser_rr, + "No RR in the response matches the closest encloser", + ), + ( + next_closer_name_rr, + "No RR in the response covers the next closer name", + ), + (wildcard_rr, "No RR in the response covers the wildcard"), + ], + ); + + Ok(()) +} + +// This test checks that name servers produce a no data response compliant with section 7.2.3. +// of RFC5155 when the query type is not DS. +#[test] +#[ignore] +fn no_data_response_not_ds() -> Result<()> { + let alice_fqdn = FQDN(ALICE_FQDN)?; + // The queried name + let qname = alice_fqdn.clone(); + + let (nsec3_rrs, _status, nsec3_rrs_response) = query_nameserver( + [Record::a(alice_fqdn, Ipv4Addr::new(1, 2, 3, 4))], + &qname, + RecordType::MX, + )?; + + // The server MUST include the NSEC3 RR that matches QNAME. + + // if this panics, it probably means that the precomputed hashes must be recomputed. + let qname_rr = find_match(ALICE_HASH, &nsec3_rrs).expect("No RR in the zonefile matches QNAME"); + + find_records( + &nsec3_rrs_response, + [(qname_rr, "No RR in the response matches QNAME")], + ); + + Ok(()) +} + +// This test checks that name servers produce a no data response compliant with section 7.2.4. +// of RFC5155 when the query type is DS and there is an NSEC3 RR that matches the queried name. +#[test] +#[ignore] +fn no_data_response_ds_match() -> Result<()> { + let alice_fqdn = FQDN(ALICE_FQDN)?; + // The queried name + let qname = alice_fqdn.clone(); + + let (nsec3_rrs, _status, nsec3_rrs_response) = query_nameserver( + [Record::a(alice_fqdn, Ipv4Addr::new(1, 2, 3, 4))], + &qname, + RecordType::DS, + )?; + + // If there is an NSEC3 RR that matches QNAME, the server MUST return it in the response. + + // if this panics, it probably means that the precomputed hashes must be recomputed. + let qname_rr = find_match(ALICE_HASH, &nsec3_rrs).expect("No RR in the zonefile matches QNAME"); + + find_records( + &nsec3_rrs_response, + [(qname_rr, "No RR in the response matches QNAME")], + ); + + Ok(()) +} + +// This test checks that name servers produce a no data response compliant with section 7.2.4. +// of RFC5155 when the query type is DS and no NSEC3 RR matches the queried name. +#[test] +#[ignore] +fn no_data_response_ds_no_match() -> Result<()> { + let alice_fqdn = FQDN(ALICE_FQDN)?; + // The queried name + let qname = FQDN(CHARLIE_FQDN)?; + + let (nsec3_rrs, _status, nsec3_rrs_response) = query_nameserver( + [Record::a(alice_fqdn, Ipv4Addr::new(1, 2, 3, 4))], + &qname, + RecordType::DS, + )?; + + // If no NSEC3 RR matches QNAME, the server MUST return a closest provable encloser proof for + // QNAME. + + // Closest Encloser Proof + // + // The closest encloser of a name is its longest existing ancestor. In this scenario, the + // closest encloser of `charlie.alice.com.` is `alice.com.` as this is the longest ancestor with an + // existing RR. + // + // The next closer name of a name is the name one label longer than its closest encloser. In + // this scenario, the closest encloser is `alice.com.` which means that the next closer name is `charlie.alice.com.` + + // If this panics, it probably means that the precomputed hashes must be recomputed. + let (closest_encloser_rr, next_closer_name_rr) = + closest_encloser_proof(ALICE_HASH, CHARLIE_HASH, &nsec3_rrs) + .expect("Cannot find a closest encloser proof in the zonefile"); + + find_records( + &nsec3_rrs_response, + [ + ( + closest_encloser_rr, + "No RR in the response matches the closest encloser", + ), + ( + next_closer_name_rr, + "No RR in the response covers the next closer name", + ), + ], + ); + + Ok(()) +} + +// This test checks that name servers produce a wildcard no data response compliant with section 7.2.5. +#[test] +#[ignore] +fn wildcard_no_data_response() -> Result<()> { + let wildcard_fqdn = FQDN(WILDCARD_FQDN)?; + // The queried name + let qname = FQDN(CHARLIE_FQDN)?; + + let (nsec3_rrs, _status, nsec3_rrs_response) = query_nameserver( + [Record::a(wildcard_fqdn, Ipv4Addr::new(1, 2, 3, 4))], + &qname, + RecordType::MX, + )?; + + // If there is a wildcard match for QNAME, but QTYPE is not present at that name, the response MUST + // include a closest encloser proof for QNAME and MUST include the NSEC3 RR that matches the + // wildcard. + + // Closest Encloser Proof + // + // The closest encloser of a name is its longest existing ancestor. In this scenario, the + // closest encloser of `charlie.alice.com.` is `alice.com.` as this is the longest ancestor with an + // existing RR. + // + // The next closer name of a name is the name one label longer than its closest encloser. In + // this scenario, the closest encloser is `alice.com.` which means that the next closer name is `charlie.alice.com.` + + // If this panics, it probably means that the precomputed hashes must be recomputed. + let (closest_encloser_rr, next_closer_name_rr) = + closest_encloser_proof(ALICE_HASH, CHARLIE_HASH, &nsec3_rrs) + .expect("Cannot find a closest encloser proof in the zonefile"); + + // Wildcard RR: This NSEC3 RR must match `*.alice.com`. + + // If this panics, it probably means that the precomputed hashes must be recomputed. + let wildcard_rr = + find_match(WILDCARD_HASH, &nsec3_rrs).expect("No RR in the zonefile matches the wildcard"); + + find_records( + &nsec3_rrs_response, + [ + ( + closest_encloser_rr, + "No RR in the response matches the closest encloser", + ), + ( + next_closer_name_rr, + "No RR in the response covers the next closer name", + ), + (wildcard_rr, "No RR in the response covers the wildcard"), + ], + ); + + Ok(()) +} + +// This test checks that name servers produce a wildcard answer response compliant with section 7.2.6. +#[test] +#[ignore] +fn wildcard_answer_response() -> Result<()> { + let wildcard_fqdn = FQDN(WILDCARD_FQDN)?; + // The queried name + let qname = FQDN(CHARLIE_FQDN)?; + + let (nsec3_rrs, _status, nsec3_rrs_response) = query_nameserver( + [Record::a(wildcard_fqdn, Ipv4Addr::new(1, 2, 3, 4))], + &qname, + RecordType::A, + )?; + + // If there is a wildcard match for QNAME and QTYPE, then, in addition to the expanded wildcard + // RRSet returned in the answer section of the response, proof that the wildcard match was + // valid must be returned. ... To this end, the NSEC3 RR that covers the "next closer" name of the + // immediate ancestor of the wildcard MUST be returned. + + // The next closer name of a name is the name one label longer than its closest encloser. In + // this scenario, the closest encloser is `alice.com.` which means that the next closer name is `charlie.alice.com.` + + // If this panics, it probably means that the precomputed hashes must be recomputed. + let next_closer_name_rr = find_cover(CHARLIE_HASH, &nsec3_rrs) + .expect("No RR in the zonefile covers the next closer name"); + + find_records( + &nsec3_rrs_response, + [( + next_closer_name_rr, + "No RR in the response covers the next closer name", + )], + ); + + Ok(()) +} diff --git a/conformance/packages/dns-test/src/fqdn.rs b/conformance/packages/dns-test/src/fqdn.rs index b0ae757c..904848b1 100644 --- a/conformance/packages/dns-test/src/fqdn.rs +++ b/conformance/packages/dns-test/src/fqdn.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use crate::{Error, Result}; -#[derive(Clone, PartialEq)] +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd)] pub struct FQDN { inner: Cow<'static, str>, } @@ -13,6 +13,7 @@ pub struct FQDN { #[allow(non_snake_case)] pub fn FQDN(input: impl Into>) -> Result { let input = input.into(); + if !input.ends_with('.') { return Err("FQDN must end with a `.`".into()); } @@ -77,6 +78,10 @@ impl FQDN { .filter(|label| !label.is_empty()) .count() } + + pub fn last_label(&self) -> &str { + self.inner.split_once('.').map(|(label, _)| label).unwrap() + } } impl FromStr for FQDN { diff --git a/conformance/packages/dns-test/src/record.rs b/conformance/packages/dns-test/src/record.rs index dc9d4423..4ea91a77 100644 --- a/conformance/packages/dns-test/src/record.rs +++ b/conformance/packages/dns-test/src/record.rs @@ -14,7 +14,7 @@ const CLASS: &str = "IN"; // "internet" macro_rules! record_types { ($($variant:ident),*) => { #[allow(clippy::upper_case_acronyms)] - #[derive(Debug, PartialEq)] + #[derive(Debug, PartialEq, Clone)] pub enum RecordType { $($variant),* } @@ -49,7 +49,7 @@ macro_rules! record_types { record_types!(A, AAAA, DNSKEY, DS, MX, NS, NSEC3, NSEC3PARAM, RRSIG, SOA, TXT); -#[derive(Debug)] +#[derive(Debug, Clone)] #[allow(clippy::upper_case_acronyms)] pub enum Record { A(A), @@ -150,6 +150,14 @@ impl Record { Err(self) } } + + pub fn try_into_nsec3(self) -> CoreResult { + if let Self::NSEC3(v) = self { + Ok(v) + } else { + Err(self) + } + } } impl FromStr for Record { @@ -192,7 +200,7 @@ impl fmt::Display for Record { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct A { pub fqdn: FQDN, pub ttl: u32, @@ -395,7 +403,7 @@ impl fmt::Display for DS { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct NS { pub zone: FQDN, pub ttl: u32, @@ -439,7 +447,7 @@ impl FromStr for NS { } // integer types chosen based on bit sizes in section 3.2 of RFC5155 -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq)] pub struct NSEC3 { pub fqdn: FQDN, pub ttl: u32, @@ -509,7 +517,7 @@ impl fmt::Display for NSEC3 { } // integer types chosen based on bit sizes in section 4.2 of RFC5155 -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct NSEC3PARAM { pub zone: FQDN, pub ttl: u32, @@ -567,7 +575,7 @@ impl fmt::Display for NSEC3PARAM { // integer types chosen based on bit sizes in section 3.1 of RFC4034 #[allow(clippy::upper_case_acronyms)] -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct RRSIG { pub fqdn: FQDN, pub ttl: u32, @@ -646,7 +654,7 @@ impl fmt::Display for RRSIG { } #[allow(clippy::upper_case_acronyms)] -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SOA { pub zone: FQDN, pub ttl: u32, @@ -704,7 +712,7 @@ impl fmt::Display for SOA { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SoaSettings { pub serial: u32, pub refresh: u32, diff --git a/conformance/packages/dns-test/src/zone_file/mod.rs b/conformance/packages/dns-test/src/zone_file/mod.rs index 2c8c02b4..11a2416d 100644 --- a/conformance/packages/dns-test/src/zone_file/mod.rs +++ b/conformance/packages/dns-test/src/zone_file/mod.rs @@ -12,6 +12,7 @@ use std::str::FromStr; use crate::record::{self, Record, SOA}; use crate::{Error, Result, DEFAULT_TTL, FQDN}; +#[derive(Clone)] pub struct ZoneFile { origin: FQDN, pub soa: SOA,