From 9f8c19cb71c4caf74262baf97c59f18bf60db1b4 Mon Sep 17 00:00:00 2001 From: Christian Poveda Date: Fri, 14 Jun 2024 13:49:31 -0500 Subject: [PATCH] Factor NSEC3 records logic to its own module --- .../src/name_server/rfc5155.rs | 184 +++++++----------- conformance/packages/dns-test/src/lib.rs | 1 + conformance/packages/dns-test/src/nsec3.rs | 66 +++++++ 3 files changed, 137 insertions(+), 114 deletions(-) create mode 100644 conformance/packages/dns-test/src/nsec3.rs diff --git a/conformance/packages/conformance-tests/src/name_server/rfc5155.rs b/conformance/packages/conformance-tests/src/name_server/rfc5155.rs index 7084738d..928bf2f8 100644 --- a/conformance/packages/conformance-tests/src/name_server/rfc5155.rs +++ b/conformance/packages/conformance-tests/src/name_server/rfc5155.rs @@ -1,105 +1,11 @@ -use std::collections::BTreeMap; use std::net::Ipv4Addr; use dns_test::client::{Client, DigSettings, DigStatus}; use dns_test::name_server::NameServer; +use dns_test::nsec3::NSEC3Records; 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 CHARLIE_FQDN: &str = "charlie.alice.com."; const WILDCARD_FQDN: &str = "*.alice.com."; @@ -120,9 +26,7 @@ fn name_error_response() -> Result<()> { 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(alice_fqdn, Ipv4Addr::new(1, 2, 3, 4))], &qname, RecordType::A, )?; @@ -139,9 +43,9 @@ fn name_error_response() -> Result<()> { // 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"); + let (closest_encloser_rr, next_closer_name_rr) = nsec3_rrs + .closest_encloser_proof(ALICE_HASH, CHARLIE_HASH) + .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. @@ -152,8 +56,9 @@ fn name_error_response() -> Result<()> { // 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"); + let wildcard_rr = nsec3_rrs + .find_cover(WILDCARD_HASH) + .expect("No RR in the zonefile covers the wildcard"); // Now we check that the response has the three NSEC3 RRs. find_records( @@ -192,7 +97,9 @@ fn no_data_response_not_ds() -> Result<()> { // 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"); + let qname_rr = nsec3_rrs + .find_match(ALICE_HASH) + .expect("No RR in the zonefile matches QNAME"); find_records( &nsec3_rrs_response, @@ -220,7 +127,9 @@ fn no_data_response_ds_match() -> Result<()> { // 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"); + let qname_rr = nsec3_rrs + .find_match(ALICE_HASH) + .expect("No RR in the zonefile matches QNAME"); find_records( &nsec3_rrs_response, @@ -258,9 +167,9 @@ fn no_data_response_ds_no_match() -> Result<()> { // 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"); + let (closest_encloser_rr, next_closer_name_rr) = nsec3_rrs + .closest_encloser_proof(ALICE_HASH, CHARLIE_HASH) + .expect("Cannot find a closest encloser proof in the zonefile"); find_records( &nsec3_rrs_response, @@ -307,15 +216,16 @@ fn wildcard_no_data_response() -> Result<()> { // 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"); + let (closest_encloser_rr, next_closer_name_rr) = nsec3_rrs + .closest_encloser_proof(ALICE_HASH, CHARLIE_HASH) + .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"); + let wildcard_rr = nsec3_rrs + .find_match(WILDCARD_HASH) + .expect("No RR in the zonefile matches the wildcard"); find_records( &nsec3_rrs_response, @@ -358,7 +268,8 @@ fn wildcard_answer_response() -> Result<()> { // 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) + let next_closer_name_rr = nsec3_rrs + .find_cover(CHARLIE_HASH) .expect("No RR in the zonefile covers the next closer name"); find_records( @@ -371,3 +282,48 @@ fn wildcard_answer_response() -> Result<()> { Ok(()) } + +fn query_nameserver( + records: impl IntoIterator, + qname: &FQDN, + qtype: RecordType, +) -> Result<(NSEC3Records, 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()?; + + let nsec3_rrs = NSEC3Records::new(ns.signed_zone_file()); + + 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); + } +} diff --git a/conformance/packages/dns-test/src/lib.rs b/conformance/packages/dns-test/src/lib.rs index afc3773d..49c02921 100644 --- a/conformance/packages/dns-test/src/lib.rs +++ b/conformance/packages/dns-test/src/lib.rs @@ -15,6 +15,7 @@ mod container; mod fqdn; mod implementation; pub mod name_server; +pub mod nsec3; pub mod record; mod resolver; mod trust_anchor; diff --git a/conformance/packages/dns-test/src/nsec3.rs b/conformance/packages/dns-test/src/nsec3.rs new file mode 100644 index 00000000..0dba403e --- /dev/null +++ b/conformance/packages/dns-test/src/nsec3.rs @@ -0,0 +1,66 @@ +use std::collections::BTreeMap; + +use crate::{record::NSEC3, zone_file::ZoneFile}; + +pub struct NSEC3Records { + records: BTreeMap, +} + +impl NSEC3Records { + /// Extract the NSEC3 RRs from the signed zonefile and sort them by the hash embedded in the + /// last label of each record's owner. + pub fn new(signed_zf: &ZoneFile) -> Self { + Self { + records: signed_zf + .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(), + } + } + + /// 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. + pub fn find_match<'a>(&'a self, name_hash: &str) -> Option<&'a NSEC3> { + self.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. + pub fn find_cover<'a>(&'a self, name_hash: &str) -> Option<&'a NSEC3> { + let (hash, candidate) = self + .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(|| self.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. + pub fn closest_encloser_proof<'a>( + &'a self, + closest_encloser_hash: &str, + next_closer_name_hash: &str, + ) -> Option<(&'a NSEC3, &'a NSEC3)> { + Some(( + self.find_match(closest_encloser_hash)?, + self.find_cover(next_closer_name_hash)?, + )) + } +}