Skip to main content

kipuka/config/
mod.rs

1//! Configuration loading and validation.
2//!
3//! The Kipuka EST server is configured via a single TOML file.  The
4//! top-level [`Config`] struct owns all sub-configurations and provides
5//! [`Config::from_file`] for loading with semantic validation.
6//!
7//! # Example configuration
8//!
9//! ```toml
10//! [server]
11//! listen_addr = "0.0.0.0:8443"
12//!
13//! [tls]
14//! enabled = true
15//! cert_file = "/etc/kipuka/server.crt"
16//! key_file  = "/etc/kipuka/server.key"
17//! ca_file   = "/etc/kipuka/est-ca.pem"
18//!
19//! [database]
20//! url = "sqlite:///var/lib/kipuka/kipuka.db"
21//!
22//! [[ca]]
23//! id = "default"
24//! is_default = true
25//! key_file  = "/etc/kipuka/ca.key"
26//! cert_file = "/etc/kipuka/ca.crt"
27//!
28//! [est]
29//! simpleenroll = true
30//! simplereenroll = true
31//!
32//! [audit]
33//! enabled = true
34//! ```
35
36mod admin;
37mod audit;
38mod ca;
39mod cmp;
40mod cms_est;
41mod coap;
42mod db;
43mod est;
44mod hsm;
45mod otp;
46mod server;
47mod star;
48mod tls;
49
50pub use self::admin::*;
51pub use self::audit::*;
52pub use self::ca::*;
53pub use self::cmp::*;
54pub use self::cms_est::*;
55pub use self::coap::*;
56pub use self::db::*;
57pub use self::est::*;
58pub use self::hsm::*;
59pub use self::otp::*;
60pub use self::server::*;
61pub use self::star::*;
62pub use self::tls::*;
63
64// Re-export OcspConfig from the ocsp module for config-level access.
65pub use crate::ocsp::OcspConfig;
66
67use serde::Deserialize;
68
69/// Root configuration for the Kipuka EST server.
70///
71/// Loaded from a TOML file via [`Config::from_file`].
72#[derive(Debug, Clone, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct Config {
75    /// Server listener configuration.
76    #[serde(default)]
77    pub server: ServerConfig,
78
79    /// TLS configuration for the EST listener.
80    #[serde(default)]
81    pub tls: TlsConfig,
82
83    /// Database connection configuration.
84    #[serde(default)]
85    pub database: DbConfig,
86
87    /// Certificate Authority configurations.
88    ///
89    /// Supports `[ca]` (single-CA backward compat) or `[[ca]]` (multi-CA).
90    #[serde(rename = "ca", deserialize_with = "deserialize_ca_array")]
91    pub cas: Vec<CaConfig>,
92
93    /// EST protocol configuration.
94    #[serde(default)]
95    pub est: EstConfig,
96
97    /// HSM / PKCS#11 configuration.  Absent → software-only key storage.
98    #[serde(default)]
99    pub hsm: Option<HsmConfig>,
100
101    /// OTP enrollment authentication.
102    #[serde(default)]
103    pub otp: OtpConfig,
104
105    /// Admin API configuration.  Absent → admin endpoints disabled.
106    #[serde(default)]
107    pub admin: Option<AdminConfig>,
108
109    /// Audit trail configuration.
110    #[serde(default)]
111    pub audit: AuditConfig,
112
113    /// CoAP transport configuration (RFC 9483).  Absent → CoAP disabled.
114    #[serde(default)]
115    pub coap: Option<CoapConfig>,
116
117    /// CMS-wrapped EST configuration (RFC 8295).  Absent → CMS-EST disabled.
118    #[serde(default)]
119    pub cms_est: Option<CmsEstConfig>,
120
121    /// CMP v3 configuration (RFC 9810).  Absent → CMP disabled.
122    #[serde(default)]
123    pub cmp: Option<CmpConfig>,
124
125    /// STAR certificate configuration (RFC 8739).  Absent → STAR disabled.
126    #[serde(default)]
127    pub star: Option<StarConfig>,
128
129    /// OCSP configuration for certificate revocation checking (RFC 6960).
130    /// Absent → OCSP checking disabled (RHELBU-3536 R21).
131    #[serde(default)]
132    pub ocsp: OcspConfig,
133}
134
135impl Config {
136    /// Load and validate configuration from a TOML file.
137    ///
138    /// Returns the parsed config or a human-readable error string
139    /// suitable for startup diagnostics.
140    pub fn from_file(path: &str) -> Result<Self, String> {
141        let content = std::fs::read_to_string(path)
142            .map_err(|e| format!("cannot read config file '{path}': {e}"))?;
143        let config: Self =
144            toml::from_str(&content).map_err(|e| format!("config parse error: {e}"))?;
145        config.validate()?;
146        Ok(config)
147    }
148
149    /// Validate semantic constraints that cannot be expressed in serde alone.
150    ///
151    /// Called automatically by [`Self::from_file`].
152    pub fn validate(&self) -> Result<(), String> {
153        // ── CAs ──────────────────────────────────────────────────────────────
154        if self.cas.is_empty() {
155            return Err("at least one [ca] or [[ca]] entry is required".into());
156        }
157
158        // Validate each CA entry
159        for ca in &self.cas {
160            if ca.id.is_empty() {
161                return Err("each [[ca]] entry must have a non-empty `id` field".into());
162            }
163            if !is_valid_ca_id(&ca.id) {
164                return Err(format!(
165                    "CA id {:?} must match ^[a-z0-9][a-z0-9_-]*$ (max 64 chars)",
166                    ca.id
167                ));
168            }
169        }
170
171        // Check for duplicate CA IDs
172        let mut seen_ids = std::collections::HashSet::new();
173        for ca in &self.cas {
174            if !seen_ids.insert(ca.id.as_str()) {
175                return Err(format!("duplicate CA id {:?}", ca.id));
176            }
177        }
178
179        // Multi-CA: exactly one default
180        if self.cas.len() > 1 {
181            let default_count = self.cas.iter().filter(|c| c.is_default).count();
182            if default_count == 0 {
183                return Err(
184                    "with multiple [[ca]] entries, exactly one must have `is_default = true`"
185                        .into(),
186                );
187            }
188            if default_count > 1 {
189                return Err("at most one [[ca]] entry may have `is_default = true`".into());
190            }
191        }
192
193        // ── TLS ──────────────────────────────────────────────────────────────
194        self.tls.validate()?;
195
196        // TLS + Unix socket conflict
197        if self.tls.enabled && self.server.is_unix_socket() {
198            return Err("TLS cannot be used with a Unix domain socket listener".into());
199        }
200
201        // ── EST labels reference valid CAs ───────────────────────────────────
202        let known_ca_ids: std::collections::HashSet<&str> =
203            self.cas.iter().map(|c| c.id.as_str()).collect();
204
205        for label in &self.est.labels {
206            if let Some(ref ca_id) = label.ca_id
207                && !known_ca_ids.contains(ca_id.as_str())
208            {
209                return Err(format!(
210                    "EST label {:?} references unknown CA id {ca_id:?}",
211                    label.name
212                ));
213            }
214        }
215
216        // ── OTP ──────────────────────────────────────────────────────────────
217        self.otp.validate()?;
218
219        // ── Admin ────────────────────────────────────────────────────────────
220        if let Some(ref admin) = self.admin {
221            admin.validate()?;
222        }
223
224        // ── Audit ────────────────────────────────────────────────────────────
225        self.audit.validate()?;
226
227        // ── CoAP ────────────────────────────────────────────────────────────
228        if let Some(ref coap) = self.coap {
229            coap.validate()?;
230        }
231
232        // ── CMS-EST ─────────────────────────────────────────────────────────
233        if let Some(ref cms_est) = self.cms_est {
234            cms_est.validate()?;
235        }
236
237        // ── CMP ─────────────────────────────────────────────────────────────
238        if let Some(ref cmp) = self.cmp {
239            let _ = cmp; // No validation needed yet beyond serde
240        }
241
242        // ── STAR ────────────────────────────────────────────────────────────
243        if let Some(ref star) = self.star {
244            star.validate()?;
245        }
246
247        // ── File path existence checks ───────────────────────────────────────
248        // These are warnings rather than hard failures to allow config
249        // validation before all files are deployed (--check-config).
250        // At startup, missing files will produce clear errors anyway.
251        if self.tls.enabled {
252            check_file_exists("[tls].cert_file", &self.tls.cert_file)?;
253            check_file_exists("[tls].key_file", &self.tls.key_file)?;
254            if !self.tls.ca_file.is_empty() {
255                check_file_exists("[tls].ca_file", &self.tls.ca_file)?;
256            }
257        }
258
259        Ok(())
260    }
261
262    /// Returns the default CA config: the one with `is_default = true`, or the
263    /// only CA when there is exactly one `[[ca]]` entry.
264    ///
265    /// # Panics
266    ///
267    /// Panics if `cas` is empty or no CA is marked default in a multi-CA
268    /// config.  [`Self::validate`] prevents both situations.
269    pub fn default_ca(&self) -> &CaConfig {
270        if self.cas.len() == 1 {
271            return &self.cas[0];
272        }
273        self.cas
274            .iter()
275            .find(|c| c.is_default)
276            .expect("validate() ensures exactly one default CA")
277    }
278}
279
280/// Validate that a CA identifier conforms to naming rules.
281///
282/// CA IDs must:
283/// - Be 1–64 characters
284/// - Start with a lowercase ASCII letter or digit
285/// - Contain only lowercase ASCII letters, digits, `_`, and `-`
286fn is_valid_ca_id(id: &str) -> bool {
287    if id.is_empty() || id.len() > 64 {
288        return false;
289    }
290    let mut chars = id.chars();
291    match chars.next() {
292        None => return false,
293        Some(c) if !c.is_ascii_lowercase() && !c.is_ascii_digit() => return false,
294        _ => {}
295    }
296    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
297}
298
299/// Check that a file path exists (for config validation).
300fn check_file_exists(field: &str, path: &str) -> Result<(), String> {
301    if !path.is_empty() && !std::path::Path::new(path).exists() {
302        return Err(format!("{field} path {path:?} does not exist"));
303    }
304    Ok(())
305}
306
307/// Deserialize either a `[ca]` single-table or a `[[ca]]` array-of-tables
308/// into `Vec<CaConfig>`.
309///
310/// When the TOML source uses the old `[ca]` form the resulting single entry
311/// gets `id = "default"` and `is_default = true` injected automatically.
312fn deserialize_ca_array<'de, D>(deserializer: D) -> Result<Vec<CaConfig>, D::Error>
313where
314    D: serde::Deserializer<'de>,
315{
316    use serde::de::{MapAccess, SeqAccess, Visitor};
317    use std::fmt;
318
319    struct CaArrayVisitor;
320
321    impl<'de> Visitor<'de> for CaArrayVisitor {
322        type Value = Vec<CaConfig>;
323
324        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325            f.write_str("a [ca] table or [[ca]] array of tables")
326        }
327
328        fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<CaConfig>, A::Error> {
329            let mut cas = Vec::new();
330            while let Some(ca) = seq.next_element::<CaConfig>()? {
331                cas.push(ca);
332            }
333            Ok(cas)
334        }
335
336        fn visit_map<M: MapAccess<'de>>(self, map: M) -> Result<Vec<CaConfig>, M::Error> {
337            let mut ca = CaConfig::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
338            if ca.id.is_empty() {
339                ca.id = "default".to_owned();
340            }
341            ca.is_default = true;
342            Ok(vec![ca])
343        }
344    }
345
346    deserializer.deserialize_any(CaArrayVisitor)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    fn minimal_toml() -> &'static str {
354        r#"
355[database]
356url = "sqlite::memory:"
357
358[ca]
359key_file = "/tmp/ca.key"
360cert_file = "/tmp/ca.crt"
361"#
362    }
363
364    #[test]
365    fn parse_minimal_config() {
366        let cfg: Config = toml::from_str(minimal_toml()).unwrap();
367        assert_eq!(cfg.database.url, "sqlite::memory:");
368        assert_eq!(cfg.cas.len(), 1);
369        assert_eq!(cfg.cas[0].id, "default");
370        assert!(cfg.cas[0].is_default);
371    }
372
373    #[test]
374    fn default_ca_returns_single_entry() {
375        let cfg: Config = toml::from_str(minimal_toml()).unwrap();
376        assert_eq!(cfg.default_ca().id, "default");
377    }
378
379    #[test]
380    fn multi_ca_requires_default() {
381        let toml = r#"
382[database]
383url = "sqlite::memory:"
384[[ca]]
385id = "a"
386key_file = "/tmp/a.key"
387cert_file = "/tmp/a.crt"
388[[ca]]
389id = "b"
390key_file = "/tmp/b.key"
391cert_file = "/tmp/b.crt"
392"#;
393        let cfg: Config = toml::from_str(toml).unwrap();
394        let err = cfg.validate().unwrap_err();
395        assert!(err.contains("is_default"), "err: {err}");
396    }
397
398    #[test]
399    fn multi_ca_rejects_duplicate_id() {
400        let toml = r#"
401[database]
402url = "sqlite::memory:"
403[[ca]]
404id = "a"
405is_default = true
406key_file = "/tmp/a.key"
407cert_file = "/tmp/a.crt"
408[[ca]]
409id = "a"
410key_file = "/tmp/a2.key"
411cert_file = "/tmp/a2.crt"
412"#;
413        let cfg: Config = toml::from_str(toml).unwrap();
414        let err = cfg.validate().unwrap_err();
415        assert!(err.contains("duplicate"), "err: {err}");
416    }
417
418    #[test]
419    fn est_label_rejects_unknown_ca_id() {
420        let toml = r#"
421[database]
422url = "sqlite::memory:"
423[ca]
424key_file = "/tmp/ca.key"
425cert_file = "/tmp/ca.crt"
426[[est.label]]
427name = "devices"
428ca_id = "nonexistent"
429"#;
430        let cfg: Config = toml::from_str(toml).unwrap();
431        let err = cfg.validate().unwrap_err();
432        assert!(err.contains("nonexistent"), "err: {err}");
433    }
434
435    #[test]
436    fn invalid_ca_id_rejected() {
437        assert!(!is_valid_ca_id(""));
438        assert!(!is_valid_ca_id("Bad Id!"));
439        assert!(!is_valid_ca_id("_starts_with_underscore"));
440        assert!(is_valid_ca_id("good-id"));
441        assert!(is_valid_ca_id("a123_test-ca"));
442    }
443
444    #[test]
445    fn server_defaults_applied() {
446        let cfg: Config = toml::from_str(minimal_toml()).unwrap();
447        assert_eq!(cfg.server.listen_addr, "0.0.0.0:8443");
448        assert_eq!(cfg.server.max_body_size, 65536);
449    }
450
451    #[test]
452    fn est_defaults_applied() {
453        let cfg: Config = toml::from_str(minimal_toml()).unwrap();
454        assert!(cfg.est.simpleenroll);
455        assert!(cfg.est.simplereenroll);
456        assert!(!cfg.est.fullcmc);
457        assert!(!cfg.est.serverkeygen);
458        assert!(cfg.est.csrattrs);
459    }
460}