From 97f0ef57318b72892e9eb4d3ae4e1857427de34c Mon Sep 17 00:00:00 2001 From: Christian Poveda Date: Fri, 17 May 2024 20:03:50 -0500 Subject: [PATCH 1/8] Add a non-existence test using NSEC3 --- .../conformance-tests/src/resolver/dnssec.rs | 1 + .../src/resolver/dnssec/rfc5155.rs | 181 ++++++++++++++++++ packages/dns-test/src/fqdn.rs | 4 + 3 files changed, 186 insertions(+) create mode 100644 packages/conformance-tests/src/resolver/dnssec/rfc5155.rs diff --git a/packages/conformance-tests/src/resolver/dnssec.rs b/packages/conformance-tests/src/resolver/dnssec.rs index f813ae4..7d00478 100644 --- a/packages/conformance-tests/src/resolver/dnssec.rs +++ b/packages/conformance-tests/src/resolver/dnssec.rs @@ -2,4 +2,5 @@ mod fixtures; mod rfc4035; +mod rfc5155; mod scenarios; diff --git a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs new file mode 100644 index 0000000..a9fcfe8 --- /dev/null +++ b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs @@ -0,0 +1,181 @@ +use std::net::Ipv4Addr; + +use dns_test::{ + client::{Client, DigSettings}, + name_server::{Graph, NameServer, Sign}, + record::{Record, RecordType, NSEC3}, + Network, Resolver, Result, FQDN, +}; + +/// Find the index of the element immediately previous to `needle` in `haystack`. +fn find_prev(needle: &str, haystack: &Vec<&str>) -> usize { + let (Ok(index) | Err(index)) = haystack.binary_search(&needle); + match index { + 0 => haystack.len() - 1, + index => index - 1, + } +} + +/// Find the index of the element immediately next to `needle` in `haystack`. +fn find_next(needle: &str, haystack: &Vec<&str>) -> usize { + let (Ok(index) | Err(index)) = haystack.binary_search(&needle); + (index + 1) % haystack.len() +} + +/// Return `true` if `record` convers `hash`. This is, if `hash` falls in between the owner of +/// `record` and the next hashed owner name of `record`. +fn covers(record: &NSEC3, hash: &str) -> bool { + record.next_hashed_owner_name.as_str() > hash + && record.fqdn.labels().next().unwrap().to_uppercase().as_str() < hash +} + +#[ignore] +#[test] +fn proof_of_non_existence_with_nsec3_records() -> Result<()> { + let peer = dns_test::peer(); + let network = Network::new()?; + + let alice_fqdn = FQDN("alice.nameservers.com.")?; + let bob_fqdn = FQDN("bob.nameservers.com.")?; + let charlie_fqdn = FQDN("charlie.nameservers.com.")?; + + let bob_hash = "9AU9KOU2HVABPTPB7D3AQBH57QPLNDI6"; /* bob.namesevers.com. */ + let wildcard_hash = "M417220KKVJDM7CHD6QVUV4TGHDU2N2K"; /* *.nameservers.com */ + let nameservers_hash = "7M2FCI51VUC2E5RIBDPTVJ6S08EMMR3O"; /* nameservers.com. */ + + let mut leaf_ns = NameServer::new(&peer, FQDN::NAMESERVERS, &network)?; + leaf_ns + .add(Record::a(alice_fqdn.clone(), Ipv4Addr::new(1, 2, 3, 4))) + .add(Record::a(charlie_fqdn.clone(), Ipv4Addr::new(1, 2, 3, 5))); + + let Graph { + nameservers, + root, + trust_anchor, + } = Graph::build(leaf_ns, Sign::Yes)?; + + // This is the sorted list of hashes that can be proven to exist by the name servers. + let hashes = { + // These are the hashes that we statically know they exist. + let mut hashes = vec![ + nameservers_hash, + "8C538GR0B1FT11G01UI8THM4IPM64NUC", /* charlie.nameservers.com. */ + "PQVTTO5UIDVCHKP34DDQ3LIIH7TQED20", /* alice.nameservers.com. */ + ]; + + // Include the hashes of the nameservers dynamically as they change between executions. + for ns in &nameservers { + let hash = match ns.fqdn().as_str() { + "primary0.nameservers.com." => "E05P5R80N590NS9PP24QOOFHRT605T8A", + "primary1.nameservers.com." => "C1JIVO7U1IH8JFK6BMU60V65S5FVEFT2", + "primary2.nameservers.com." => "NJ1OLIA8A6HTNBMC20ATDDIDTA42AI8V", + "primary3.nameservers.com." => "9JMUC5ADM6MUKUN4NTBMR19C1030SRM0", + "primary4.nameservers.com." => "0RM17SJJI0C51PADDIFG9LI8K2S04EE9", + "primary5.nameservers.com." => "546PPSKSPN8DOKTTA9MASB0TM06I72GD", + "primary6.nameservers.com." => "40PTL9S01ERIF3E05RERHM419K0465GB", + ns => panic!("Unexpected nameserver: {ns}"), + }; + + hashes.push(hash); + } + + // Sort the hashes + hashes.sort(); + hashes + }; + + let trust_anchor = &trust_anchor.unwrap(); + let resolver = Resolver::new(&network, root) + .trust_anchor(trust_anchor) + .start(&dns_test::subject())?; + let resolver_addr = resolver.ipv4_addr(); + + let client = Client::new(&network)?; + let settings = *DigSettings::default().recurse().authentic_data().dnssec(); + + let output = client.dig(settings, resolver_addr, RecordType::MX, &bob_fqdn)?; + + assert!(output.status.is_nxdomain()); + + let nsec3_rrs = output + .authority + .into_iter() + .filter_map(|record| { + if let Record::NSEC3(r) = record { + Some(r) + } else { + None + } + }) + .collect::>(); + + // Closest encloser RR: Must match the closest encloser of bob.nameservers.com. + // + // The closest encloser must be nameservers.com. as it is the closest existing ancestor of + // bob.nameservers.com. + let closest_encloser_fqdn = FQDN(nameservers_hash.to_lowercase() + ".nameservers.com.")?; + let closest_encloser_rr = nsec3_rrs + .iter() + .find(|record| record.fqdn == closest_encloser_fqdn) + .expect("Closest encloser RR was not found"); + + // Check that the next hashed owner name of the record is the hash immediately next to the hash + // of nameservers.com. + let expected = hashes[find_next(nameservers_hash, &hashes)]; + let found = &closest_encloser_rr.next_hashed_owner_name; + assert_eq!(expected, found); + + // Next closer name RR: Must cover the next closer name of bob.nameservers.com. + // + // The next closer name of bob.nameservers.com. is bob.nameservers.com. as it is the name one + // label longer than nameservers.com. + let next_closer_name_rr = nsec3_rrs + .iter() + .find(|record| covers(record, bob_hash)) + .expect("Closest encloser RR was not found"); + + let index = find_prev(bob_hash, &hashes); + + // Check that the owner hash of record is the hash immediately previous to the hash of + // bob.nameservers.com. + let expected = hashes[index]; + let found = next_closer_name_rr + .fqdn + .labels() + .next() + .unwrap() + .to_uppercase(); + assert_eq!(expected, found); + + // Check that the next hashed owner name of the record is the hash immediately next to the + // owner hash. + let expected = hashes[(index + 1) % hashes.len()]; + let found = &next_closer_name_rr.next_hashed_owner_name; + assert_eq!(expected, found); + + // Wildcard at the closet encloser RR: Must cover the wildcard at the closest encloser of + // bob.nameservers.com. + // + // The wildcard at the closest encloser of bob.nameservers.com. is *.nameservers.com. as it is + // the wildcard at nameservers.com. + let wildcard_rr = nsec3_rrs + .iter() + .find(|record| covers(record, wildcard_hash)) + .expect("Wildcard RR was not found"); + + let index = find_prev(wildcard_hash, &hashes); + + // Check that the owner hash of record is the hash immediately previous to the hash of + // *.nameservers.com. + let expected = hashes[index]; + let found = wildcard_rr.fqdn.labels().next().unwrap().to_uppercase(); + assert_eq!(expected, found); + + // Check that the next hashed owner name of the record is the hash immediately next to the + // owner hash. + let expected = hashes[(index + 1) % hashes.len()]; + let found = &wildcard_rr.next_hashed_owner_name; + assert_eq!(expected, found); + + Ok(()) +} diff --git a/packages/dns-test/src/fqdn.rs b/packages/dns-test/src/fqdn.rs index b0ae757..1b4d34e 100644 --- a/packages/dns-test/src/fqdn.rs +++ b/packages/dns-test/src/fqdn.rs @@ -77,6 +77,10 @@ impl FQDN { .filter(|label| !label.is_empty()) .count() } + + pub fn labels(&self) -> impl Iterator { + self.inner.split('.') + } } impl FromStr for FQDN { From 53c7a5ba18cfd3d51d527ef8c550c6ef2c6d6fba Mon Sep 17 00:00:00 2001 From: Christian Poveda Date: Sat, 18 May 2024 12:14:59 -0500 Subject: [PATCH 2/8] Add more nameserver matches --- .../src/resolver/dnssec/rfc5155.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs index a9fcfe8..6825698 100644 --- a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs +++ b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs @@ -29,10 +29,8 @@ fn covers(record: &NSEC3, hash: &str) -> bool { && record.fqdn.labels().next().unwrap().to_uppercase().as_str() < hash } -#[ignore] #[test] fn proof_of_non_existence_with_nsec3_records() -> Result<()> { - let peer = dns_test::peer(); let network = Network::new()?; let alice_fqdn = FQDN("alice.nameservers.com.")?; @@ -43,7 +41,7 @@ fn proof_of_non_existence_with_nsec3_records() -> Result<()> { let wildcard_hash = "M417220KKVJDM7CHD6QVUV4TGHDU2N2K"; /* *.nameservers.com */ let nameservers_hash = "7M2FCI51VUC2E5RIBDPTVJ6S08EMMR3O"; /* nameservers.com. */ - let mut leaf_ns = NameServer::new(&peer, FQDN::NAMESERVERS, &network)?; + let mut leaf_ns = NameServer::new(&dns_test::PEER, FQDN::NAMESERVERS, &network)?; leaf_ns .add(Record::a(alice_fqdn.clone(), Ipv4Addr::new(1, 2, 3, 4))) .add(Record::a(charlie_fqdn.clone(), Ipv4Addr::new(1, 2, 3, 5))); @@ -73,6 +71,14 @@ fn proof_of_non_existence_with_nsec3_records() -> Result<()> { "primary4.nameservers.com." => "0RM17SJJI0C51PADDIFG9LI8K2S04EE9", "primary5.nameservers.com." => "546PPSKSPN8DOKTTA9MASB0TM06I72GD", "primary6.nameservers.com." => "40PTL9S01ERIF3E05RERHM419K0465GB", + "primary7.nameservers.com." => "G8O54KH0MJNTDE1IFQOBSLNRA5G7PGJ0", + "primary8.nameservers.com." => "FRMTGMJ1QH91I2QHU61BTJNFKS39UQ2D", + "primary9.nameservers.com." => "6RJVT7UR167JB2296JTV2VG9P8LJK1KG", + "primary10.nameservers.com." => "1CN3HD3QPK3R53P3L13FL91KSML0LT13", + "primary11.nameservers.com." => "6TEE5C0TA2FU4T2KA9R3CT749IVDH0R2", + "primary12.nameservers.com." => "0DJ0I4F1D7AANKJQ5RB9CLFSALMC636P", + "primary13.nameservers.com." => "QBHIT7FBP5GM6K1NPK23KIKFRFLESB59", + "primary14.nameservers.com." => "OAIN54SNHJ76M5ATNE9U21DMVC0QIU6L", ns => panic!("Unexpected nameserver: {ns}"), }; @@ -87,7 +93,7 @@ fn proof_of_non_existence_with_nsec3_records() -> Result<()> { let trust_anchor = &trust_anchor.unwrap(); let resolver = Resolver::new(&network, root) .trust_anchor(trust_anchor) - .start(&dns_test::subject())?; + .start(&dns_test::SUBJECT)?; let resolver_addr = resolver.ipv4_addr(); let client = Client::new(&network)?; From 861355624fde7879ddda81ca396f2901e29358ec Mon Sep 17 00:00:00 2001 From: Christian Poveda Date: Wed, 22 May 2024 14:40:24 -0500 Subject: [PATCH 3/8] Assert that haystacks are not empty --- packages/conformance-tests/src/resolver/dnssec/rfc5155.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs index 6825698..b40e2a6 100644 --- a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs +++ b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs @@ -9,6 +9,8 @@ use dns_test::{ /// Find the index of the element immediately previous to `needle` in `haystack`. fn find_prev(needle: &str, haystack: &Vec<&str>) -> usize { + assert!(!haystack.is_empty()); + let (Ok(index) | Err(index)) = haystack.binary_search(&needle); match index { 0 => haystack.len() - 1, @@ -18,6 +20,8 @@ fn find_prev(needle: &str, haystack: &Vec<&str>) -> usize { /// Find the index of the element immediately next to `needle` in `haystack`. fn find_next(needle: &str, haystack: &Vec<&str>) -> usize { + assert!(!haystack.is_empty()); + let (Ok(index) | Err(index)) = haystack.binary_search(&needle); (index + 1) % haystack.len() } From 8c497bedb1a950f5667afa6702c9b4d312caaa54 Mon Sep 17 00:00:00 2001 From: Christian Poveda Date: Wed, 22 May 2024 14:56:46 -0500 Subject: [PATCH 4/8] verify the hashing parameters --- .../conformance-tests/src/resolver/dnssec/rfc5155.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs index b40e2a6..3310e69 100644 --- a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs +++ b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs @@ -119,6 +119,15 @@ fn proof_of_non_existence_with_nsec3_records() -> Result<()> { }) .collect::>(); + for record in &nsec3_rrs { + // Check that the hashing function is SHA-1. + assert_eq!(record.hash_alg, 1); + // Check that the salt is empty (dig puts `-` in the salt field when it is empty). + assert_eq!(record.salt, "-"); + // Check that the number of iterations is 1. + assert_eq!(record.iterations, 1); + } + // Closest encloser RR: Must match the closest encloser of bob.nameservers.com. // // The closest encloser must be nameservers.com. as it is the closest existing ancestor of From c369acdadaee3fcf0a16d1d947df6a37ef993a4b Mon Sep 17 00:00:00 2001 From: Christian Poveda Date: Wed, 22 May 2024 14:56:53 -0500 Subject: [PATCH 5/8] explain how to compute the hashes --- packages/conformance-tests/src/resolver/dnssec/rfc5155.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs index 3310e69..65da80a 100644 --- a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs +++ b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs @@ -41,6 +41,14 @@ fn proof_of_non_existence_with_nsec3_records() -> Result<()> { let bob_fqdn = FQDN("bob.nameservers.com.")?; let charlie_fqdn = FQDN("charlie.nameservers.com.")?; + // To compute these hashes refer to [Section 5 of RFC 5515](https://datatracker.ietf.org/doc/html/rfc5155#section-5) + // or install `dnspython` and then run: + // + // ```python + // import dns.dnssec + // + // dns.dnssec.nsec3_hash(domain, salt="", iterations=1, algorithm="SHA1") + // ``` let bob_hash = "9AU9KOU2HVABPTPB7D3AQBH57QPLNDI6"; /* bob.namesevers.com. */ let wildcard_hash = "M417220KKVJDM7CHD6QVUV4TGHDU2N2K"; /* *.nameservers.com */ let nameservers_hash = "7M2FCI51VUC2E5RIBDPTVJ6S08EMMR3O"; /* nameservers.com. */ From e8a952a2b82a172d4b4814105246090342b64da6 Mon Sep 17 00:00:00 2001 From: Christian Poveda Date: Wed, 22 May 2024 15:01:09 -0500 Subject: [PATCH 6/8] Add even more nameserver matches --- .../src/resolver/dnssec/rfc5155.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs index 65da80a..5a999ba 100644 --- a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs +++ b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs @@ -91,6 +91,22 @@ fn proof_of_non_existence_with_nsec3_records() -> Result<()> { "primary12.nameservers.com." => "0DJ0I4F1D7AANKJQ5RB9CLFSALMC636P", "primary13.nameservers.com." => "QBHIT7FBP5GM6K1NPK23KIKFRFLESB59", "primary14.nameservers.com." => "OAIN54SNHJ76M5ATNE9U21DMVC0QIU6L", + "primary15.nameservers.com." => "4VNB3RBR9DRCL9FUD30R1B70AKBOQ2VR", + "primary16.nameservers.com." => "F55MMTN4LRTVELLVHP7C7VP8HKR5EGGR", + "primary17.nameservers.com." => "69EQOTFRBMV1VOSVI5JI45HAAFKM687U", + "primary18.nameservers.com." => "VPFCG36N058VJJHREDI109TLNN3ULTAL", + "primary19.nameservers.com." => "NGMTQB48BJ52E6VNPV7B4UQ43PIMV63D", + "primary20.nameservers.com." => "VKMT6Q9OO8UT7UH6L5TNU441J9DE69GM", + "primary21.nameservers.com." => "M0S7C0H6BNVE984C1MPD57BRAQ6NFC5F", + "primary22.nameservers.com." => "925I7PPN55AHIREP0H4N24GT99EKFIU2", + "primary23.nameservers.com." => "530REIRKSLRIVRS7S695PNGEM9VBC8K7", + "primary24.nameservers.com." => "C8BU7CCSPTSOD9T8PLH5I1PK95OVN0HK", + "primary25.nameservers.com." => "TGIMV2IN9Q28K984IDH9TK7VK2G9J6NP", + "primary26.nameservers.com." => "552V9A7DP75FLS9FU9O9T8AOJM8AAI5M", + "primary27.nameservers.com." => "5V3AV5U0L1G265IGO4D673K50UO6G8MI", + "primary28.nameservers.com." => "CK1ML666D0KQKU9ESTSOM6P32HSDGB60", + "primary29.nameservers.com." => "UOBH9BHGQ2756GG6IUM6FILVDSAKJ70C", + "primary30.nameservers.com." => "MK7H6U1V39MHIDC6RPKJORAU3VCH36LU", ns => panic!("Unexpected nameserver: {ns}"), }; @@ -132,7 +148,7 @@ fn proof_of_non_existence_with_nsec3_records() -> Result<()> { assert_eq!(record.hash_alg, 1); // Check that the salt is empty (dig puts `-` in the salt field when it is empty). assert_eq!(record.salt, "-"); - // Check that the number of iterations is 1. + // Check that the number of iterations is 1. assert_eq!(record.iterations, 1); } From 7e6ae362f51419755297dffc131b56c54f0f8135 Mon Sep 17 00:00:00 2001 From: Christian Poveda Date: Wed, 22 May 2024 15:08:56 -0500 Subject: [PATCH 7/8] mark test as ignored --- packages/conformance-tests/src/resolver/dnssec/rfc5155.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs index 5a999ba..47adc87 100644 --- a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs +++ b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs @@ -34,6 +34,7 @@ fn covers(record: &NSEC3, hash: &str) -> bool { } #[test] +#[ignore] fn proof_of_non_existence_with_nsec3_records() -> Result<()> { let network = Network::new()?; From 8467cb2e06df0173e6c7c5935378f34a4c163f6e Mon Sep 17 00:00:00 2001 From: Christian Poveda Date: Wed, 22 May 2024 16:30:24 -0500 Subject: [PATCH 8/8] fix clippy lints I didn't know that the binary search method was part of `&[T]`, I thought it was part of `Vec` instead. The more you know :rainbow: --- packages/conformance-tests/src/resolver/dnssec/rfc5155.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs index 47adc87..030f59a 100644 --- a/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs +++ b/packages/conformance-tests/src/resolver/dnssec/rfc5155.rs @@ -8,7 +8,7 @@ use dns_test::{ }; /// Find the index of the element immediately previous to `needle` in `haystack`. -fn find_prev(needle: &str, haystack: &Vec<&str>) -> usize { +fn find_prev(needle: &str, haystack: &[&str]) -> usize { assert!(!haystack.is_empty()); let (Ok(index) | Err(index)) = haystack.binary_search(&needle); @@ -19,7 +19,7 @@ fn find_prev(needle: &str, haystack: &Vec<&str>) -> usize { } /// Find the index of the element immediately next to `needle` in `haystack`. -fn find_next(needle: &str, haystack: &Vec<&str>) -> usize { +fn find_next(needle: &str, haystack: &[&str]) -> usize { assert!(!haystack.is_empty()); let (Ok(index) | Err(index)) = haystack.binary_search(&needle);