Skip to content
This repository has been archived by the owner on Jun 7, 2024. It is now read-only.

Add a non-existence test using NSEC3 #63

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/conformance-tests/src/resolver/dnssec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

mod fixtures;
mod rfc4035;
mod rfc5155;
mod scenarios;
225 changes: 225 additions & 0 deletions packages/conformance-tests/src/resolver/dnssec/rfc5155.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
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: &[&str]) -> usize {
assert!(!haystack.is_empty());

let (Ok(index) | Err(index)) = haystack.binary_search(&needle);
match index {
0 => haystack.len() - 1,
pvdrz marked this conversation as resolved.
Show resolved Hide resolved
index => index - 1,
}
}

/// Find the index of the element immediately next to `needle` in `haystack`.
fn find_next(needle: &str, haystack: &[&str]) -> usize {
assert!(!haystack.is_empty());

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
}

#[test]
#[ignore]
fn proof_of_non_existence_with_nsec3_records() -> Result<()> {
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.")?;

// 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. */
pvdrz marked this conversation as resolved.
Show resolved Hide resolved
let wildcard_hash = "M417220KKVJDM7CHD6QVUV4TGHDU2N2K"; /* *.nameservers.com */
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will be included in the signed zone file despite the original / unsigned zone file not having a wildcard record?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, it will not, it is added because the server will compute it and generate an NSEC3 record that covers that specific hash.

let nameservers_hash = "7M2FCI51VUC2E5RIBDPTVJ6S08EMMR3O"; /* nameservers.com. */

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)));

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",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if your leaf nameserver was not nameservers.com. but rather somedomain.com. you would not need to deal with these primaryNN.nameservers.com. records

also, changing the naming scheme to something like primary${thread_id}-${thread_local_count} would make this list shorter but you would need to compute the hash in the test code instead of computing them offline / ahead of time

it's probably easier to read the contents of the signed zone file to get the hashes

Copy link
Collaborator Author

@pvdrz pvdrz May 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having to compute the hash in the tests kinda defeats the purpose as eventually we'd have to integrate similar code to Hickory-DNS and it would feel like we're testing the hashing implementation against itself.

BUUUUT, if we were able to use example. we could just reuse the examples in the RFC's appendix.

"primary1.nameservers.com." => "C1JIVO7U1IH8JFK6BMU60V65S5FVEFT2",
"primary2.nameservers.com." => "NJ1OLIA8A6HTNBMC20ATDDIDTA42AI8V",
"primary3.nameservers.com." => "9JMUC5ADM6MUKUN4NTBMR19C1030SRM0",
"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",
"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}"),
};

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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you may want to add a try_into_nsec3 method using rust-analyzer and use that here

Some(r)
} else {
None
}
})
.collect::<Vec<_>>();

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
// 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);
Copy link
Collaborator

@japaric japaric May 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are several assertions in this single unit test. I would suggest splitting it into several #[test]s, where each one maps to a sentence / paragraph in the RFC text, and using a fixture (i.e. a setup function, see src/resolver/dnssec/fixtures.rs) to set up the same name server graph in all the tests.

Copy link
Collaborator Author

@pvdrz pvdrz May 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds a bit expensive. Would it make sense to initialize this in a static then?

But then they wouldn't be dropped 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to initialize this in a static then?

if you put anything that internally contains a Network or a Container in a static variable then its destructor won't run even if the process shut down cleanly. not running the destructors means leaving docker networks / containers behind which is undesirable

at the moment, I would not worry about having more #[test] functions making things slower. more tests means you have more granularity about what's failing. if you put many assertions in a single tests and the first one fails then you won't know the outcome of the other assertions. tests can be filtered through the CLI so devs fixing issues will only be running the tests that are of interest to them so the total number of tests won't impact their workflow.

that being said, there are ways to set up an object that will be shared by #[test] functions and then properly tear down when all the #[test] functions end but it's quite a bit of work to setup. it does involve a shared static variable and a Mutex. the side effect of such setup is that one test will impact the other because of the caching behavior of resolvers and nameservers; meaning that such resource-efficient test groups should only be used to test things where caching is unimportant (lest we come up with a way to clear all the caches in between test runs; which again adds more complexity)


Ok(())
}
4 changes: 4 additions & 0 deletions packages/dns-test/src/fqdn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ impl FQDN {
.filter(|label| !label.is_empty())
.count()
}

pub fn labels(&self) -> impl Iterator<Item = &str> {
self.inner.split('.')
}
}

impl FromStr for FQDN {
Expand Down