diff --git a/docker/unbound.Dockerfile b/docker/unbound.Dockerfile index 138d7abe..ce8e2a62 100644 --- a/docker/unbound.Dockerfile +++ b/docker/unbound.Dockerfile @@ -2,5 +2,3 @@ FROM ubuntu:22.04 RUN apt-get update && \ apt-get install -y dnsutils unbound nsd iputils-ping tshark vim ldnsutils - -COPY ./files/etc/unbound/unbound.conf /etc/unbound/unbound.conf diff --git a/src/client.rs b/src/client.rs index c40eb33d..1902ac7d 100644 --- a/src/client.rs +++ b/src/client.rs @@ -16,6 +16,22 @@ impl Client { }) } + // FIXME this needs to use the same trust anchor as `RecursiveResolver` or validation will fail + pub fn delv( + &self, + server: Ipv4Addr, + record_type: RecordType, + fqdn: &FQDN<'_>, + ) -> Result { + self.inner.stdout(&[ + "delv", + "+mtrace", + &format!("@{server}"), + record_type.as_str(), + fqdn.as_str(), + ]) + } + pub fn dig( &self, recurse: Recurse, @@ -153,6 +169,7 @@ pub struct DigFlags { pub recursion_desired: bool, pub recursion_available: bool, pub authoritative_answer: bool, + pub authenticated_data: bool, } impl FromStr for DigFlags { @@ -163,6 +180,7 @@ impl FromStr for DigFlags { let mut recursion_desired = false; let mut recursion_available = false; let mut authoritative_answer = false; + let mut authenticated_data = false; for flag in input.split_whitespace() { match flag { @@ -170,6 +188,7 @@ impl FromStr for DigFlags { "rd" => recursion_desired = true, "ra" => recursion_available = true, "aa" => authoritative_answer = true, + "ad" => authenticated_data = true, _ => return Err(format!("unknown flag: {flag}").into()), } } @@ -179,6 +198,7 @@ impl FromStr for DigFlags { recursion_desired, recursion_available, authoritative_answer, + authenticated_data, }) } } diff --git a/src/name_server.rs b/src/name_server.rs index c757cba5..c6c8fb6e 100644 --- a/src/name_server.rs +++ b/src/name_server.rs @@ -1,12 +1,10 @@ -use core::array; -use core::str::FromStr; use core::sync::atomic::{self, AtomicUsize}; use std::net::Ipv4Addr; use std::process::Child; use crate::container::Container; -use crate::zone_file::{self, SoaSettings, ZoneFile}; -use crate::{Error, Result, FQDN}; +use crate::zone_file::{self, SoaSettings, ZoneFile, DNSKEY, DS}; +use crate::{Result, FQDN}; pub struct NameServer<'a, State> { container: Container, @@ -68,6 +66,12 @@ impl<'a> NameServer<'a, Stopped> { self } + /// Adds a DS record to the zone file + pub fn ds(&mut self, ds: DS) -> &mut Self { + self.zone_file.entry(ds); + self + } + /// Freezes and signs the name server's zone file pub fn sign(self) -> Result> { // TODO do we want to make these settings configurable? @@ -90,13 +94,13 @@ impl<'a> NameServer<'a, Stopped> { format!("cd {ZONES_DIR} && ldns-keygen -a {ALGORITHM} -b {ZSK_BITS} {zone}"); let zsk_filename = container.stdout(&["sh", "-c", &zsk_keygen])?; let zsk_path = format!("{ZONES_DIR}/{zsk_filename}.key"); - let zsk: Key = container.stdout(&["cat", &zsk_path])?.parse()?; + let zsk: DNSKEY = container.stdout(&["cat", &zsk_path])?.parse()?; let ksk_keygen = format!("cd {ZONES_DIR} && ldns-keygen -k -a {ALGORITHM} -b {KSK_BITS} {zone}"); let ksk_filename = container.stdout(&["sh", "-c", &ksk_keygen])?; let ksk_path = format!("{ZONES_DIR}/{ksk_filename}.key"); - let ksk: Key = container.stdout(&["cat", &ksk_path])?.parse()?; + let ksk: DNSKEY = container.stdout(&["cat", &ksk_path])?.parse()?; // -n = use NSEC3 instead of NSEC // -p = set the opt-out flag on all nsec3 rrs @@ -105,6 +109,11 @@ impl<'a> NameServer<'a, Stopped> { ); container.status_ok(&["sh", "-c", &signzone])?; + // TODO do we want to make the hashing algorithm configurable? + // -2 = use SHA256 for the DS hash + let key2ds = format!("cd {ZONES_DIR} && ldns-key2ds -n -2 {ZONE_FILENAME}.signed"); + let ds: DS = container.stdout(&["sh", "-c", &key2ds])?.parse()?; + // we have an in-memory representation of the zone file so we just delete the on-disk version let zone_file_path = zone_file_path(); container.status_ok(&["mv", &format!("{zone_file_path}.signed"), &zone_file_path])?; @@ -115,9 +124,10 @@ impl<'a> NameServer<'a, Stopped> { container, zone_file, state: Signed { - zsk, + ds, ksk, signed_zone_file, + zsk, }, }) } @@ -148,62 +158,6 @@ impl<'a> NameServer<'a, Stopped> { } } -#[derive(Debug)] -pub struct Key { - pub bits: u16, - pub encoded: String, - pub id: u32, -} - -impl FromStr for Key { - type Err = Error; - - fn from_str(input: &str) -> Result { - let (before, after) = input.split_once(';').ok_or("comment was not found")?; - let mut columns = before.split_whitespace(); - - let [Some(_zone), Some(class), Some(record_type), Some(_flags), Some(_protocol), Some(_algorithm), Some(encoded), None] = - array::from_fn(|_| columns.next()) - else { - return Err("expected 7 columns".into()); - }; - - if record_type != "DNSKEY" { - return Err(format!("tried to parse `{record_type}` record as a DNSKEY record").into()); - } - - if class != "IN" { - return Err(format!("unknown class: {class}").into()); - } - - // {id = 24975 (zsk), size = 1024b} - let error = "invalid comment syntax"; - let (id_expr, size_expr) = after.split_once(',').ok_or(error)?; - - // {id = 24975 (zsk) - let (id_lhs, id_rhs) = id_expr.split_once('=').ok_or(error)?; - if id_lhs.trim() != "{id" { - return Err(error.into()); - } - - // 24975 (zsk) - let (id, _key_type) = id_rhs.trim().split_once(' ').ok_or(error)?; - - // size = 1024b} - let (size_lhs, size_rhs) = size_expr.split_once('=').ok_or(error)?; - if size_lhs.trim() != "size" { - return Err(error.into()); - } - let bits = size_rhs.trim().strip_suffix("b}").ok_or(error)?.parse()?; - - Ok(Self { - bits, - encoded: encoded.to_string(), - id: id.parse()?, - }) - } -} - const ZONES_DIR: &str = "/etc/nsd/zones"; const ZONE_FILENAME: &str = "main.zone"; @@ -239,17 +193,21 @@ impl<'a> NameServer<'a, Signed> { }) } - pub fn key_signing_key(&self) -> &Key { + pub fn key_signing_key(&self) -> &DNSKEY { &self.state.ksk } - pub fn zone_signing_key(&self) -> &Key { + pub fn zone_signing_key(&self) -> &DNSKEY { &self.state.zsk } pub fn signed_zone_file(&self) -> &str { &self.state.signed_zone_file } + + pub fn ds(&self) -> &DS { + &self.state.ds + } } impl<'a, S> NameServer<'a, S> { @@ -257,6 +215,7 @@ impl<'a, S> NameServer<'a, S> { self.container.ipv4_addr() } + /// Zone file BEFORE signing pub fn zone_file(&self) -> &ZoneFile<'a> { &self.zone_file } @@ -273,8 +232,9 @@ impl<'a, S> NameServer<'a, S> { pub struct Stopped; pub struct Signed { - zsk: Key, - ksk: Key, + ds: DS, + zsk: DNSKEY, + ksk: DNSKEY, signed_zone_file: String, } @@ -360,21 +320,21 @@ mod tests { #[test] fn signed() -> Result<()> { - let tld_ns = NameServer::new(FQDN::ROOT)?.sign()?; + let ns = NameServer::new(FQDN::ROOT)?.sign()?; - eprintln!("KSK: {:?}", tld_ns.key_signing_key()); - eprintln!("ZSK: {:?}", tld_ns.zone_signing_key()); - eprintln!("root.zone.signed:\n{}", tld_ns.signed_zone_file()); + eprintln!("KSK:\n{}", ns.key_signing_key()); + eprintln!("ZSK:\n{}", ns.zone_signing_key()); + eprintln!("root.zone.signed:\n{}", ns.signed_zone_file()); - let tld_ns = tld_ns.start()?; + let tld_ns = ns.start()?; - let ipv4_addr = tld_ns.ipv4_addr(); + let ns_addr = tld_ns.ipv4_addr(); let client = Client::new()?; let output = client.dig( Recurse::No, Dnssec::Yes, - ipv4_addr, + ns_addr, RecordType::SOA, &FQDN::ROOT, )?; @@ -392,18 +352,4 @@ mod tests { Ok(()) } - - #[test] - fn can_parse_ldns_keygen_output() -> Result<()> { - let input = "example.com. IN DNSKEY 256 3 7 AwEAAdIpMlio4GJas7GbIZ9xRpzpB2pf4SxBJcsquN/0yNBPGNE2rzcFykqMAKmLwypk1/1q/EdHVa4tQ5RlK0w09CRhgSXfCaph+yLNJKpiPyuVcXKl2k0RnO4p835sgVEUIvx8qGTDo7c7DA9UBje+/3ViFKqVhOBaWyT6gHAmNVpb ;{id = 24975 (zsk), size = 1024b}"; - - let key: Key = input.parse()?; - - assert_eq!(1024, key.bits); - assert_eq!(24975, key.id); - let expected = "AwEAAdIpMlio4GJas7GbIZ9xRpzpB2pf4SxBJcsquN/0yNBPGNE2rzcFykqMAKmLwypk1/1q/EdHVa4tQ5RlK0w09CRhgSXfCaph+yLNJKpiPyuVcXKl2k0RnO4p835sgVEUIvx8qGTDo7c7DA9UBje+/3ViFKqVhOBaWyT6gHAmNVpb"; - assert_eq!(expected, key.encoded); - - Ok(()) - } } diff --git a/src/recursive_resolver.rs b/src/recursive_resolver.rs index 4879e321..5e369e69 100644 --- a/src/recursive_resolver.rs +++ b/src/recursive_resolver.rs @@ -3,7 +3,7 @@ use std::net::Ipv4Addr; use std::process::Child; use crate::container::Container; -use crate::zone_file::Root; +use crate::zone_file::{Root, DNSKEY}; use crate::Result; pub struct RecursiveResolver { @@ -12,7 +12,9 @@ pub struct RecursiveResolver { } impl RecursiveResolver { - pub fn start(roots: &[Root]) -> Result { + pub fn start(roots: &[Root], trust_anchors: &[DNSKEY]) -> Result { + const TRUST_ANCHOR_FILE: &str = "/etc/trusted-key.key"; + let container = Container::run()?; let mut hints = String::new(); @@ -22,6 +24,18 @@ impl RecursiveResolver { container.cp("/etc/unbound/root.hints", &hints)?; + let use_dnssec = !trust_anchors.is_empty(); + container.cp("/etc/unbound/unbound.conf", &unbound_conf(use_dnssec))?; + + if use_dnssec { + let trust_anchor = trust_anchors.iter().fold(String::new(), |mut buf, ds| { + writeln!(buf, "{ds}").expect("infallible"); + buf + }); + + container.cp(TRUST_ANCHOR_FILE, &trust_anchor)?; + } + let child = container.spawn(&["unbound", "-d"])?; Ok(Self { child, container }) @@ -38,8 +52,13 @@ impl Drop for RecursiveResolver { } } +fn unbound_conf(use_dnssec: bool) -> String { + minijinja::render!(include_str!("templates/unbound.conf.jinja"), use_dnssec => use_dnssec) +} + #[cfg(test)] mod tests { + use crate::{ client::{Client, Dnssec, Recurse}, name_server::NameServer, @@ -81,7 +100,7 @@ mod tests { eprintln!("root.zone:\n{}", root_ns.zone_file()); let roots = &[Root::new(root_ns.fqdn().clone(), root_ns.ipv4_addr())]; - let resolver = RecursiveResolver::start(roots)?; + let resolver = RecursiveResolver::start(roots, &[])?; let resolver_ip_addr = resolver.ipv4_addr(); let client = Client::new()?; @@ -103,4 +122,115 @@ mod tests { Ok(()) } + + // no DS records are involved; this is a single-link chain of trust + #[test] + fn can_validate_without_delegation() -> Result<()> { + let mut ns = NameServer::new(FQDN::ROOT)?; + ns.a(ns.fqdn().clone(), ns.ipv4_addr()); + let ns = ns.sign()?; + + let root_ksk = ns.key_signing_key().clone(); + let root_zsk = ns.zone_signing_key().clone(); + + eprintln!("root.zone.signed:\n{}", ns.signed_zone_file()); + + let ns = ns.start()?; + + eprintln!("root.zone:\n{}", ns.zone_file()); + + let roots = &[Root::new(ns.fqdn().clone(), ns.ipv4_addr())]; + + let trust_anchor = [root_ksk.clone(), root_zsk.clone()]; + let resolver = RecursiveResolver::start(roots, &trust_anchor)?; + let resolver_addr = resolver.ipv4_addr(); + + let client = Client::new()?; + let output = client.dig( + Recurse::Yes, + Dnssec::Yes, + resolver_addr, + RecordType::SOA, + &FQDN::ROOT, + )?; + + assert!(output.status.is_noerror()); + assert!(output.flags.authenticated_data); + + Ok(()) + } + + #[test] + fn can_validate_with_delegation() -> Result<()> { + let expected_ipv4_addr = Ipv4Addr::new(1, 2, 3, 4); + let needle = FQDN("example.nameservers.com.")?; + + let mut root_ns = NameServer::new(FQDN::ROOT)?; + let mut com_ns = NameServer::new(FQDN::COM)?; + + let mut nameservers_ns = NameServer::new(FQDN("nameservers.com.")?)?; + nameservers_ns + .a(root_ns.fqdn().clone(), root_ns.ipv4_addr()) + .a(com_ns.fqdn().clone(), com_ns.ipv4_addr()) + .a(needle.clone(), expected_ipv4_addr); + let nameservers_ns = nameservers_ns.sign()?; + let nameservers_ds = nameservers_ns.ds().clone(); + let nameservers_ns = nameservers_ns.start()?; + + eprintln!("nameservers.com.zone:\n{}", nameservers_ns.zone_file()); + + com_ns + .referral( + nameservers_ns.zone().clone(), + nameservers_ns.fqdn().clone(), + nameservers_ns.ipv4_addr(), + ) + .ds(nameservers_ds); + let com_ns = com_ns.sign()?; + let com_ds = com_ns.ds().clone(); + let com_ns = com_ns.start()?; + + eprintln!("com.zone:\n{}", com_ns.zone_file()); + + root_ns + .referral(FQDN::COM, com_ns.fqdn().clone(), com_ns.ipv4_addr()) + .ds(com_ds); + let root_ns = root_ns.sign()?; + let root_ksk = root_ns.key_signing_key().clone(); + let root_zsk = root_ns.zone_signing_key().clone(); + + eprintln!("root.zone.signed:\n{}", root_ns.signed_zone_file()); + + let root_ns = root_ns.start()?; + + eprintln!("root.zone:\n{}", root_ns.zone_file()); + + let roots = &[Root::new(root_ns.fqdn().clone(), root_ns.ipv4_addr())]; + + let resolver = RecursiveResolver::start(roots, &[root_ksk.clone(), root_zsk.clone()])?; + let resolver_ip_addr = resolver.ipv4_addr(); + + let client = Client::new()?; + let output = client.dig( + Recurse::Yes, + Dnssec::Yes, + resolver_ip_addr, + RecordType::A, + &needle, + )?; + + drop(resolver); + + assert!(output.status.is_noerror()); + + assert!(output.flags.authenticated_data); + + let [a, _rrsig] = output.answer.try_into().unwrap(); + let a = a.try_into_a().unwrap(); + + assert_eq!(needle, a.fqdn); + assert_eq!(expected_ipv4_addr, a.ipv4_addr); + + Ok(()) + } } diff --git a/docker/files/etc/unbound/unbound.conf b/src/templates/unbound.conf.jinja similarity index 71% rename from docker/files/etc/unbound/unbound.conf rename to src/templates/unbound.conf.jinja index ad446203..fe74a6cf 100644 --- a/docker/files/etc/unbound/unbound.conf +++ b/src/templates/unbound.conf.jinja @@ -4,6 +4,9 @@ server: interface: 0.0.0.0 access-control: 172.17.0.0/16 allow root-hints: /etc/unbound/root.hints +{% if use_dnssec %} + trust-anchor-file: /etc/trusted-key.key +{% endif %} remote-control: control-enable: no diff --git a/src/zone_file.rs b/src/zone_file.rs index 524f26db..71a7b9c6 100644 --- a/src/zone_file.rs +++ b/src/zone_file.rs @@ -4,10 +4,11 @@ //! - the `@` syntax is not used to avoid relying on the order of the entries //! - relative domain names are not used; all domain names must be in fully-qualified form -use core::fmt; +use core::{array, fmt}; use std::net::Ipv4Addr; +use std::str::FromStr; -use crate::FQDN; +use crate::{Error, FQDN}; pub struct ZoneFile<'a> { pub origin: FQDN<'a>, @@ -94,9 +95,17 @@ impl fmt::Display for Root<'_> { pub enum Entry<'a> { A(A<'a>), + DNSKEY(DNSKEY), + DS(DS), NS(NS<'a>), } +impl<'a> From for Entry<'a> { + fn from(v: DS) -> Self { + Self::DS(v) + } +} + impl<'a> From> for Entry<'a> { fn from(v: A<'a>) -> Self { Self::A(v) @@ -113,6 +122,8 @@ impl fmt::Display for Entry<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Entry::A(a) => a.fmt(f), + Entry::DNSKEY(dnskey) => dnskey.fmt(f), + Entry::DS(ds) => ds.fmt(f), Entry::NS(ns) => ns.fmt(f), } } @@ -132,6 +143,166 @@ impl fmt::Display for A<'_> { } } +// integer types chosen based on bit sizes in section 2.1 of RFC4034 +#[derive(Clone, Debug)] +pub struct DNSKEY { + zone: FQDN<'static>, + flags: u16, + protocol: u8, + algorithm: u8, + public_key: String, + + // extra information in `+multiline` format and `ldns-keygen`'s output + bits: u16, + key_tag: u16, +} + +impl DNSKEY { + pub fn bits(&self) -> u16 { + self.bits + } + + pub fn key_tag(&self) -> u16 { + self.key_tag + } +} + +impl FromStr for DNSKEY { + type Err = Error; + + fn from_str(input: &str) -> Result { + let (before, after) = input.split_once(';').ok_or("comment was not found")?; + let mut columns = before.split_whitespace(); + + let [Some(zone), Some(class), Some(record_type), Some(flags), Some(protocol), Some(algorithm), Some(public_key), None] = + array::from_fn(|_| columns.next()) + else { + return Err("expected 7 columns".into()); + }; + + if record_type != "DNSKEY" { + return Err(format!("tried to parse `{record_type}` record as a DNSKEY record").into()); + } + + if class != "IN" { + return Err(format!("unknown class: {class}").into()); + } + + // {id = 24975 (zsk), size = 1024b} + let error = "invalid comment syntax"; + let (id_expr, size_expr) = after.split_once(',').ok_or(error)?; + + // {id = 24975 (zsk) + let (id_lhs, id_rhs) = id_expr.split_once('=').ok_or(error)?; + if id_lhs.trim() != "{id" { + return Err(error.into()); + } + + // 24975 (zsk) + let (key_tag, _key_type) = id_rhs.trim().split_once(' ').ok_or(error)?; + + // size = 1024b} + let (size_lhs, size_rhs) = size_expr.split_once('=').ok_or(error)?; + if size_lhs.trim() != "size" { + return Err(error.into()); + } + let bits = size_rhs.trim().strip_suffix("b}").ok_or(error)?.parse()?; + + Ok(Self { + zone: zone.parse()?, + flags: flags.parse()?, + protocol: protocol.parse()?, + algorithm: algorithm.parse()?, + public_key: public_key.to_string(), + + key_tag: key_tag.parse()?, + bits, + }) + } +} + +impl fmt::Display for DNSKEY { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + zone, + flags, + protocol, + algorithm, + public_key, + bits: _, + key_tag: _, + } = self; + + write!( + f, + "{zone}\tIN\tDNSKEY\t{flags}\t{protocol}\t{algorithm}\t{public_key}" + ) + } +} + +#[derive(Clone)] +pub struct DS { + zone: FQDN<'static>, + _ttl: u32, + key_tag: u16, + algorithm: u8, + digest_type: u8, + digest: String, +} + +impl FromStr for DS { + type Err = Error; + + fn from_str(input: &str) -> Result { + let mut columns = input.split_whitespace(); + + let [Some(zone), Some(ttl), Some(class), Some(record_type), Some(key_tag), Some(algorithm), Some(digest_type), Some(digest), None] = + array::from_fn(|_| columns.next()) + else { + return Err("expected 8 columns".into()); + }; + + let expected = "DS"; + if record_type != expected { + return Err( + format!("tried to parse `{record_type}` entry as a {expected} entry").into(), + ); + } + + if class != "IN" { + return Err(format!("unknown class: {class}").into()); + } + + Ok(Self { + zone: zone.parse()?, + _ttl: ttl.parse()?, + key_tag: key_tag.parse()?, + algorithm: algorithm.parse()?, + digest_type: digest_type.parse()?, + digest: digest.to_string(), + }) + } +} + +/// NOTE does NOT include the TTL field +impl fmt::Display for DS { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + zone, + _ttl, + key_tag, + algorithm, + digest_type, + digest, + } = self; + + write!( + f, + "{zone}\tIN\tDS\t{key_tag}\t{algorithm}\t{digest_type}\t{digest}" + ) + } +} + pub struct NS<'a> { pub zone: FQDN<'a>, pub nameserver: FQDN<'a>, @@ -262,6 +433,41 @@ e.gtld-servers.net. IN A 192.12.94.30 Ok(()) } + // not quite roundtrip because we drop the TTL field when doing `to_string` + #[test] + fn ds_roundtrip() -> Result<()> { + let input = + ". 1800 IN DS 31153 7 2 7846338aaacde9cc9518f1f450082adc015a207c45a1e69d6e660e6836f4ef3b"; + let ds: DS = input.parse()?; + let output = ds.to_string(); + + let expected = + ". IN DS 31153 7 2 7846338aaacde9cc9518f1f450082adc015a207c45a1e69d6e660e6836f4ef3b"; + assert_eq!(expected, output); + + Ok(()) + } + + #[test] + fn dnskey_roundtrip() -> Result<()> { + let input = "example.com. IN DNSKEY 256 3 7 AwEAAdIpMlio4GJas7GbIZ9xRpzpB2pf4SxBJcsquN/0yNBPGNE2rzcFykqMAKmLwypk1/1q/EdHVa4tQ5RlK0w09CRhgSXfCaph+yLNJKpiPyuVcXKl2k0RnO4p835sgVEUIvx8qGTDo7c7DA9UBje+/3ViFKqVhOBaWyT6gHAmNVpb ;{id = 24975 (zsk), size = 1024b}"; + + let dnskey: DNSKEY = input.parse()?; + + assert_eq!(256, dnskey.flags); + assert_eq!(3, dnskey.protocol); + assert_eq!(7, dnskey.algorithm); + let expected = "AwEAAdIpMlio4GJas7GbIZ9xRpzpB2pf4SxBJcsquN/0yNBPGNE2rzcFykqMAKmLwypk1/1q/EdHVa4tQ5RlK0w09CRhgSXfCaph+yLNJKpiPyuVcXKl2k0RnO4p835sgVEUIvx8qGTDo7c7DA9UBje+/3ViFKqVhOBaWyT6gHAmNVpb"; + assert_eq!(expected, dnskey.public_key); + assert_eq!(1024, dnskey.bits()); + assert_eq!(24975, dnskey.key_tag()); + + let output = dnskey.to_string(); + assert!(input.starts_with(&output)); + + Ok(()) + } + fn example_a() -> Result> { Ok(A { fqdn: FQDN("e.gtld-servers.net.")?,