bindy/bind9/
rndc.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! RNDC key generation and management functions.
5
6use super::types::RndcKeyData;
7use anyhow::{Context, Result};
8use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
9use hickory_proto::rr::rdata::tsig::TsigAlgorithm;
10use hickory_proto::rr::Name;
11use hickory_proto::rr::TSigner;
12use rand::Rng;
13use std::collections::BTreeMap;
14use std::str::FromStr;
15
16use crate::constants::TSIG_FUDGE_TIME_SECS;
17
18/// Generate a new RNDC key with HMAC-SHA256.
19///
20/// Returns a base64-encoded 256-bit (32-byte) key suitable for rndc authentication.
21#[must_use]
22pub fn generate_rndc_key() -> RndcKeyData {
23    let mut rng = rand::rng();
24    let mut key_bytes = [0u8; 32]; // 256 bits for HMAC-SHA256
25    rng.fill_bytes(&mut key_bytes);
26
27    RndcKeyData {
28        name: String::new(), // Will be set by caller
29        algorithm: crate::crd::RndcAlgorithm::HmacSha256,
30        secret: BASE64.encode(key_bytes),
31    }
32}
33
34/// Create a Kubernetes Secret manifest for an RNDC key.
35///
36/// Returns a `BTreeMap` suitable for use as Secret data.
37#[must_use]
38pub fn create_rndc_secret_data(key_data: &RndcKeyData) -> BTreeMap<String, String> {
39    let mut data = BTreeMap::new();
40    data.insert("key-name".to_string(), key_data.name.clone());
41    data.insert(
42        "algorithm".to_string(),
43        key_data.algorithm.as_str().to_string(),
44    );
45    data.insert("secret".to_string(), key_data.secret.clone());
46
47    // Add rndc.key file content for BIND9 to use
48    let rndc_key_content = format!(
49        "key \"{}\" {{\n    algorithm {};\n    secret \"{}\";\n}};\n",
50        key_data.name,
51        key_data.algorithm.as_str(),
52        key_data.secret
53    );
54    data.insert("rndc.key".to_string(), rndc_key_content);
55
56    data
57}
58
59/// Parse RNDC key data from a Kubernetes Secret.
60///
61/// Supports two Secret formats:
62/// 1. **Operator-generated** (all 4 fields): `key-name`, `algorithm`, `secret`, `rndc.key`
63/// 2. **External/user-managed** (minimal): `rndc.key` only - parses the BIND9 key file
64///
65/// # Errors
66///
67/// Returns an error if:
68/// - Neither the metadata fields nor `rndc.key` are present
69/// - The `rndc.key` file cannot be parsed
70/// - Values are not valid UTF-8 strings
71pub fn parse_rndc_secret_data(data: &BTreeMap<String, Vec<u8>>) -> Result<RndcKeyData> {
72    // Try the operator-generated format first (has all metadata fields)
73    if let (Some(name_bytes), Some(algo_bytes), Some(secret_bytes)) = (
74        data.get("key-name"),
75        data.get("algorithm"),
76        data.get("secret"),
77    ) {
78        let name = std::str::from_utf8(name_bytes)?.to_string();
79        let algorithm_str = std::str::from_utf8(algo_bytes)?;
80        let secret = std::str::from_utf8(secret_bytes)?.to_string();
81
82        let algorithm = match algorithm_str {
83            "hmac-md5" => anyhow::bail!(
84                "hmac-md5 is rejected: MD5 is deprecated by RFC 8945 and not \
85                 acceptable for new RNDC/TSIG keys. Rotate to hmac-sha256 or stronger."
86            ),
87            "hmac-sha1" => crate::crd::RndcAlgorithm::HmacSha1,
88            "hmac-sha224" => crate::crd::RndcAlgorithm::HmacSha224,
89            "hmac-sha256" => crate::crd::RndcAlgorithm::HmacSha256,
90            "hmac-sha384" => crate::crd::RndcAlgorithm::HmacSha384,
91            "hmac-sha512" => crate::crd::RndcAlgorithm::HmacSha512,
92            _ => anyhow::bail!("Unsupported RNDC algorithm '{algorithm_str}'. Supported algorithms: hmac-sha1, hmac-sha224, hmac-sha256, hmac-sha384, hmac-sha512"),
93        };
94
95        return Ok(RndcKeyData {
96            name,
97            algorithm,
98            secret,
99        });
100    }
101
102    // Fall back to parsing the rndc.key file (external Secret format)
103    if let Some(rndc_key_bytes) = data.get("rndc.key") {
104        let rndc_key_content = std::str::from_utf8(rndc_key_bytes)?;
105        return parse_rndc_key_file(rndc_key_content);
106    }
107
108    anyhow::bail!(
109        "Secret must contain either (key-name, algorithm, secret) or rndc.key field. \
110         For external secrets, provide only 'rndc.key' with the BIND9 key file content."
111    )
112}
113
114/// Parse a BIND9 key file (rndc.key format) to extract key metadata.
115///
116/// Expected format:
117/// ```text
118/// key "key-name" {
119///     algorithm hmac-sha256;
120///     secret "base64secret==";
121/// };
122/// ```
123///
124/// # Errors
125///
126/// Returns an error if the file format is invalid or required fields are missing.
127fn parse_rndc_key_file(content: &str) -> Result<RndcKeyData> {
128    // Simple regex-based parser for BIND9 key file format
129    // Format: key "name" { algorithm algo; secret "secret"; };
130
131    // Extract key name
132    let name = content
133        .lines()
134        .find(|line| line.contains("key"))
135        .and_then(|line| {
136            line.split('"').nth(1) // Get the text between first pair of quotes
137        })
138        .context("Failed to parse key name from rndc.key file")?
139        .to_string();
140
141    // Extract algorithm
142    let algorithm_str = content
143        .lines()
144        .find(|line| line.contains("algorithm"))
145        .and_then(|line| {
146            line.split_whitespace()
147                .nth(1) // After "algorithm"
148                .map(|s| s.trim_end_matches(';'))
149        })
150        .context("Failed to parse algorithm from rndc.key file")?;
151
152    let algorithm = match algorithm_str {
153        "hmac-md5" => anyhow::bail!(
154            "hmac-md5 in rndc.key file is rejected: MD5 is deprecated by RFC 8945. \
155             Rotate to hmac-sha256 or stronger."
156        ),
157        "hmac-sha1" => crate::crd::RndcAlgorithm::HmacSha1,
158        "hmac-sha224" => crate::crd::RndcAlgorithm::HmacSha224,
159        "hmac-sha256" => crate::crd::RndcAlgorithm::HmacSha256,
160        "hmac-sha384" => crate::crd::RndcAlgorithm::HmacSha384,
161        "hmac-sha512" => crate::crd::RndcAlgorithm::HmacSha512,
162        _ => anyhow::bail!("Unsupported algorithm '{algorithm_str}' in rndc.key file"),
163    };
164
165    // Extract secret
166    let secret = content
167        .lines()
168        .find(|line| line.contains("secret"))
169        .and_then(|line| {
170            line.split('"').nth(1) // Get the text between first pair of quotes
171        })
172        .context("Failed to parse secret from rndc.key file")?
173        .to_string();
174
175    Ok(RndcKeyData {
176        name,
177        algorithm,
178        secret,
179    })
180}
181
182/// Create a TSIG signer from RNDC key data.
183///
184/// # Errors
185///
186/// Returns an error if the algorithm is unsupported or key data is invalid.
187pub fn create_tsig_signer(key_data: &RndcKeyData) -> Result<TSigner> {
188    // Map RndcAlgorithm to hickory TsigAlgorithm. HMAC-MD5 is intentionally
189    // absent from the source enum (see crd::RndcAlgorithm) because RFC 8945
190    // deprecates it; nothing can reach this match with MD5.
191    let algorithm = match key_data.algorithm {
192        crate::crd::RndcAlgorithm::HmacSha1 => TsigAlgorithm::HmacSha1,
193        crate::crd::RndcAlgorithm::HmacSha224 => TsigAlgorithm::HmacSha224,
194        crate::crd::RndcAlgorithm::HmacSha256 => TsigAlgorithm::HmacSha256,
195        crate::crd::RndcAlgorithm::HmacSha384 => TsigAlgorithm::HmacSha384,
196        crate::crd::RndcAlgorithm::HmacSha512 => TsigAlgorithm::HmacSha512,
197    };
198
199    // Decode the base64 key
200    let key_bytes = BASE64
201        .decode(&key_data.secret)
202        .context("Failed to decode TSIG key")?;
203
204    // Create TSIG signer
205    let signer = TSigner::new(
206        key_bytes,
207        algorithm,
208        Name::from_str(&key_data.name).context("Invalid TSIG key name")?,
209        u16::try_from(TSIG_FUDGE_TIME_SECS).unwrap_or(300),
210    )
211    .context("Failed to create TSIG signer")?;
212
213    Ok(signer)
214}
215
216/// Create a Kubernetes Secret with RNDC key data and rotation tracking annotations.
217///
218/// This function creates a Secret with the RNDC key data (via `create_rndc_secret_data`)
219/// and adds rotation tracking annotations for automatic key rotation.
220///
221/// # Arguments
222///
223/// * `namespace` - Kubernetes namespace for the Secret
224/// * `name` - Secret name
225/// * `key_data` - RNDC key data (name, algorithm, secret)
226/// * `created_at` - Timestamp when the key was created or last rotated
227/// * `rotate_after` - Optional duration after which to rotate (None = no rotation)
228/// * `rotation_count` - Number of times the key has been rotated (0 for new keys)
229///
230/// # Returns
231///
232/// A Kubernetes Secret resource with:
233/// - RNDC key data in `.data`
234/// - Rotation tracking annotations in `.metadata.annotations`
235///
236/// # Annotations
237///
238/// - `bindy.firestoned.io/rndc-created-at`: ISO 8601 timestamp (always present)
239/// - `bindy.firestoned.io/rndc-rotate-at`: ISO 8601 timestamp (only if `rotate_after` is Some)
240/// - `bindy.firestoned.io/rndc-rotation-count`: Number of rotations (always present)
241///
242/// # Examples
243///
244/// ```rust,no_run
245/// use bindy::bind9::rndc::{generate_rndc_key, create_rndc_secret_with_annotations};
246/// use chrono::Utc;
247/// use std::time::Duration;
248///
249/// let key_data = generate_rndc_key();
250/// let created_at = Utc::now();
251/// let rotate_after = Duration::from_secs(30 * 24 * 3600); // 30 days
252///
253/// let secret = create_rndc_secret_with_annotations(
254///     "bindy-system",
255///     "bind9-primary-rndc-key",
256///     &key_data,
257///     created_at,
258///     Some(rotate_after),
259///     0, // First key, not rotated yet
260/// );
261/// ```
262///
263/// # Panics
264///
265/// May panic if the `rotate_after` duration cannot be converted to a chrono Duration.
266/// This should not happen for valid rotation intervals (1h - 8760h).
267#[must_use]
268pub fn create_rndc_secret_with_annotations(
269    namespace: &str,
270    name: &str,
271    key_data: &RndcKeyData,
272    created_at: chrono::DateTime<chrono::Utc>,
273    rotate_after: Option<std::time::Duration>,
274    rotation_count: u32,
275) -> k8s_openapi::api::core::v1::Secret {
276    use k8s_openapi::api::core::v1::Secret;
277    use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
278    use k8s_openapi::ByteString;
279
280    // Create Secret data with RNDC key
281    let secret_data_map = create_rndc_secret_data(key_data);
282    let mut data = BTreeMap::new();
283    for (k, v) in secret_data_map {
284        data.insert(k, ByteString(v.into_bytes()));
285    }
286
287    // Create rotation tracking annotations
288    let mut annotations = BTreeMap::new();
289    annotations.insert(
290        crate::constants::ANNOTATION_RNDC_CREATED_AT.to_string(),
291        created_at.to_rfc3339(),
292    );
293
294    // Add rotate_at annotation if rotation is enabled
295    if let Some(duration) = rotate_after {
296        let rotate_at = created_at + chrono::Duration::from_std(duration).unwrap();
297        annotations.insert(
298            crate::constants::ANNOTATION_RNDC_ROTATE_AT.to_string(),
299            rotate_at.to_rfc3339(),
300        );
301    }
302
303    annotations.insert(
304        crate::constants::ANNOTATION_RNDC_ROTATION_COUNT.to_string(),
305        rotation_count.to_string(),
306    );
307
308    Secret {
309        metadata: ObjectMeta {
310            name: Some(name.to_string()),
311            namespace: Some(namespace.to_string()),
312            annotations: Some(annotations),
313            ..Default::default()
314        },
315        data: Some(data),
316        ..Default::default()
317    }
318}
319
320/// Parse rotation tracking annotations from a Kubernetes Secret.
321///
322/// Extracts the `created_at`, `rotate_at`, and `rotation_count` annotations
323/// from a Secret's metadata.
324///
325/// # Arguments
326///
327/// * `annotations` - Secret annotations map
328///
329/// # Returns
330///
331/// A tuple of:
332/// - `created_at`: Timestamp when the key was created or last rotated
333/// - `rotate_at`: Optional timestamp when rotation is due (None if rotation disabled)
334/// - `rotation_count`: Number of times the key has been rotated
335///
336/// # Errors
337///
338/// Returns an error if:
339/// - The `created-at` annotation is missing
340/// - Any timestamp cannot be parsed as ISO 8601
341/// - The `rotation-count` annotation cannot be parsed as u32
342///
343/// # Examples
344///
345/// ```rust,no_run
346/// use std::collections::BTreeMap;
347/// use bindy::bind9::rndc::parse_rotation_annotations;
348///
349/// let mut annotations = BTreeMap::new();
350/// annotations.insert(
351///     "bindy.firestoned.io/rndc-created-at".to_string(),
352///     "2025-01-26T10:00:00Z".to_string()
353/// );
354/// annotations.insert(
355///     "bindy.firestoned.io/rndc-rotate-at".to_string(),
356///     "2025-02-25T10:00:00Z".to_string()
357/// );
358/// annotations.insert(
359///     "bindy.firestoned.io/rndc-rotation-count".to_string(),
360///     "5".to_string()
361/// );
362///
363/// let (created_at, rotate_at, count) = parse_rotation_annotations(&annotations).unwrap();
364/// assert_eq!(count, 5);
365/// ```
366pub fn parse_rotation_annotations(
367    annotations: &BTreeMap<String, String>,
368) -> Result<(
369    chrono::DateTime<chrono::Utc>,
370    Option<chrono::DateTime<chrono::Utc>>,
371    u32,
372)> {
373    // Parse created_at (required)
374    let created_at_str = annotations
375        .get(crate::constants::ANNOTATION_RNDC_CREATED_AT)
376        .context("Missing created-at annotation")?;
377    let created_at = chrono::DateTime::parse_from_rfc3339(created_at_str)
378        .context("Failed to parse created-at timestamp")?
379        .with_timezone(&chrono::Utc);
380
381    // Parse rotate_at (optional)
382    let rotate_at =
383        if let Some(rotate_at_str) = annotations.get(crate::constants::ANNOTATION_RNDC_ROTATE_AT) {
384            Some(
385                chrono::DateTime::parse_from_rfc3339(rotate_at_str)
386                    .context("Failed to parse rotate-at timestamp")?
387                    .with_timezone(&chrono::Utc),
388            )
389        } else {
390            None
391        };
392
393    // Parse rotation_count (default to 0 if missing)
394    let rotation_count = annotations
395        .get(crate::constants::ANNOTATION_RNDC_ROTATION_COUNT)
396        .map(|s| s.parse::<u32>().context("Failed to parse rotation-count"))
397        .transpose()?
398        .unwrap_or(0);
399
400    Ok((created_at, rotate_at, rotation_count))
401}
402
403/// Check if RNDC key rotation is due based on the rotation timestamp.
404///
405/// Rotation is due if:
406/// - `rotate_at` is Some AND
407/// - `rotate_at` is less than or equal to `now`
408///
409/// # Arguments
410///
411/// * `rotate_at` - Optional timestamp when rotation should occur (None = no rotation)
412/// * `now` - Current timestamp
413///
414/// # Returns
415///
416/// - `true` if rotation is due (`rotate_at` has passed)
417/// - `false` if rotation is not due or disabled (`rotate_at` is None)
418///
419/// # Examples
420///
421/// ```rust
422/// use bindy::bind9::rndc::is_rotation_due;
423/// use chrono::Utc;
424///
425/// let past_time = Utc::now() - chrono::Duration::hours(1);
426/// let now = Utc::now();
427///
428/// assert!(is_rotation_due(Some(past_time), now)); // Rotation is due
429///
430/// let future_time = Utc::now() + chrono::Duration::hours(1);
431/// assert!(!is_rotation_due(Some(future_time), now)); // Not due yet
432///
433/// assert!(!is_rotation_due(None, now)); // Rotation disabled
434/// ```
435#[must_use]
436pub fn is_rotation_due(
437    rotate_at: Option<chrono::DateTime<chrono::Utc>>,
438    now: chrono::DateTime<chrono::Utc>,
439) -> bool {
440    match rotate_at {
441        Some(rotate_time) => rotate_time <= now,
442        None => false, // No rotation scheduled
443    }
444}
445
446#[cfg(test)]
447#[path = "rndc_tests.rs"]
448mod rndc_tests;