bindcar/
dnssec.rs

1// Copyright (c) 2026 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! DNSSEC observability primitives (ADR-0001, roadmap 07).
5//!
6//! Two independent concerns live here, both pure and side-effect free:
7//!
8//! 1. Parsing `rndc dnssec -status <zone>` output into [`DnssecStatus`] —
9//!    the source of the zone-status DNSSEC block. Per ADR-0001 this output
10//!    (the supported operator interface, reflecting the *running* server) is
11//!    parsed instead of BIND's internal key `.state` files.
12//! 2. Parsing DNSKEY resource records from public key files
13//!    (`K<zone>.+<alg>+<tag>.key`) and computing the DS record (RFC 4034
14//!    appendix B key tag, RFC 4509 SHA-256 digest) in-process, so no
15//!    `dnssec-dsfromkey` binary is needed in the image.
16
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19#[cfg(feature = "server")]
20use utoipa::ToSchema;
21
22/// DS digest type 2 = SHA-256 (RFC 4509). The only digest type bindcar emits.
23pub const DS_DIGEST_TYPE_SHA256: u8 = 2;
24
25/// DNSKEY flags bit 15 (SEP, RFC 4034 §2.1.1): set on key-signing keys.
26const DNSKEY_FLAG_SEP: u16 = 0x0001;
27
28/// Maximum length of a single DNS label in wire format (RFC 1035 §2.3.4).
29const MAX_LABEL_LEN: usize = 63;
30
31/// Maximum length of a DNS name in wire format (RFC 1035 §2.3.4).
32const MAX_NAME_LEN: usize = 255;
33
34/// Timestamp format BIND uses in `rndc dnssec -status` output
35/// (ctime-style, e.g. `Sun Sep 27 22:07:06 2026`).
36const BIND_STATUS_TIME_FORMAT: &str = "%a %b %e %H:%M:%S %Y";
37
38/// ISO 8601 (naive — the server's local clock, no zone claim) format used
39/// for timestamps in API responses.
40const ISO8601_NAIVE_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
41
42/// Errors from DNSKEY/DS parsing and computation.
43#[derive(Debug, thiserror::Error)]
44pub enum DnssecError {
45    /// The input is not a DNSKEY resource record in textual form.
46    #[error("not a DNSKEY resource record: {0}")]
47    NotDnskey(String),
48
49    /// A DNSKEY RR field failed to parse.
50    #[error("invalid DNSKEY field {field}: {value}")]
51    InvalidField {
52        /// Which RDATA field was invalid.
53        field: &'static str,
54        /// The offending value.
55        value: String,
56    },
57
58    /// The owner name cannot be encoded in DNS wire format.
59    #[error("invalid owner name: {0}")]
60    InvalidOwnerName(String),
61}
62
63/// Per-key signing state parsed from `rndc dnssec -status` output.
64#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
65#[cfg_attr(feature = "server", derive(ToSchema))]
66#[serde(rename_all = "camelCase")]
67pub struct DnssecKeyStatus {
68    /// Key tag (RFC 4034 appendix B).
69    pub tag: u16,
70    /// Algorithm mnemonic as BIND reports it (e.g. `ECDSAP256SHA256`).
71    pub algorithm: String,
72    /// Key role: `KSK`, `ZSK` or `CSK`.
73    pub role: String,
74    /// Whether the DNSKEY is published in the zone.
75    pub published: bool,
76    /// When publication started (ISO 8601, server-local clock), if published.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub published_since: Option<String>,
79    /// Whether the key signs the DNSKEY RRset (KSK duty).
80    pub key_signing: bool,
81    /// When key-signing started, if active.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub key_signing_since: Option<String>,
84    /// Whether the key signs zone data (ZSK duty).
85    pub zone_signing: bool,
86    /// When zone-signing started, if active.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub zone_signing_since: Option<String>,
89    /// Next scheduled rollover event, if BIND reports one.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub next_rollover: Option<String>,
92    /// True once BIND reports "Key has been removed from the zone".
93    pub removed: bool,
94    /// Key goal state (`hidden`/`rumoured`/`omnipresent`/`unretentive`).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub goal: Option<String>,
97    /// DNSKEY record state.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub dnskey_state: Option<String>,
100    /// DS record state (as far as named knows, via checkds/parental agents).
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub ds_state: Option<String>,
103    /// Zone RRSIG state.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub zone_rrsig_state: Option<String>,
106    /// Key (DNSKEY RRset) RRSIG state.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub key_rrsig_state: Option<String>,
109}
110
111/// Zone DNSSEC status parsed from `rndc dnssec -status` output.
112#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
113#[cfg_attr(feature = "server", derive(ToSchema))]
114#[serde(rename_all = "camelCase")]
115pub struct DnssecStatus {
116    /// The `dnssec-policy` in effect on the running zone, if any.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub policy: Option<String>,
119    /// Whether the zone currently serves DNSKEY records (derived: any key
120    /// whose DNSKEY state is `rumoured` or `omnipresent`).
121    pub signed: bool,
122    /// Per-key state.
123    pub keys: Vec<DnssecKeyStatus>,
124}
125
126/// Converts a BIND ctime-style timestamp to naive ISO 8601, passing the raw
127/// string through when it does not parse (never drop information).
128fn convert_bind_timestamp(raw: &str) -> String {
129    match chrono::NaiveDateTime::parse_from_str(raw.trim(), BIND_STATUS_TIME_FORMAT) {
130        Ok(dt) => dt.format(ISO8601_NAIVE_FORMAT).to_string(),
131        Err(_) => raw.trim().to_string(),
132    }
133}
134
135/// Parses a `published:` / `key signing:` / `zone signing:` value of the form
136/// `yes - since <timestamp>` or `no`, returning (active, since).
137fn parse_yes_since(value: &str) -> (bool, Option<String>) {
138    let value = value.trim();
139    if let Some(rest) = value.strip_prefix("yes") {
140        let since = rest
141            .trim_start()
142            .strip_prefix("- since")
143            .map(convert_bind_timestamp);
144        return (true, since);
145    }
146    (false, None)
147}
148
149/// Parses `rndc dnssec -status <zone>` output.
150///
151/// Unknown lines are ignored so that new BIND minor versions cannot break the
152/// status endpoint; a zone without a policy ("Zone does not have
153/// dnssec-policy") yields `policy: None, signed: false, keys: []`.
154///
155/// # Arguments
156/// * `output` - Raw text returned by the rndc channel
157pub fn parse_dnssec_status(output: &str) -> DnssecStatus {
158    let mut status = DnssecStatus::default();
159    let mut current: Option<DnssecKeyStatus> = None;
160
161    for line in output.lines() {
162        let trimmed = line.trim();
163
164        if let Some(policy) = trimmed.strip_prefix("dnssec-policy:") {
165            status.policy = Some(policy.trim().to_string());
166            continue;
167        }
168
169        if let Some(rest) = trimmed.strip_prefix("key:") {
170            // "key: 33306 (ECDSAP256SHA256), CSK"
171            if let Some(prev) = current.take() {
172                status.keys.push(prev);
173            }
174            let mut key = DnssecKeyStatus::default();
175            let rest = rest.trim();
176            let mut parts = rest.split_whitespace();
177            key.tag = parts.next().and_then(|t| t.parse().ok()).unwrap_or(0);
178            if let (Some(open), Some(close)) = (rest.find('('), rest.find(')')) {
179                if open < close {
180                    key.algorithm = rest[open + 1..close].to_string();
181                }
182                key.role = rest[close + 1..].trim_start_matches(',').trim().to_string();
183            }
184            current = Some(key);
185            continue;
186        }
187
188        let Some(key) = current.as_mut() else {
189            continue;
190        };
191
192        if let Some(v) = trimmed.strip_prefix("published:") {
193            (key.published, key.published_since) = parse_yes_since(v);
194        } else if let Some(v) = trimmed.strip_prefix("key signing:") {
195            (key.key_signing, key.key_signing_since) = parse_yes_since(v);
196        } else if let Some(v) = trimmed.strip_prefix("zone signing:") {
197            (key.zone_signing, key.zone_signing_since) = parse_yes_since(v);
198        } else if let Some(ts) = trimmed.strip_prefix("Next rollover scheduled on") {
199            key.next_rollover = Some(convert_bind_timestamp(ts));
200        } else if trimmed == "Key has been removed from the zone" {
201            key.removed = true;
202        } else if let Some(v) = trimmed.strip_prefix("- goal:") {
203            key.goal = Some(v.trim().to_string());
204        } else if let Some(v) = trimmed.strip_prefix("- dnskey:") {
205            key.dnskey_state = Some(v.trim().to_string());
206        } else if let Some(v) = trimmed.strip_prefix("- ds:") {
207            key.ds_state = Some(v.trim().to_string());
208        } else if let Some(v) = trimmed.strip_prefix("- zone rrsig:") {
209            key.zone_rrsig_state = Some(v.trim().to_string());
210        } else if let Some(v) = trimmed.strip_prefix("- key rrsig:") {
211            key.key_rrsig_state = Some(v.trim().to_string());
212        }
213    }
214
215    if let Some(prev) = current.take() {
216        status.keys.push(prev);
217    }
218
219    status.signed = status.keys.iter().any(|k| {
220        matches!(
221            k.dnskey_state.as_deref(),
222            Some("rumoured") | Some("omnipresent")
223        )
224    });
225
226    status
227}
228
229impl DnssecStatus {
230    /// Key tags of keys in a key-signing role (`KSK` or `CSK`) — the keys
231    /// whose DNSKEY records DS records are derived from (ADR-0001).
232    pub fn ksk_tags(&self) -> std::collections::HashSet<u16> {
233        self.keys
234            .iter()
235            .filter(|k| k.role == "KSK" || k.role == "CSK")
236            .map(|k| k.tag)
237            .collect()
238    }
239}
240
241/// DS visibility state reported to named via `rndc dnssec -checkds`
242/// (ADR-0001: exposed so the insecure transition can complete without host
243/// access when no parental agents are configured).
244#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
245#[cfg_attr(feature = "server", derive(ToSchema))]
246#[serde(rename_all = "lowercase")]
247pub enum CheckdsState {
248    /// The DS for the key has been published at the parent.
249    Published,
250    /// The DS for the key has been removed from the parent.
251    Withdrawn,
252}
253
254impl CheckdsState {
255    /// The literal argument `rndc dnssec -checkds` expects.
256    pub fn as_str(&self) -> &'static str {
257        match self {
258            CheckdsState::Published => "published",
259            CheckdsState::Withdrawn => "withdrawn",
260        }
261    }
262}
263
264/// A DNSKEY resource record parsed from textual (zone file / key file) form.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct DnskeyRecord {
267    /// Owner name exactly as it appeared in the record (trailing dot kept).
268    pub owner: String,
269    /// DNSKEY flags field (256 = ZSK, 257 = ZSK+SEP i.e. KSK/CSK).
270    pub flags: u16,
271    /// DNSKEY protocol field (always 3 per RFC 4034).
272    pub protocol: u8,
273    /// DNSSEC algorithm number.
274    pub algorithm: u8,
275    /// Decoded public key material.
276    pub public_key: Vec<u8>,
277}
278
279/// A computed DS record ready to publish at the parent zone.
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
281#[cfg_attr(feature = "server", derive(ToSchema))]
282#[serde(rename_all = "camelCase")]
283pub struct DsRecord {
284    /// Key tag of the DNSKEY this DS refers to.
285    pub key_tag: u16,
286    /// DNSSEC algorithm number of the DNSKEY.
287    pub algorithm: u8,
288    /// DS digest type (always 2 = SHA-256).
289    pub digest_type: u8,
290    /// Uppercase hex digest.
291    pub digest: String,
292}
293
294impl DsRecord {
295    /// The DS RDATA in presentation format: `<tag> <alg> <digest type> <digest>`.
296    pub fn rdata(&self) -> String {
297        format!(
298            "{} {} {} {}",
299            self.key_tag, self.algorithm, self.digest_type, self.digest
300        )
301    }
302}
303
304impl DnskeyRecord {
305    /// True when the SEP (key-signing) flag bit is set.
306    pub fn is_ksk(&self) -> bool {
307        self.flags & DNSKEY_FLAG_SEP != 0
308    }
309
310    /// DNSKEY RDATA in wire format: flags, protocol, algorithm, key material.
311    fn rdata_wire(&self) -> Vec<u8> {
312        let mut wire = Vec::with_capacity(self.public_key.len() + 4);
313        wire.extend_from_slice(&self.flags.to_be_bytes());
314        wire.push(self.protocol);
315        wire.push(self.algorithm);
316        wire.extend_from_slice(&self.public_key);
317        wire
318    }
319
320    /// Computes the key tag (RFC 4034 appendix B).
321    pub fn key_tag(&self) -> u16 {
322        let wire = self.rdata_wire();
323        let mut acc: u32 = 0;
324        for (i, byte) in wire.iter().enumerate() {
325            if i % 2 == 0 {
326                acc += u32::from(*byte) << 8;
327            } else {
328                acc += u32::from(*byte);
329            }
330        }
331        acc += acc >> 16;
332        (acc & 0xFFFF) as u16
333    }
334
335    /// Computes the SHA-256 DS record for this DNSKEY (RFC 4509).
336    ///
337    /// The digest is `SHA-256(canonical owner name wire format || DNSKEY
338    /// RDATA wire format)`; the owner name is lowercased per RFC 4034 §6.2.
339    ///
340    /// # Errors
341    /// Returns [`DnssecError::InvalidOwnerName`] if the owner name cannot be
342    /// encoded in wire format (empty/oversized labels, name too long).
343    pub fn ds_record(&self) -> Result<DsRecord, DnssecError> {
344        let owner_wire = name_to_canonical_wire(&self.owner)?;
345
346        let mut hasher = Sha256::new();
347        hasher.update(&owner_wire);
348        hasher.update(self.rdata_wire());
349        let digest = hasher.finalize();
350
351        let mut hex = String::with_capacity(digest.len() * 2);
352        for byte in digest {
353            hex.push_str(&format!("{byte:02X}"));
354        }
355
356        Ok(DsRecord {
357            key_tag: self.key_tag(),
358            algorithm: self.algorithm,
359            digest_type: DS_DIGEST_TYPE_SHA256,
360            digest: hex,
361        })
362    }
363}
364
365/// Encodes a textual DNS name in canonical (lowercase) wire format.
366fn name_to_canonical_wire(name: &str) -> Result<Vec<u8>, DnssecError> {
367    let name = name.trim_end_matches('.');
368    let mut wire = Vec::with_capacity(name.len() + 2);
369
370    if !name.is_empty() {
371        for label in name.split('.') {
372            if label.is_empty() || label.len() > MAX_LABEL_LEN {
373                return Err(DnssecError::InvalidOwnerName(format!(
374                    "label {label:?} is empty or longer than {MAX_LABEL_LEN} octets"
375                )));
376            }
377            wire.push(label.len() as u8);
378            wire.extend(label.bytes().map(|b| b.to_ascii_lowercase()));
379        }
380    }
381    wire.push(0);
382
383    if wire.len() > MAX_NAME_LEN {
384        return Err(DnssecError::InvalidOwnerName(format!(
385            "name {name:?} exceeds {MAX_NAME_LEN} octets in wire format"
386        )));
387    }
388    Ok(wire)
389}
390
391/// Decodes base64, tolerating embedded whitespace (key files split the blob).
392fn decode_base64(input: &str) -> Result<Vec<u8>, DnssecError> {
393    let compact: String = input.split_whitespace().collect();
394    base64_decode(&compact).ok_or_else(|| DnssecError::InvalidField {
395        field: "public key",
396        value: input.to_string(),
397    })
398}
399
400/// Minimal strict base64 (standard alphabet, `=` padding) decoder.
401///
402/// Kept local so the always-on library half does not grow a dependency for
403/// one call site; the RFC 4509 test vector pins its correctness.
404fn base64_decode(input: &str) -> Option<Vec<u8>> {
405    const INVALID: u8 = u8::MAX;
406    fn value(byte: u8) -> u8 {
407        match byte {
408            b'A'..=b'Z' => byte - b'A',
409            b'a'..=b'z' => byte - b'a' + 26,
410            b'0'..=b'9' => byte - b'0' + 52,
411            b'+' => 62,
412            b'/' => 63,
413            _ => INVALID,
414        }
415    }
416
417    let bytes = input.as_bytes();
418    let stripped = match bytes {
419        [rest @ .., b'=', b'='] => rest,
420        [rest @ .., b'='] => rest,
421        rest => rest,
422    };
423
424    let mut out = Vec::with_capacity(stripped.len() * 3 / 4 + 3);
425    let mut acc: u32 = 0;
426    let mut bits: u32 = 0;
427    for &byte in stripped {
428        let v = value(byte);
429        if v == INVALID {
430            return None;
431        }
432        acc = (acc << 6) | u32::from(v);
433        bits += 6;
434        if bits >= 8 {
435            bits -= 8;
436            out.push((acc >> bits) as u8);
437        }
438    }
439    // Leftover bits must be zero padding only.
440    if bits > 0 && acc & ((1 << bits) - 1) != 0 {
441        return None;
442    }
443    Some(out)
444}
445
446/// Parses a DNSKEY resource record in presentation format:
447/// `<owner> [<ttl>] [<class>] DNSKEY <flags> <protocol> <algorithm> <base64…>`.
448///
449/// # Errors
450/// Returns [`DnssecError::NotDnskey`] when the RR type is not DNSKEY, and
451/// [`DnssecError::InvalidField`] when an RDATA field fails to parse.
452pub fn parse_dnskey_rr(text: &str) -> Result<DnskeyRecord, DnssecError> {
453    let mut tokens = text.split_whitespace().peekable();
454
455    let owner = tokens
456        .next()
457        .ok_or_else(|| DnssecError::NotDnskey("empty input".to_string()))?
458        .to_string();
459
460    // Skip optional TTL and class, then require the DNSKEY type token.
461    let mut saw_dnskey = false;
462    for token in tokens.by_ref() {
463        if token.eq_ignore_ascii_case("DNSKEY") {
464            saw_dnskey = true;
465            break;
466        }
467        let is_ttl = token.chars().all(|c| c.is_ascii_digit());
468        let is_class = token.eq_ignore_ascii_case("IN");
469        if !is_ttl && !is_class {
470            return Err(DnssecError::NotDnskey(format!(
471                "unexpected token {token:?} before DNSKEY type"
472            )));
473        }
474    }
475    if !saw_dnskey {
476        return Err(DnssecError::NotDnskey(
477            "no DNSKEY type token found".to_string(),
478        ));
479    }
480
481    let flags_token = tokens.next().ok_or(DnssecError::InvalidField {
482        field: "flags",
483        value: "missing".to_string(),
484    })?;
485    let flags: u16 = flags_token.parse().map_err(|_| DnssecError::InvalidField {
486        field: "flags",
487        value: flags_token.to_string(),
488    })?;
489
490    let protocol_token = tokens.next().ok_or(DnssecError::InvalidField {
491        field: "protocol",
492        value: "missing".to_string(),
493    })?;
494    let protocol: u8 = protocol_token
495        .parse()
496        .map_err(|_| DnssecError::InvalidField {
497            field: "protocol",
498            value: protocol_token.to_string(),
499        })?;
500
501    let algorithm_token = tokens.next().ok_or(DnssecError::InvalidField {
502        field: "algorithm",
503        value: "missing".to_string(),
504    })?;
505    let algorithm: u8 = algorithm_token
506        .parse()
507        .map_err(|_| DnssecError::InvalidField {
508            field: "algorithm",
509            value: algorithm_token.to_string(),
510        })?;
511
512    let key_b64: String = tokens.collect::<Vec<_>>().join(" ");
513    if key_b64.is_empty() {
514        return Err(DnssecError::InvalidField {
515            field: "public key",
516            value: "missing".to_string(),
517        });
518    }
519    let public_key = decode_base64(&key_b64)?;
520
521    Ok(DnskeyRecord {
522        owner,
523        flags,
524        protocol,
525        algorithm,
526        public_key,
527    })
528}
529
530/// Parses a BIND public key file (`K<zone>.+<alg>+<tag>.key`): comment lines
531/// start with `;`, the remaining non-empty line is the DNSKEY RR.
532///
533/// # Errors
534/// Returns [`DnssecError::NotDnskey`] if no DNSKEY record line is present,
535/// or the underlying [`parse_dnskey_rr`] error for a malformed record line.
536pub fn parse_key_file(contents: &str) -> Result<DnskeyRecord, DnssecError> {
537    for line in contents.lines() {
538        let trimmed = line.trim();
539        if trimmed.is_empty() || trimmed.starts_with(';') {
540            continue;
541        }
542        return parse_dnskey_rr(trimmed);
543    }
544    Err(DnssecError::NotDnskey(
545        "no DNSKEY record line in key file".to_string(),
546    ))
547}