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;
37pub mod audit;
38mod ca;
39mod cmp;
40mod cms_est;
41mod coap;
42mod db;
43mod est;
44mod ha;
45mod hsm;
46mod lockout;
47mod otp;
48pub mod secret;
49mod server;
50mod star;
51mod tls;
52
53pub use self::admin::*;
54pub use self::audit::*;
55pub use self::ca::*;
56pub use self::cmp::*;
57pub use self::cms_est::*;
58pub use self::coap::*;
59pub use self::db::*;
60pub use self::est::*;
61pub use self::ha::*;
62pub use self::hsm::*;
63pub use self::lockout::*;
64pub use self::otp::*;
65pub use self::secret::{ResolvedSecrets, SecretRef, SecretResolver};
66pub use self::server::*;
67pub use self::star::*;
68pub use self::tls::*;
69
70// Re-export OcspConfig from the ocsp module for config-level access.
71pub use crate::ocsp::OcspConfig;
72
73use serde::Deserialize;
74
75/// Root configuration for the Kipuka EST server.
76///
77/// Loaded from a TOML file via [`Config::from_file`].
78#[derive(Debug, Clone, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct Config {
81    /// Server listener configuration.
82    #[serde(default)]
83    pub server: ServerConfig,
84
85    /// TLS configuration for the EST listener.
86    #[serde(default)]
87    pub tls: TlsConfig,
88
89    /// Database connection configuration.
90    #[serde(default)]
91    pub database: DbConfig,
92
93    /// Certificate Authority configurations.
94    ///
95    /// Supports `[ca]` (single-CA backward compat) or `[[ca]]` (multi-CA).
96    #[serde(rename = "ca", deserialize_with = "deserialize_ca_array")]
97    pub cas: Vec<CaConfig>,
98
99    /// EST protocol configuration.
100    #[serde(default)]
101    pub est: EstConfig,
102
103    /// HSM / PKCS#11 configuration.  Absent → software-only key storage.
104    #[serde(default)]
105    pub hsm: Option<HsmConfig>,
106
107    /// OTP enrollment authentication.
108    #[serde(default)]
109    pub otp: OtpConfig,
110
111    /// Admin API configuration.  Absent → admin endpoints disabled.
112    #[serde(default)]
113    pub admin: Option<AdminConfig>,
114
115    /// Audit trail configuration.
116    #[serde(default)]
117    pub audit: AuditConfig,
118
119    /// Authentication failure-lockout configuration (FIA_AFL.1).
120    #[serde(default)]
121    pub auth_lockout: LockoutConfig,
122
123    /// CoAP transport configuration (RFC 9483).  Absent → CoAP disabled.
124    #[serde(default)]
125    pub coap: Option<CoapConfig>,
126
127    /// CMS-wrapped EST configuration (RFC 8295).  Absent → CMS-EST disabled.
128    #[serde(default)]
129    pub cms_est: Option<CmsEstConfig>,
130
131    /// CMP v3 configuration (RFC 9810).  Absent → CMP disabled.
132    #[serde(default)]
133    pub cmp: Option<CmpConfig>,
134
135    /// Dogtag PKI backend configuration (CA + KRA).
136    ///
137    /// When present, enrollment and CMC requests are forwarded to a
138    /// Dogtag PKI CA via its REST API instead of using direct signing.
139    /// Absent → direct signing only (no Dogtag integration).
140    #[serde(default)]
141    pub dogtag: Option<kipuka_dogtag::DogtagConfig>,
142
143    /// STAR certificate configuration (RFC 8739).  Absent → STAR disabled.
144    #[serde(default)]
145    pub star: Option<StarConfig>,
146
147    #[serde(default)]
148    pub ha: Option<HaConfig>,
149
150    /// OCSP configuration for certificate revocation checking (RFC 6960).
151    /// Absent → OCSP checking disabled (RHELBU-3536 R21).
152    #[serde(default)]
153    pub ocsp: OcspConfig,
154
155    /// Interval in seconds between CRL regeneration cycles.
156    ///
157    /// Default: 3600 (1 hour).
158    #[serde(default = "default_crl_refresh_interval_secs")]
159    pub crl_refresh_interval_secs: u64,
160}
161
162fn default_crl_refresh_interval_secs() -> u64 {
163    3600
164}
165
166impl Config {
167    /// Load and validate configuration from a TOML file.
168    ///
169    /// Returns the parsed config or a human-readable error string
170    /// suitable for startup diagnostics.
171    pub fn from_file(path: &str) -> Result<Self, String> {
172        let content = std::fs::read_to_string(path)
173            .map_err(|e| format!("cannot read config file '{path}': {e}"))?;
174        let config: Self =
175            toml::from_str(&content).map_err(|e| format!("config parse error: {e}"))?;
176        config.validate()?;
177        Ok(config)
178    }
179
180    /// Validate semantic constraints that cannot be expressed in serde alone.
181    ///
182    /// Called automatically by [`Self::from_file`].
183    pub fn validate(&self) -> Result<(), String> {
184        // ── CAs ──────────────────────────────────────────────────────────────
185        if self.cas.is_empty() {
186            return Err("at least one [ca] or [[ca]] entry is required".into());
187        }
188
189        // Validate each CA entry
190        for ca in &self.cas {
191            if ca.id.is_empty() {
192                return Err("each [[ca]] entry must have a non-empty `id` field".into());
193            }
194            if !is_valid_ca_id(&ca.id) {
195                return Err(format!(
196                    "CA id {:?} must match ^[a-z0-9][a-z0-9_-]*$ (max 64 chars)",
197                    ca.id
198                ));
199            }
200        }
201
202        // Check for duplicate CA IDs
203        let mut seen_ids = std::collections::HashSet::new();
204        for ca in &self.cas {
205            if !seen_ids.insert(ca.id.as_str()) {
206                return Err(format!("duplicate CA id {:?}", ca.id));
207            }
208        }
209
210        // Multi-CA: exactly one default
211        if self.cas.len() > 1 {
212            let default_count = self.cas.iter().filter(|c| c.is_default).count();
213            if default_count == 0 {
214                return Err(
215                    "with multiple [[ca]] entries, exactly one must have `is_default = true`"
216                        .into(),
217                );
218            }
219            if default_count > 1 {
220                return Err("at most one [[ca]] entry may have `is_default = true`".into());
221            }
222        }
223
224        if let Some(ha) = &self.ha {
225            ha.validate(&self.cas)?;
226        }
227        for label in &self.est.labels {
228            if !label.ca_pool.is_empty() {
229                if !self.ha.as_ref().is_some_and(|ha| ha.enabled) {
230                    return Err("label ca_pool requires enabled HA".into());
231                }
232                if label.ca_id.is_some() {
233                    return Err("label ca_id and ca_pool are mutually exclusive".into());
234                }
235                for id in &label.ca_pool {
236                    if !self.cas.iter().any(|ca| &ca.id == id) {
237                        return Err(format!("unknown CA {id} in label ca_pool"));
238                    }
239                }
240            }
241        }
242
243        // ── TLS ──────────────────────────────────────────────────────────────
244        self.tls.validate()?;
245
246        // TLS + Unix socket conflict
247        if self.tls.enabled && self.server.is_unix_socket() {
248            return Err("TLS cannot be used with a Unix domain socket listener".into());
249        }
250
251        // ── EST labels reference valid CAs ───────────────────────────────────
252        let known_ca_ids: std::collections::HashSet<&str> =
253            self.cas.iter().map(|c| c.id.as_str()).collect();
254
255        for label in &self.est.labels {
256            if let Some(ref ca_id) = label.ca_id
257                && !known_ca_ids.contains(ca_id.as_str())
258            {
259                return Err(format!(
260                    "EST label {:?} references unknown CA id {ca_id:?}",
261                    label.name
262                ));
263            }
264        }
265
266        // ── OTP ──────────────────────────────────────────────────────────────
267        self.otp.validate()?;
268
269        // ── Admin ────────────────────────────────────────────────────────────
270        if let Some(ref admin) = self.admin {
271            admin.validate()?;
272        }
273
274        // ── Audit ────────────────────────────────────────────────────────────
275        self.audit.validate()?;
276
277        // ── Auth lockout (FIA_AFL.1) ─────────────────────────────────────────
278        self.auth_lockout.validate()?;
279
280        // ── CoAP ────────────────────────────────────────────────────────────
281        if let Some(ref coap) = self.coap {
282            coap.validate()?;
283        }
284
285        // ── CMS-EST ─────────────────────────────────────────────────────────
286        if let Some(ref cms_est) = self.cms_est {
287            cms_est.validate()?;
288        }
289
290        // ── CMP ─────────────────────────────────────────────────────────────
291        if let Some(ref cmp) = self.cmp {
292            let _ = cmp; // No validation needed yet beyond serde
293        }
294
295        // ── Dogtag ───────────────────────────────────────────────────────
296        if let Some(ref dogtag) = self.dogtag {
297            if dogtag.ca_url.scheme() != "https" && dogtag.ca_url.scheme() != "http" {
298                return Err("dogtag.ca_url must use HTTPS or HTTP".into());
299            }
300            if dogtag.ca_url.scheme() == "http" {
301                tracing::warn!(
302                    url = %dogtag.ca_url,
303                    "dogtag.ca_url uses HTTP — agent credentials sent in cleartext"
304                );
305            }
306            if dogtag.agent_cert_file.is_empty() {
307                return Err("dogtag.agent_cert_file must not be empty".into());
308            }
309            if dogtag.agent_key_file.is_empty() {
310                return Err("dogtag.agent_key_file must not be empty".into());
311            }
312            if dogtag.ca_cert_file.is_empty() {
313                return Err("dogtag.ca_cert_file must not be empty".into());
314            }
315            if dogtag.profile_id.is_empty() {
316                return Err("dogtag.profile_id must not be empty".into());
317            }
318        }
319
320        // ── STAR ────────────────────────────────────────────────────────────
321        if let Some(ref star) = self.star {
322            star.validate()?;
323        }
324
325        // ── CRL refresh interval ─────────────────────────────────────────────
326        if self.crl_refresh_interval_secs == 0 {
327            return Err("crl_refresh_interval_secs must be at least 1".into());
328        }
329
330        // ── File path existence checks ───────────────────────────────────────
331        // These are warnings rather than hard failures to allow config
332        // validation before all files are deployed (--check-config).
333        // At startup, missing files will produce clear errors anyway.
334        if self.tls.enabled {
335            check_file_exists("[tls].cert_file", &self.tls.cert_file)?;
336            check_file_exists("[tls].key_file", &self.tls.key_file)?;
337            if !self.tls.ca_file.is_empty() {
338                check_file_exists("[tls].ca_file", &self.tls.ca_file)?;
339            }
340        }
341
342        Ok(())
343    }
344
345    /// Returns the default CA config: the one with `is_default = true`, or the
346    /// only CA when there is exactly one `[[ca]]` entry.
347    ///
348    /// # Panics
349    ///
350    /// Panics if `cas` is empty or no CA is marked default in a multi-CA
351    /// config.  [`Self::validate`] prevents both situations.
352    pub fn default_ca(&self) -> &CaConfig {
353        if self.cas.len() == 1 {
354            return &self.cas[0];
355        }
356        self.cas
357            .iter()
358            .find(|c| c.is_default)
359            .expect("validate() ensures exactly one default CA")
360    }
361}
362
363/// Validate that a CA identifier conforms to naming rules.
364///
365/// CA IDs must:
366/// - Be 1–64 characters
367/// - Start with a lowercase ASCII letter or digit
368/// - Contain only lowercase ASCII letters, digits, `_`, and `-`
369fn is_valid_ca_id(id: &str) -> bool {
370    if id.is_empty() || id.len() > 64 {
371        return false;
372    }
373    let mut chars = id.chars();
374    match chars.next() {
375        None => return false,
376        Some(c) if !c.is_ascii_lowercase() && !c.is_ascii_digit() => return false,
377        _ => {}
378    }
379    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
380}
381
382/// Check that a file path exists (for config validation).
383fn check_file_exists(field: &str, path: &str) -> Result<(), String> {
384    if path.is_empty() || path.starts_with("pkcs11:") {
385        return Ok(());
386    }
387    if !std::path::Path::new(path).exists() {
388        return Err(format!("{field} path {path:?} does not exist"));
389    }
390    Ok(())
391}
392
393/// Deserialize either a `[ca]` single-table or a `[[ca]]` array-of-tables
394/// into `Vec<CaConfig>`.
395///
396/// When the TOML source uses the old `[ca]` form the resulting single entry
397/// gets `id = "default"` and `is_default = true` injected automatically.
398fn deserialize_ca_array<'de, D>(deserializer: D) -> Result<Vec<CaConfig>, D::Error>
399where
400    D: serde::Deserializer<'de>,
401{
402    use serde::de::{MapAccess, SeqAccess, Visitor};
403    use std::fmt;
404
405    struct CaArrayVisitor;
406
407    impl<'de> Visitor<'de> for CaArrayVisitor {
408        type Value = Vec<CaConfig>;
409
410        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
411            f.write_str("a [ca] table or [[ca]] array of tables")
412        }
413
414        fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<CaConfig>, A::Error> {
415            let mut cas = Vec::new();
416            while let Some(ca) = seq.next_element::<CaConfig>()? {
417                cas.push(ca);
418            }
419            Ok(cas)
420        }
421
422        fn visit_map<M: MapAccess<'de>>(self, map: M) -> Result<Vec<CaConfig>, M::Error> {
423            let mut ca = CaConfig::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
424            if ca.id.is_empty() {
425                ca.id = "default".to_owned();
426            }
427            ca.is_default = true;
428            Ok(vec![ca])
429        }
430    }
431
432    deserializer.deserialize_any(CaArrayVisitor)
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    fn minimal_toml() -> &'static str {
440        r#"
441[database]
442url = "sqlite::memory:"
443
444[ca]
445key_file = "/tmp/ca.key"
446cert_file = "/tmp/ca.crt"
447"#
448    }
449
450    #[test]
451    fn parse_minimal_config() {
452        let cfg: Config = toml::from_str(minimal_toml()).unwrap();
453        assert_eq!(cfg.database.url, "sqlite::memory:");
454        assert_eq!(cfg.cas.len(), 1);
455        assert_eq!(cfg.cas[0].id, "default");
456        assert!(cfg.cas[0].is_default);
457    }
458
459    #[test]
460    fn default_ca_returns_single_entry() {
461        let cfg: Config = toml::from_str(minimal_toml()).unwrap();
462        assert_eq!(cfg.default_ca().id, "default");
463    }
464
465    #[test]
466    fn multi_ca_requires_default() {
467        let toml = r#"
468[database]
469url = "sqlite::memory:"
470[[ca]]
471id = "a"
472key_file = "/tmp/a.key"
473cert_file = "/tmp/a.crt"
474[[ca]]
475id = "b"
476key_file = "/tmp/b.key"
477cert_file = "/tmp/b.crt"
478"#;
479        let cfg: Config = toml::from_str(toml).unwrap();
480        let err = cfg.validate().unwrap_err();
481        assert!(err.contains("is_default"), "err: {err}");
482    }
483
484    #[test]
485    fn multi_ca_rejects_duplicate_id() {
486        let toml = r#"
487[database]
488url = "sqlite::memory:"
489[[ca]]
490id = "a"
491is_default = true
492key_file = "/tmp/a.key"
493cert_file = "/tmp/a.crt"
494[[ca]]
495id = "a"
496key_file = "/tmp/a2.key"
497cert_file = "/tmp/a2.crt"
498"#;
499        let cfg: Config = toml::from_str(toml).unwrap();
500        let err = cfg.validate().unwrap_err();
501        assert!(err.contains("duplicate"), "err: {err}");
502    }
503
504    #[test]
505    fn est_label_rejects_unknown_ca_id() {
506        let toml = r#"
507[database]
508url = "sqlite::memory:"
509[ca]
510key_file = "/tmp/ca.key"
511cert_file = "/tmp/ca.crt"
512[[est.label]]
513name = "devices"
514ca_id = "nonexistent"
515"#;
516        let cfg: Config = toml::from_str(toml).unwrap();
517        let err = cfg.validate().unwrap_err();
518        assert!(err.contains("nonexistent"), "err: {err}");
519    }
520
521    #[test]
522    fn invalid_ca_id_rejected() {
523        assert!(!is_valid_ca_id(""));
524        assert!(!is_valid_ca_id("Bad Id!"));
525        assert!(!is_valid_ca_id("_starts_with_underscore"));
526        assert!(is_valid_ca_id("good-id"));
527        assert!(is_valid_ca_id("a123_test-ca"));
528    }
529
530    #[test]
531    fn server_defaults_applied() {
532        let cfg: Config = toml::from_str(minimal_toml()).unwrap();
533        assert_eq!(cfg.server.listen_addr, "0.0.0.0:8443");
534        assert_eq!(cfg.server.max_body_size, 65536);
535    }
536
537    #[test]
538    fn est_defaults_applied() {
539        let cfg: Config = toml::from_str(minimal_toml()).unwrap();
540        assert!(cfg.est.simpleenroll);
541        assert!(cfg.est.simplereenroll);
542        assert!(!cfg.est.fullcmc);
543        assert!(!cfg.est.serverkeygen);
544        assert!(cfg.est.csrattrs);
545    }
546
547    /// The shipped Multi-CA HA example (issue #12) must stay parseable and
548    /// semantically valid so operators can copy it verbatim. Every config
549    /// table uses `deny_unknown_fields`, so a renamed or misspelled key here
550    /// fails this test. We parse and drive the HA semantic validator directly
551    /// rather than `Config::from_file`, because the realistic example enables
552    /// TLS with deployment paths that do not exist in the test sandbox (the
553    /// full validator hard-fails on missing TLS files by design).
554    #[test]
555    fn ha_example_config_is_valid() {
556        let path = concat!(
557            env!("CARGO_MANIFEST_DIR"),
558            "/contrib/local-dev/kipuka-ha.toml"
559        );
560        let content = std::fs::read_to_string(path).expect("read kipuka-ha.toml");
561        let cfg: Config = toml::from_str(&content).expect("kipuka-ha.toml must parse");
562
563        // Two CAs, exactly one default (the multi-CA invariant).
564        assert_eq!(cfg.cas.len(), 2, "example should configure two CAs");
565        assert_eq!(
566            cfg.cas.iter().filter(|c| c.is_default).count(),
567            1,
568            "exactly one CA must be is_default"
569        );
570
571        // HA is enabled and its semantic validation passes against the CAs.
572        let ha = cfg.ha.as_ref().expect("[ha] table present");
573        assert!(ha.enabled, "example must enable HA to be illustrative");
574        ha.validate(&cfg.cas)
575            .expect("[ha] example must pass semantic validation");
576
577        // The secondary is probed remotely (endpoint mapping); the primary is
578        // omitted and therefore probed locally (`local:ca-primary`).
579        assert!(ha.endpoints.contains_key("ca-secondary"));
580        assert!(!ha.endpoints.contains_key("ca-primary"));
581
582        // The label pool authorizes exactly the two configured CAs. `ca_pool`
583        // is only legal with HA enabled, which the parse above confirms.
584        assert_eq!(
585            cfg.est.labels[0].ca_pool,
586            vec!["ca-primary".to_string(), "ca-secondary".to_string()],
587        );
588    }
589}