use std::net::{Ipv4Addr, Ipv6Addr};
use anyhow::{Result, bail};
use data_encoding::BASE32_NOPAD;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Encoding {
Ip,
Domain,
Base32,
}
impl Encoding {
pub fn parse(raw: &str) -> Result<Encoding> {
match raw.trim().to_ascii_lowercase().as_str() {
"ip" => Ok(Encoding::Ip),
"domain" => Ok(Encoding::Domain),
"base32" => Ok(Encoding::Base32),
other => bail!("unknown DNS encoding '{other}', expected ip, domain or base32"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Exposed {
pub label: String,
pub namespace: String,
pub encoding: Encoding,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Lookup<'a> {
pub exposed: &'a Exposed,
pub value: String,
}
pub fn resolve<'a>(
qlabels: &[String],
zone: &[String],
exposed: &'a [Exposed],
) -> Option<Lookup<'a>> {
let rest = qlabels.strip_suffix(zone)?;
let (label, value_labels) = rest.split_last()?;
if value_labels.is_empty() {
return None;
}
let exposed = exposed.iter().find(|e| e.label == *label)?;
let value = decode(value_labels, exposed.encoding)?;
Some(Lookup { exposed, value })
}
pub fn decode(labels: &[String], encoding: Encoding) -> Option<String> {
match encoding {
Encoding::Ip => decode_ip(labels),
Encoding::Domain => Some(labels.join(".")),
Encoding::Base32 => decode_base32(labels),
}
}
fn decode_ip(labels: &[String]) -> Option<String> {
match labels.len() {
4 => {
let mut octets = [0u8; 4];
for (slot, label) in octets.iter_mut().zip(labels.iter().rev()) {
*slot = parse_canonical_u8(label)?;
}
Some(Ipv4Addr::from(octets).to_string())
}
32 => {
let mut nibbles = [0u8; 32];
for (slot, label) in nibbles.iter_mut().zip(labels.iter().rev()) {
let bytes = label.as_bytes();
if bytes.len() != 1 {
return None;
}
*slot = (bytes[0] as char).to_digit(16)? as u8;
}
let mut octets = [0u8; 16];
for (i, octet) in octets.iter_mut().enumerate() {
*octet = (nibbles[i * 2] << 4) | nibbles[i * 2 + 1];
}
Some(Ipv6Addr::from(octets).to_string())
}
_ => None,
}
}
fn parse_canonical_u8(label: &str) -> Option<u8> {
if label.is_empty() || (label.len() > 1 && label.starts_with('0')) {
return None;
}
label.parse().ok()
}
fn decode_base32(labels: &[String]) -> Option<String> {
let joined = labels.concat().to_ascii_uppercase();
let bytes = BASE32_NOPAD.decode(joined.as_bytes()).ok()?;
String::from_utf8(bytes).ok()
}
#[cfg(test)]
pub fn encode_base32(value: &str) -> String {
BASE32_NOPAD.encode(value.as_bytes()).to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
fn labels(name: &str) -> Vec<String> {
name.split('.').map(str::to_string).collect()
}
fn exposed() -> Vec<Exposed> {
vec![
Exposed {
label: "malware".into(),
namespace: "malware/ips".into(),
encoding: Encoding::Ip,
},
Exposed {
label: "domains".into(),
namespace: "malware/domains".into(),
encoding: Encoding::Domain,
},
Exposed {
label: "hashes".into(),
namespace: "malware/hashes".into(),
encoding: Encoding::Base32,
},
]
}
#[test]
fn reversed_octets_are_an_ipv4_address() {
assert_eq!(decode(&labels("4.3.2.1"), Encoding::Ip).unwrap(), "1.2.3.4");
assert_eq!(
decode(&labels("1.0.0.127"), Encoding::Ip).unwrap(),
"127.0.0.1"
);
assert_eq!(
decode(&labels("255.255.255.255"), Encoding::Ip).unwrap(),
"255.255.255.255"
);
}
#[test]
fn non_canonical_octets_are_rejected() {
assert_eq!(decode(&labels("01.2.3.4"), Encoding::Ip), None);
assert_eq!(decode(&labels("4.3.2.256"), Encoding::Ip), None);
assert_eq!(decode(&labels("4.3.2.-1"), Encoding::Ip), None);
assert_eq!(decode(&labels("4.3.2.x"), Encoding::Ip), None);
assert_eq!(decode(&labels("4.3.2."), Encoding::Ip), None);
}
#[test]
fn the_wrong_number_of_octets_is_not_an_address() {
assert_eq!(decode(&labels("3.2.1"), Encoding::Ip), None);
assert_eq!(decode(&labels("5.4.3.2.1"), Encoding::Ip), None);
}
#[test]
fn reversed_nibbles_are_an_ipv6_address() {
let mut nibbles: Vec<String> = Vec::new();
for ch in "20010db8000000000000000000000001".chars() {
nibbles.push(ch.to_string());
}
nibbles.reverse();
assert_eq!(decode(&nibbles, Encoding::Ip).unwrap(), "2001:db8::1");
}
#[test]
fn malformed_nibbles_are_rejected() {
let mut nibbles: Vec<String> = std::iter::repeat_n("0".to_string(), 32).collect();
nibbles[5] = "zz".to_string();
assert_eq!(decode(&nibbles, Encoding::Ip), None);
nibbles[5] = "g".to_string();
assert_eq!(decode(&nibbles, Encoding::Ip), None);
}
#[test]
fn domain_labels_are_the_value() {
assert_eq!(
decode(&labels("evil.com"), Encoding::Domain).unwrap(),
"evil.com"
);
assert_eq!(
decode(&labels("a.b.evil.com"), Encoding::Domain).unwrap(),
"a.b.evil.com"
);
}
#[test]
fn base32_round_trips() {
for value in [
"1.2.3.4",
"evil.com",
"d41d8cd98f00b204e9800998ecf8427e",
"https://example.com/a?b=c",
] {
let encoded = encode_base32(value);
assert_eq!(
decode(std::slice::from_ref(&encoded), Encoding::Base32).as_deref(),
Some(value),
"round trip failed for {value} via {encoded}"
);
}
}
#[test]
fn base32_spans_several_labels() {
let value = "a".repeat(100);
let encoded = encode_base32(&value);
assert!(encoded.len() > 63);
let chunks: Vec<String> = encoded
.as_bytes()
.chunks(63)
.map(|c| String::from_utf8(c.to_vec()).unwrap())
.collect();
assert!(chunks.len() > 1);
assert_eq!(
decode(&chunks, Encoding::Base32).as_deref(),
Some(&value[..])
);
}
#[test]
fn invalid_base32_is_not_a_value() {
assert_eq!(decode(&labels("not!base32"), Encoding::Base32), None);
assert_eq!(decode(&labels("1"), Encoding::Base32), None);
}
#[test]
fn a_query_resolves_to_its_namespace_and_value() {
let zone = labels("sdb.example.com");
let exposed = exposed();
let found = resolve(&labels("4.3.2.1.malware.sdb.example.com"), &zone, &exposed).unwrap();
assert_eq!(found.exposed.namespace, "malware/ips");
assert_eq!(found.value, "1.2.3.4");
let found = resolve(&labels("evil.com.domains.sdb.example.com"), &zone, &exposed).unwrap();
assert_eq!(found.exposed.namespace, "malware/domains");
assert_eq!(found.value, "evil.com");
}
#[test]
fn names_outside_the_zone_do_not_resolve() {
let zone = labels("sdb.example.com");
assert_eq!(
resolve(&labels("4.3.2.1.malware.elsewhere.com"), &zone, &exposed()),
None
);
}
#[test]
fn unexposed_namespaces_do_not_resolve() {
let zone = labels("sdb.example.com");
assert_eq!(
resolve(
&labels("4.3.2.1.secrets.sdb.example.com"),
&zone,
&exposed()
),
None
);
assert_eq!(
resolve(
&labels("4.3.2.1._config.sdb.example.com"),
&zone,
&exposed()
),
None
);
}
#[test]
fn the_zone_apex_and_a_bare_namespace_are_not_lookups() {
let zone = labels("sdb.example.com");
assert_eq!(resolve(&labels("sdb.example.com"), &zone, &exposed()), None);
assert_eq!(
resolve(&labels("malware.sdb.example.com"), &zone, &exposed()),
None
);
}
#[test]
fn encodings_parse_from_configuration() {
assert_eq!(Encoding::parse("ip").unwrap(), Encoding::Ip);
assert_eq!(Encoding::parse(" Domain ").unwrap(), Encoding::Domain);
assert_eq!(Encoding::parse("BASE32").unwrap(), Encoding::Base32);
assert!(Encoding::parse("rot13").is_err());
}
}