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;