use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[cfg(feature = "server")]
use utoipa::ToSchema;
pub const DS_DIGEST_TYPE_SHA256: u8 = 2;
const DNSKEY_FLAG_SEP: u16 = 0x0001;
const MAX_LABEL_LEN: usize = 63;
const MAX_NAME_LEN: usize = 255;
const BIND_STATUS_TIME_FORMAT: &str = "%a %b %e %H:%M:%S %Y";
const ISO8601_NAIVE_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
#[derive(Debug, thiserror::Error)]
pub enum DnssecError {
#[error("not a DNSKEY resource record: {0}")]
NotDnskey(String),
#[error("invalid DNSKEY field {field}: {value}")]
InvalidField {
field: &'static str,
value: String,
},
#[error("invalid owner name: {0}")]
InvalidOwnerName(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "server", derive(ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DnssecKeyStatus {
pub tag: u16,
pub algorithm: String,
pub role: String,
pub published: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_since: Option<String>,
pub key_signing: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_signing_since: Option<String>,
pub zone_signing: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub zone_signing_since: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_rollover: Option<String>,
pub removed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dnskey_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ds_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub zone_rrsig_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_rrsig_state: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "server", derive(ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DnssecStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<String>,
pub signed: bool,
pub keys: Vec<DnssecKeyStatus>,
}
fn convert_bind_timestamp(raw: &str) -> String {
match chrono::NaiveDateTime::parse_from_str(raw.trim(), BIND_STATUS_TIME_FORMAT) {
Ok(dt) => dt.format(ISO8601_NAIVE_FORMAT).to_string(),
Err(_) => raw.trim().to_string(),
}
}
fn parse_yes_since(value: &str) -> (bool, Option<String>) {
let value = value.trim();
if let Some(rest) = value.strip_prefix("yes") {
let since = rest
.trim_start()
.strip_prefix("- since")
.map(convert_bind_timestamp);
return (true, since);
}
(false, None)
}
pub fn parse_dnssec_status(output: &str) -> DnssecStatus {
let mut status = DnssecStatus::default();
let mut current: Option<DnssecKeyStatus> = None;
for line in output.lines() {
let trimmed = line.trim();
if let Some(policy) = trimmed.strip_prefix("dnssec-policy:") {
status.policy = Some(policy.trim().to_string());
continue;
}
if let Some(rest) = trimmed.strip_prefix("key:") {
if let Some(prev) = current.take() {
status.keys.push(prev);
}
let mut key = DnssecKeyStatus::default();
let rest = rest.trim();
let mut parts = rest.split_whitespace();
key.tag = parts.next().and_then(|t| t.parse().ok()).unwrap_or(0);
if let (Some(open), Some(close)) = (rest.find('('), rest.find(')')) {
if open < close {
key.algorithm = rest[open + 1..close].to_string();
}
key.role = rest[close + 1..].trim_start_matches(',').trim().to_string();
}
current = Some(key);
continue;
}
let Some(key) = current.as_mut() else {
continue;
};
if let Some(v) = trimmed.strip_prefix("published:") {
(key.published, key.published_since) = parse_yes_since(v);
} else if let Some(v) = trimmed.strip_prefix("key signing:") {
(key.key_signing, key.key_signing_since) = parse_yes_since(v);
} else if let Some(v) = trimmed.strip_prefix("zone signing:") {
(key.zone_signing, key.zone_signing_since) = parse_yes_since(v);
} else if let Some(ts) = trimmed.strip_prefix("Next rollover scheduled on") {
key.next_rollover = Some(convert_bind_timestamp(ts));
} else if trimmed == "Key has been removed from the zone" {
key.removed = true;
} else if let Some(v) = trimmed.strip_prefix("- goal:") {
key.goal = Some(v.trim().to_string());
} else if let Some(v) = trimmed.strip_prefix("- dnskey:") {
key.dnskey_state = Some(v.trim().to_string());
} else if let Some(v) = trimmed.strip_prefix("- ds:") {
key.ds_state = Some(v.trim().to_string());
} else if let Some(v) = trimmed.strip_prefix("- zone rrsig:") {
key.zone_rrsig_state = Some(v.trim().to_string());
} else if let Some(v) = trimmed.strip_prefix("- key rrsig:") {
key.key_rrsig_state = Some(v.trim().to_string());
}
}
if let Some(prev) = current.take() {
status.keys.push(prev);
}
status.signed = status.keys.iter().any(|k| {
matches!(
k.dnskey_state.as_deref(),
Some("rumoured") | Some("omnipresent")
)
});
status
}
impl DnssecStatus {
pub fn ksk_tags(&self) -> std::collections::HashSet<u16> {
self.keys
.iter()
.filter(|k| k.role == "KSK" || k.role == "CSK")
.map(|k| k.tag)
.collect()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "server", derive(ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum CheckdsState {
Published,
Withdrawn,
}
impl CheckdsState {
pub fn as_str(&self) -> &'static str {
match self {
CheckdsState::Published => "published",
CheckdsState::Withdrawn => "withdrawn",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DnskeyRecord {
pub owner: String,
pub flags: u16,
pub protocol: u8,
pub algorithm: u8,
pub public_key: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "server", derive(ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DsRecord {
pub key_tag: u16,
pub algorithm: u8,
pub digest_type: u8,
pub digest: String,
}
impl DsRecord {
pub fn rdata(&self) -> String {
format!(
"{} {} {} {}",
self.key_tag, self.algorithm, self.digest_type, self.digest
)
}
}
impl DnskeyRecord {
pub fn is_ksk(&self) -> bool {
self.flags & DNSKEY_FLAG_SEP != 0
}
fn rdata_wire(&self) -> Vec<u8> {
let mut wire = Vec::with_capacity(self.public_key.len() + 4);
wire.extend_from_slice(&self.flags.to_be_bytes());
wire.push(self.protocol);
wire.push(self.algorithm);
wire.extend_from_slice(&self.public_key);
wire
}
pub fn key_tag(&self) -> u16 {
let wire = self.rdata_wire();
let mut acc: u32 = 0;
for (i, byte) in wire.iter().enumerate() {
if i % 2 == 0 {
acc += u32::from(*byte) << 8;
} else {
acc += u32::from(*byte);
}
}
acc += acc >> 16;
(acc & 0xFFFF) as u16
}
pub fn ds_record(&self) -> Result<DsRecord, DnssecError> {
let owner_wire = name_to_canonical_wire(&self.owner)?;
let mut hasher = Sha256::new();
hasher.update(&owner_wire);
hasher.update(self.rdata_wire());
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
hex.push_str(&format!("{byte:02X}"));
}
Ok(DsRecord {
key_tag: self.key_tag(),
algorithm: self.algorithm,
digest_type: DS_DIGEST_TYPE_SHA256,
digest: hex,
})
}
}
fn name_to_canonical_wire(name: &str) -> Result<Vec<u8>, DnssecError> {
let name = name.trim_end_matches('.');
let mut wire = Vec::with_capacity(name.len() + 2);
if !name.is_empty() {
for label in name.split('.') {
if label.is_empty() || label.len() > MAX_LABEL_LEN {
return Err(DnssecError::InvalidOwnerName(format!(
"label {label:?} is empty or longer than {MAX_LABEL_LEN} octets"
)));
}
wire.push(label.len() as u8);
wire.extend(label.bytes().map(|b| b.to_ascii_lowercase()));
}
}
wire.push(0);
if wire.len() > MAX_NAME_LEN {
return Err(DnssecError::InvalidOwnerName(format!(
"name {name:?} exceeds {MAX_NAME_LEN} octets in wire format"
)));
}
Ok(wire)
}
fn decode_base64(input: &str) -> Result<Vec<u8>, DnssecError> {
let compact: String = input.split_whitespace().collect();
base64_decode(&compact).ok_or_else(|| DnssecError::InvalidField {
field: "public key",
value: input.to_string(),
})
}
fn base64_decode(input: &str) -> Option<Vec<u8>> {
const INVALID: u8 = u8::MAX;
fn value(byte: u8) -> u8 {
match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => INVALID,
}
}
let bytes = input.as_bytes();
let stripped = match bytes {
[rest @ .., b'=', b'='] => rest,
[rest @ .., b'='] => rest,
rest => rest,
};
let mut out = Vec::with_capacity(stripped.len() * 3 / 4 + 3);
let mut acc: u32 = 0;
let mut bits: u32 = 0;
for &byte in stripped {
let v = value(byte);
if v == INVALID {
return None;
}
acc = (acc << 6) | u32::from(v);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
if bits > 0 && acc & ((1 << bits) - 1) != 0 {
return None;
}
Some(out)
}
pub fn parse_dnskey_rr(text: &str) -> Result<DnskeyRecord, DnssecError> {
let mut tokens = text.split_whitespace().peekable();
let owner = tokens
.next()
.ok_or_else(|| DnssecError::NotDnskey("empty input".to_string()))?
.to_string();
let mut saw_dnskey = false;
for token in tokens.by_ref() {
if token.eq_ignore_ascii_case("DNSKEY") {
saw_dnskey = true;
break;
}
let is_ttl = token.chars().all(|c| c.is_ascii_digit());
let is_class = token.eq_ignore_ascii_case("IN");
if !is_ttl && !is_class {
return Err(DnssecError::NotDnskey(format!(
"unexpected token {token:?} before DNSKEY type"
)));
}
}
if !saw_dnskey {
return Err(DnssecError::NotDnskey(
"no DNSKEY type token found".to_string(),
));
}
let flags_token = tokens.next().ok_or(DnssecError::InvalidField {
field: "flags",
value: "missing".to_string(),
})?;
let flags: u16 = flags_token.parse().map_err(|_| DnssecError::InvalidField {
field: "flags",
value: flags_token.to_string(),
})?;
let protocol_token = tokens.next().ok_or(DnssecError::InvalidField {
field: "protocol",
value: "missing".to_string(),
})?;
let protocol: u8 = protocol_token
.parse()
.map_err(|_| DnssecError::InvalidField {
field: "protocol",
value: protocol_token.to_string(),
})?;
let algorithm_token = tokens.next().ok_or(DnssecError::InvalidField {
field: "algorithm",
value: "missing".to_string(),
})?;
let algorithm: u8 = algorithm_token
.parse()
.map_err(|_| DnssecError::InvalidField {
field: "algorithm",
value: algorithm_token.to_string(),
})?;
let key_b64: String = tokens.collect::<Vec<_>>().join(" ");
if key_b64.is_empty() {
return Err(DnssecError::InvalidField {
field: "public key",
value: "missing".to_string(),
});
}
let public_key = decode_base64(&key_b64)?;
Ok(DnskeyRecord {
owner,
flags,
protocol,
algorithm,
public_key,
})
}
pub fn parse_key_file(contents: &str) -> Result<DnskeyRecord, DnssecError> {
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with(';') {
continue;
}
return parse_dnskey_rr(trimmed);
}
Err(DnssecError::NotDnskey(
"no DNSKEY record line in key file".to_string(),
))
}