use std::collections::BTreeSet;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum DnssecMode {
No,
#[default]
AllowDowngrade,
Yes,
}
impl DnssecMode {
pub const fn validates(self) -> bool {
!matches!(self, Self::No)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrustAnchor {
pub key: String,
}
impl TrustAnchor {
pub fn new(domain: impl Into<String>) -> Self {
Self { key: domain.into() }
}
pub fn domain(&self) -> &str {
&self.key
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NegativeTrustAnchor {
pub domain: String,
}
impl NegativeTrustAnchor {
pub fn new(domain: impl Into<String>) -> Self {
Self {
domain: domain.into(),
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ValidatorConfig {
pub mode: DnssecMode,
pub trust_anchors: Vec<TrustAnchor>,
pub ntas: Vec<NegativeTrustAnchor>,
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum ValidatorError {
#[error("invalid DNS name: {0}")]
InvalidName(String),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AnchorMatch {
NegativeTrustAnchor,
PositiveTrustAnchor,
None,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ValidationState {
Secure,
Insecure,
Bogus,
Indeterminate,
NotValidated,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NtaValidator {
mode: DnssecMode,
positive: BTreeSet<CanonicalName>,
negative: BTreeSet<CanonicalName>,
}
impl NtaValidator {
pub fn new(config: ValidatorConfig) -> Result<Self, ValidatorError> {
let positive = config
.trust_anchors
.iter()
.map(|anchor| CanonicalName::parse(anchor.domain()))
.collect::<Result<BTreeSet<_>, _>>()?;
let negative = config
.ntas
.iter()
.map(|anchor| CanonicalName::parse(&anchor.domain))
.collect::<Result<BTreeSet<_>, _>>()?;
Ok(Self {
mode: config.mode,
positive,
negative,
})
}
pub fn empty() -> Self {
Self {
mode: DnssecMode::default(),
positive: BTreeSet::new(),
negative: BTreeSet::new(),
}
}
pub const fn mode(&self) -> DnssecMode {
self.mode
}
pub fn lookup(&self, name: &str) -> Result<AnchorMatch, ValidatorError> {
let mut candidate = Some(CanonicalName::parse(name)?);
while let Some(current) = candidate {
if self.negative.contains(¤t) {
return Ok(AnchorMatch::NegativeTrustAnchor);
}
if self.positive.contains(¤t) {
return Ok(AnchorMatch::PositiveTrustAnchor);
}
candidate = current.parent();
}
Ok(AnchorMatch::None)
}
pub fn is_insecure(&self, name: &str) -> Result<bool, ValidatorError> {
Ok(matches!(
self.lookup(name)?,
AnchorMatch::NegativeTrustAnchor
))
}
pub fn should_validate(&self, name: &str) -> Result<bool, ValidatorError> {
Ok(self.mode.validates() && !self.is_insecure(name)?)
}
pub fn classify(
&self,
name: &str,
state: ValidationState,
) -> Result<ValidationState, ValidatorError> {
if !self.should_validate(name)? {
return Ok(ValidationState::NotValidated);
}
Ok(state)
}
pub fn ad_legal(&self, name: &str, state: ValidationState) -> Result<bool, ValidatorError> {
Ok(self.classify(name, state)? == ValidationState::Secure)
}
pub fn failed_signature(&self, name: &str) -> Result<ValidationState, ValidatorError> {
self.classify(name, ValidationState::Bogus)
}
pub fn missing_signature(&self, name: &str) -> Result<ValidationState, ValidatorError> {
if !self.should_validate(name)? {
return Ok(ValidationState::NotValidated);
}
Ok(match self.mode {
DnssecMode::Yes => ValidationState::Bogus,
DnssecMode::AllowDowngrade => ValidationState::Insecure,
DnssecMode::No => ValidationState::NotValidated,
})
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct CanonicalName(Vec<u8>);
impl CanonicalName {
fn parse(name: &str) -> Result<Self, ValidatorError> {
let presentation = if name.is_empty() { "." } else { name };
let mut wire = crate::wire::encode_name(presentation)
.map_err(|_| ValidatorError::InvalidName(name.to_owned()))?;
wire.iter_mut()
.for_each(|byte| *byte = byte.to_ascii_lowercase());
Ok(Self(wire))
}
fn parent(&self) -> Option<Self> {
let first_length = usize::from(*self.0.first()?);
if first_length == 0 {
return None;
}
Some(Self(self.0.get(first_length + 1..)?.to_vec()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_validator(mode: DnssecMode, positive: &[&str], negative: &[&str]) -> NtaValidator {
NtaValidator::new(ValidatorConfig {
mode,
trust_anchors: positive.iter().copied().map(TrustAnchor::new).collect(),
ntas: negative
.iter()
.copied()
.map(NegativeTrustAnchor::new)
.collect(),
})
.expect("valid anchor configuration")
}
#[test]
fn nta_covers_exact_name_and_all_descendants() {
let validator = make_validator(DnssecMode::Yes, &["."], &["corp"]);
assert_eq!(
validator.lookup("corp").unwrap(),
AnchorMatch::NegativeTrustAnchor
);
assert!(validator.is_insecure("host.CORP.").unwrap());
assert!(validator.should_validate("signed.example.").unwrap());
}
#[test]
fn positive_anchor_stops_inherited_nta_but_not_a_nearer_nta() {
let validator = make_validator(
DnssecMode::Yes,
&["signed.corp"],
&["corp", "host.signed.corp"],
);
assert_eq!(
validator.lookup("host.signed.corp").unwrap(),
AnchorMatch::NegativeTrustAnchor
);
let validator = make_validator(DnssecMode::Yes, &["signed.corp"], &["corp"]);
assert_eq!(
validator.lookup("host.signed.corp").unwrap(),
AnchorMatch::PositiveTrustAnchor
);
assert_eq!(
validator.lookup("host.unsigned.corp").unwrap(),
AnchorMatch::NegativeTrustAnchor
);
}
#[test]
fn exact_anchor_precedence_matches_systemd() {
let validator = make_validator(DnssecMode::Yes, &["corp"], &["corp"]);
assert_eq!(
validator.lookup("corp").unwrap(),
AnchorMatch::NegativeTrustAnchor
);
let validator = make_validator(DnssecMode::Yes, &["."], &["corp"]);
assert_eq!(
validator.lookup("other.example").unwrap(),
AnchorMatch::PositiveTrustAnchor
);
}
#[test]
fn canonical_name_matching_handles_case_trailing_dot_and_escapes() {
let validator = make_validator(DnssecMode::Yes, &["."], &[r"ExAmPle"]);
assert!(validator.is_insecure("host.example.").unwrap());
let escaped = make_validator(DnssecMode::Yes, &["."], &[r"foo\.bar"]);
assert!(escaped.is_insecure(r"host.foo\.bar.").unwrap());
assert!(!escaped.is_insecure("host.foo.bar.").unwrap());
}
#[test]
fn malformed_anchor_and_query_names_fail_closed() {
let result = NtaValidator::new(ValidatorConfig {
mode: DnssecMode::Yes,
trust_anchors: vec![TrustAnchor::new("bad..name")],
ntas: Vec::new(),
});
assert!(matches!(
result,
Err(ValidatorError::InvalidName(name)) if name == "bad..name"
));
let validator = make_validator(DnssecMode::Yes, &[], &["example"]);
assert!(matches!(
validator.lookup("bad..name"),
Err(ValidatorError::InvalidName(name)) if name == "bad..name"
));
}
#[test]
fn nta_and_mode_policy_never_authorize_ad() {
let validator = make_validator(DnssecMode::Yes, &["."], &["example"]);
assert_eq!(
validator
.classify("insecure.example", ValidationState::Secure)
.unwrap(),
ValidationState::NotValidated
);
assert!(!validator
.ad_legal("insecure.example", ValidationState::Secure)
.unwrap());
let disabled = make_validator(DnssecMode::No, &["."], &[]);
assert!(!disabled
.ad_legal("secure.example", ValidationState::Secure)
.unwrap());
let enabled = make_validator(DnssecMode::Yes, &["."], &[]);
assert!(enabled
.ad_legal("secure.example", ValidationState::Secure)
.unwrap());
assert!(!enabled
.ad_legal("secure.example", ValidationState::Insecure)
.unwrap());
}
#[test]
fn failed_signature_is_bogus_but_missing_data_can_downgrade() {
let strict = make_validator(DnssecMode::Yes, &["."], &[]);
assert_eq!(
strict.failed_signature("signed.example").unwrap(),
ValidationState::Bogus
);
assert_eq!(
strict.missing_signature("signed.example").unwrap(),
ValidationState::Bogus
);
let downgrade = make_validator(DnssecMode::AllowDowngrade, &["."], &[]);
assert_eq!(
downgrade.failed_signature("signed.example").unwrap(),
ValidationState::Bogus
);
assert_eq!(
downgrade.missing_signature("signed.example").unwrap(),
ValidationState::Insecure
);
let nta = make_validator(DnssecMode::Yes, &["."], &["example"]);
assert_eq!(
nta.failed_signature("private.example").unwrap(),
ValidationState::NotValidated
);
}
}