Skip to main content

hoike_core/
config.rs

1use serde::Deserialize;
2use std::path::PathBuf;
3
4#[derive(Debug, Deserialize, Clone)]
5#[serde(deny_unknown_fields)]
6pub struct Config {
7    pub server: ServerConfig,
8    pub storage: StorageConfig,
9    #[serde(default)]
10    pub ca: Vec<CaConfig>,
11    pub gossip: Option<GossipConfigSection>,
12}
13
14#[derive(Debug, Deserialize, Clone)]
15#[serde(deny_unknown_fields)]
16pub struct GossipConfigSection {
17    #[serde(default)]
18    pub enabled: bool,
19    #[serde(default = "default_gossip_bind")]
20    pub bind: String,
21    #[serde(default)]
22    pub seeds: Vec<String>,
23    #[serde(default = "default_gossip_node_name")]
24    pub node_name: String,
25    /// Path to an Ed25519 PKCS#8 private key used to sign outbound gossip
26    /// messages (design §6.3, FPT_ITT.1). When set, every message this node
27    /// broadcasts is signed; peers with the matching public key verify and drop
28    /// unauthenticated messages. When unset, gossip stays unauthenticated
29    /// (backward compatible).
30    pub identity_key: Option<PathBuf>,
31    /// Paths to Ed25519 public keys (PEM/DER) trusted to sign peer gossip
32    /// messages. When `identity_key` is set and this is empty, a node verifies
33    /// only its own signature scheme is well-formed; populate with peer keys to
34    /// authenticate the mesh.
35    #[serde(default)]
36    pub peer_keys: Vec<PathBuf>,
37    #[serde(default)]
38    pub peer_identities: std::collections::BTreeMap<String, PathBuf>,
39}
40
41fn default_gossip_bind() -> String {
42    "0.0.0.0:7946".into()
43}
44fn default_gossip_node_name() -> String {
45    std::env::var("HOSTNAME").unwrap_or_else(|_| "hoike-node".into())
46}
47
48#[derive(Debug, Deserialize, Clone)]
49#[serde(deny_unknown_fields)]
50pub struct ServerConfig {
51    #[serde(default = "default_mode")]
52    pub mode: String,
53    #[serde(default = "default_listen")]
54    pub listen: String,
55    #[serde(default = "default_max_request")]
56    pub max_request: usize,
57    pub admin: Option<AdminConfig>,
58    pub webui: Option<WebUiConfig>,
59    /// Optional dedicated listener for the Prometheus `/metrics` endpoint, e.g.
60    /// "127.0.0.1:9184". Kept off the public OCSP port. Requires a build with
61    /// the `metrics` feature to expose data; otherwise `/metrics` returns 503.
62    pub metrics_listen: Option<String>,
63    /// Optional dedicated listener for the admin API + web UI, e.g.
64    /// "127.0.0.1:2561". When set, the management surface binds here instead of
65    /// riding the public OCSP port — required to give it a trusted path
66    /// (NIAP PPCA FTP_TRP.1) without wrapping the plaintext OCSP data plane.
67    /// Pair with `admin_tls` to terminate TLS. When unset, admin/UI remain on
68    /// `listen` for backward compatibility.
69    pub admin_listen: Option<String>,
70    /// TLS material for the `admin_listen` listener. Requires a build with the
71    /// `tls` feature; startup fails if TLS support is unavailable.
72    pub admin_tls: Option<TlsConfig>,
73    /// TLS material for the `metrics_listen` listener. Requires the `tls`
74    /// feature. When unset the metrics listener stays plaintext (private-bound).
75    pub metrics_tls: Option<TlsConfig>,
76}
77
78/// PEM certificate/key material for a TLS-terminated listener.
79///
80/// `client_ca`, when set, turns on mutual TLS: clients must present a
81/// certificate chaining to one of these anchors (NIAP PPCA FCS_TLSS_EXT.2).
82/// When absent, the listener does server-auth TLS only (FCS_TLSS_EXT.1) and
83/// authentication falls to the existing bcrypt/RBAC login.
84#[derive(Debug, Deserialize, Clone)]
85#[serde(deny_unknown_fields)]
86pub struct TlsConfig {
87    /// Path to the server certificate chain (PEM, leaf first).
88    pub cert: PathBuf,
89    /// Path to the server private key (PKCS#8 or RSA/SEC1 PEM).
90    pub key: PathBuf,
91    /// Optional PEM bundle of CAs trusted to sign client certificates.
92    /// Presence enables mutual TLS (client-cert required).
93    pub client_ca: Option<PathBuf>,
94}
95
96#[derive(Debug, Deserialize, Clone)]
97#[serde(deny_unknown_fields)]
98pub struct WebUiConfig {
99    pub static_dir: Option<PathBuf>,
100}
101
102#[derive(Debug, Deserialize, Clone)]
103#[serde(deny_unknown_fields)]
104pub struct AdminConfig {
105    #[serde(default = "default_session_ttl")]
106    pub session_ttl_secs: u64,
107    #[serde(default)]
108    pub operators: Vec<OperatorConfig>,
109}
110
111#[derive(Debug, Deserialize, Clone)]
112#[serde(deny_unknown_fields)]
113pub struct OperatorConfig {
114    pub name: String,
115    pub password_hash: String,
116    #[serde(default = "default_operator_role")]
117    pub role: String,
118}
119
120fn default_session_ttl() -> u64 {
121    3600
122}
123fn default_operator_role() -> String {
124    "viewer".into()
125}
126
127#[derive(Debug, Deserialize, Clone)]
128#[serde(deny_unknown_fields)]
129pub struct StorageConfig {
130    pub bundle_dir: PathBuf,
131    #[serde(default = "default_state_db")]
132    pub state_db: PathBuf,
133    #[serde(default = "default_max_chain")]
134    pub max_chain: u32,
135    /// DER or PEM certificates trusted as seal signers.
136    /// When set, bundles without a valid CMS seal are rejected on load.
137    #[serde(default)]
138    pub seal_trust_anchors: Option<Vec<PathBuf>>,
139    #[serde(default)]
140    pub seal_authorizations: Vec<SealAuthorization>,
141    /// Explicitly trusted signer certificates, distinct from CA trust anchors.
142    #[serde(default)]
143    pub seal_signer_pins: Vec<PathBuf>,
144}
145
146/// Optional restriction of trusted seal certificates to producer and CA scope.
147#[derive(Debug, Deserialize, Clone)]
148#[serde(deny_unknown_fields)]
149pub struct SealAuthorization {
150    pub producer_id: String,
151    pub issuer_key_hash: String,
152    /// SHA-256 of the DER signer certificate, hex encoded.
153    pub signer_sha256: String,
154}
155
156#[derive(Debug, Deserialize, Clone)]
157#[serde(deny_unknown_fields)]
158pub struct CaConfig {
159    pub label: String,
160    pub bundle_file: Option<PathBuf>,
161    #[serde(default = "default_nonce_policy")]
162    pub nonce_policy: String,
163    #[serde(default = "default_completeness")]
164    pub completeness: String,
165    /// Hex-encoded issuerNameHash for explicit routing.
166    /// If absent, extracted from the bundle manifest on load.
167    pub issuer_name_hash: Option<String>,
168    /// Hex-encoded issuerKeyHash for explicit routing.
169    /// If absent, extracted from the bundle manifest on load.
170    pub issuer_key_hash: Option<String>,
171    /// URL to forward nonce-bearing requests to (for nonce_policy = "forward").
172    /// Must be `https://` (trusted channel, FTP_ITC.1) unless `forward_insecure`
173    /// is set.
174    pub forward_to: Option<String>,
175    /// Allow a plaintext `http://` `forward_to` target. Lab/testing only —
176    /// `hoike check` refuses a cleartext forward URL without this escape hatch.
177    #[serde(default)]
178    pub forward_insecure: bool,
179    /// Optional PEM CA bundle for the forward target's server certificate.
180    ///
181    /// NOTE: per-target custom roots are not yet wired into the forward path.
182    /// The shared outbound client validates the forward target against the
183    /// system trust store, so this CA must currently be installed system-wide to
184    /// take effect; setting it alone does not change validation. `hoike check`
185    /// emits the same caveat. Retained as forward-looking config.
186    pub forward_ca: Option<PathBuf>,
187    /// Revocation source (required for combined/signer mode)
188    pub source: Option<SourceConfig>,
189    /// Batch production interval in seconds (combined/signer mode)
190    #[serde(default = "default_batch_interval")]
191    pub batch_interval: u64,
192    /// OCSP response validity in seconds
193    #[serde(default = "default_validity_secs")]
194    pub validity_secs: u64,
195    /// Upper bound (seconds) of randomized jitter added to `nextUpdate` so a
196    /// fleet's responses do not all expire simultaneously (thundering-herd
197    /// avoidance). Bounded by the source's own nextUpdate. Default 7200.
198    #[serde(default = "default_jitter_secs")]
199    pub jitter_secs: u64,
200    /// CertID hash coverage baked into produced bundles:
201    /// `"dual"` (both SHA-256 and SHA-1, default), `"sha256"`, or `"sha1"`.
202    #[serde(default = "default_certid_compat")]
203    pub certid_compat: String,
204    /// Fraction of a response's validity window advertised as HTTP
205    /// `Cache-Control: max-age` at the edge. Must be in `(0, 1]`. Default 0.5.
206    #[serde(default = "default_max_age_fraction")]
207    pub max_age_fraction: f64,
208    /// Drop entries for certificates that expired more than this many seconds
209    /// ago, bounding bundle size. Requires per-certificate `notAfter`, which
210    /// only the 389 DS syncrepl source supplies — a no-op for CRL sources.
211    /// `0` (default) disables pruning.
212    #[serde(default)]
213    pub archive_cutoff_secs: u64,
214    /// When true (default), the signer produces an off-cycle bundle immediately
215    /// upon detecting a newly revoked certificate, instead of waiting for the
216    /// next `batch_interval`.
217    #[serde(default = "default_urgent_revocation")]
218    pub urgent_revocation: bool,
219    /// DER bytes of the issuer DN (for CertID computation in signer mode).
220    /// Base64-encoded in config, decoded on load.
221    pub issuer_name_der_b64: Option<String>,
222    /// Raw issuer public key bytes (for CertID computation in signer mode).
223    /// Base64-encoded in config, decoded on load.
224    pub issuer_key_bytes_b64: Option<String>,
225    /// Signing algorithm: ecdsa-p256 (default), ml-dsa-44, ml-dsa-65, ml-dsa-87
226    #[serde(default = "default_sig_alg")]
227    pub sig_alg: String,
228    /// Signing key configuration (required for signer/combined mode).
229    pub signing_key: Option<SigningKeyConfig>,
230    /// Path to the delegated OCSP signing certificate (DER or PEM).
231    /// Embedded in each OCSPResponse per RFC 9919 §3.2.2 so clients
232    /// can validate the response without pre-caching the responder cert.
233    pub responder_cert: Option<PathBuf>,
234    /// Key rotation monitoring configuration.
235    pub key_rotation: Option<KeyRotationConfigToml>,
236    /// Path to PKCS#8 PEM/DER key for bundle seal signing.
237    /// If absent, falls back to the OCSP signing key (with a warning).
238    /// The seal key SHOULD be different from the OCSP signing key.
239    pub seal_key: Option<PathBuf>,
240    /// Path to the DER/PEM certificate for the seal signer.
241    /// If absent, generates a self-signed cert (for testing only).
242    pub seal_cert: Option<PathBuf>,
243}
244
245impl CaConfig {
246    /// Returns true if the configured sig_alg is an ML-DSA variant.
247    pub fn is_ml_dsa(&self) -> bool {
248        matches!(
249            self.sig_alg.as_str(),
250            "ml-dsa-44" | "ml-dsa-65" | "ml-dsa-87"
251        )
252    }
253}
254
255/// TOML-level key rotation configuration.
256#[derive(Debug, Deserialize, Clone)]
257#[serde(deny_unknown_fields)]
258pub struct KeyRotationConfigToml {
259    /// Days before cert expiry to trigger rotation warning/action (default: 7).
260    #[serde(default = "default_renew_before_days")]
261    pub renew_before_days: u64,
262    /// Hours between rotation checks (default: 1).
263    #[serde(default = "default_check_interval_hours")]
264    pub check_interval_hours: u64,
265    /// Shell command to execute when rotation is needed.
266    /// The command receives CA label and cert path as arguments.
267    pub rotation_command: Option<String>,
268}
269
270fn default_renew_before_days() -> u64 {
271    7
272}
273fn default_check_interval_hours() -> u64 {
274    1
275}
276
277/// How to obtain the signing key for OCSP response production.
278#[derive(Debug, Deserialize, Clone)]
279#[serde(tag = "type")]
280pub enum SigningKeyConfig {
281    /// Load from a PKCS#8 PEM or DER file on disk.
282    #[serde(rename = "file")]
283    File { path: PathBuf },
284
285    /// Sign via a PKCS#11 hardware security module.
286    ///
287    /// Supported HSMs: Thales Luna, Entrust nShield, Utimaco CryptoServer,
288    /// FutureX Vectera Plus, SoftHSM2 (testing).
289    #[serde(rename = "pkcs11")]
290    Pkcs11 {
291        /// Path to the vendor's PKCS#11 shared library.
292        module: String,
293        /// Find slot by token label (e.g., Luna partition name).
294        token_label: Option<String>,
295        /// Explicit slot ID (alternative to token_label).
296        slot_id: Option<u64>,
297        /// Login PIN (plaintext — prefer pin_env for production).
298        pin: Option<String>,
299        /// Environment variable containing the login PIN.
300        pin_env: Option<String>,
301        /// Find key by CKA_LABEL.
302        key_label: Option<String>,
303        /// Find key by CKA_ID (hex-encoded).
304        key_id: Option<String>,
305    },
306
307    /// Ephemeral demo key for testing only. Produces a warning on every use.
308    #[serde(rename = "demo")]
309    Demo,
310}
311
312#[derive(Debug, Deserialize, Clone)]
313#[serde(tag = "type")]
314pub enum SourceConfig {
315    #[serde(rename = "crl")]
316    Crl {
317        path: PathBuf,
318        issuer_cert: Option<PathBuf>,
319    },
320
321    /// RFC 4533 syncrepl against a Dogtag 389 DS certificate repository.
322    ///
323    /// Initial refresh loads the full cert population; subsequent refreshes
324    /// send the stored sync cookie so 389 DS returns only changes.
325    /// Produces both `Good` and `Revoked` entries — enables
326    /// `authoritative-complete` bundles.
327    #[serde(rename = "dogtag-sync")]
328    DogtagSync {
329        /// LDAP URL, e.g. `ldap://ds-iot.cert-lab.local:3389`
330        ldap_url: String,
331        /// Search base DN, e.g. `ou=certificateRepository,ou=ca,o=pki-iot-ca-CA`
332        base_dn: String,
333        /// Bind DN (e.g. `cn=Directory Manager`)
334        #[serde(default = "default_bind_dn")]
335        bind_dn: String,
336        /// Bind password (plaintext — prefer `bind_password_env`)
337        bind_password: Option<String>,
338        /// Environment variable holding the bind password
339        bind_password_env: Option<String>,
340        /// Path to checkpoint the sync cookie (default: state_db/sync-cookie.dat)
341        cookie_path: Option<PathBuf>,
342        /// LDAP filter (default: `(objectClass=certificateRecord)`)
343        #[serde(default = "default_sync_filter")]
344        filter: Option<String>,
345        /// Transport security for the LDAP connection (FTP_ITC.1):
346        /// `"ldaps"` (implicit TLS), `"starttls"` (upgrade before bind), or
347        /// `"none"` (plaintext — the default, for backward compatibility).
348        /// With `starttls` the upgrade completes *before* the bind, so the bind
349        /// password is never sent in cleartext.
350        #[serde(default = "default_ldap_tls")]
351        tls: String,
352        /// Optional PEM CA bundle to validate the directory server's TLS
353        /// certificate against (instead of the system roots).
354        ca_cert: Option<PathBuf>,
355    },
356}
357
358fn default_ldap_tls() -> String {
359    "none".into()
360}
361
362fn default_bind_dn() -> String {
363    "cn=Directory Manager".into()
364}
365
366fn default_sync_filter() -> Option<String> {
367    Some("(objectClass=certificateRecord)".into())
368}
369
370fn default_mode() -> String {
371    "edge".into()
372}
373fn default_listen() -> String {
374    "0.0.0.0:2560".into()
375}
376fn default_max_request() -> usize {
377    8192
378}
379fn default_state_db() -> PathBuf {
380    PathBuf::from("/var/lib/hoike/state")
381}
382fn default_max_chain() -> u32 {
383    24
384}
385fn default_sig_alg() -> String {
386    "ecdsa-p256".into()
387}
388fn default_nonce_policy() -> String {
389    "ignore".into()
390}
391fn default_completeness() -> String {
392    "partial".into()
393}
394fn default_batch_interval() -> u64 {
395    3600
396}
397fn default_validity_secs() -> u64 {
398    86400
399}
400fn default_jitter_secs() -> u64 {
401    7200
402}
403fn default_certid_compat() -> String {
404    "dual".into()
405}
406fn default_max_age_fraction() -> f64 {
407    0.5
408}
409fn default_urgent_revocation() -> bool {
410    true
411}
412
413impl Config {
414    pub fn from_file(path: &std::path::Path) -> crate::error::Result<Self> {
415        let contents = std::fs::read_to_string(path)?;
416        toml::from_str(&contents).map_err(|e| crate::error::CoreError::Config(e.to_string()))
417    }
418
419    pub fn is_combined(&self) -> bool {
420        self.server.mode == "combined"
421    }
422
423    pub fn is_signer(&self) -> bool {
424        self.server.mode == "signer"
425    }
426
427    pub fn needs_signing(&self) -> bool {
428        self.is_combined() || self.is_signer()
429    }
430
431    /// Validate the actual build before binding listeners or producing bundles.
432    pub fn validate_transport(&self, tls_supported: bool) -> crate::error::Result<()> {
433        let fail = |s: &str| crate::error::CoreError::Config(s.into());
434        if (self.server.admin_tls.is_some() || self.server.metrics_tls.is_some()) && !tls_supported
435        {
436            return Err(fail(
437                "TLS configured but this binary was built without the tls feature",
438            ));
439        }
440        if self.server.admin_tls.is_some() && self.server.admin_listen.is_none() {
441            return Err(fail(
442                "admin_tls requires admin_listen; refusing plaintext management fallback",
443            ));
444        }
445        if self.server.metrics_tls.is_some() && self.server.metrics_listen.is_none() {
446            return Err(fail("metrics_tls requires metrics_listen"));
447        }
448        self.validate_for_mode()
449    }
450
451    pub fn validate_for_mode(&self) -> crate::error::Result<()> {
452        if self
453            .gossip
454            .as_ref()
455            .is_some_and(|g| g.enabled && !g.peer_keys.is_empty())
456        {
457            return Err(crate::error::CoreError::Config("replace gossip.peer_keys with peer_identities mapping node names to public-key files".into()));
458        }
459        let mut labels = std::collections::HashSet::new();
460        for ca in &self.ca {
461            if ca.label.is_empty()
462                || ca.label == "."
463                || ca.label == ".."
464                || ca.label.contains(['/', '\\'])
465                || !labels.insert(&ca.label)
466            {
467                return Err(crate::error::CoreError::Config(
468                    "CA labels must be unique nonempty file names".into(),
469                ));
470            }
471            if let Some(url) = &ca.forward_to {
472                if !url.starts_with("https://")
473                    && !(ca.forward_insecure && url.starts_with("http://"))
474                {
475                    return Err(crate::error::CoreError::Config(format!(
476                        "CA '{}': forward_to requires https:// (or explicit forward_insecure for http://)",
477                        ca.label
478                    )));
479                }
480            }
481        }
482        let valid_sig_algs = ["ecdsa-p256", "ml-dsa-44", "ml-dsa-65", "ml-dsa-87"];
483        for ca in &self.ca {
484            if !valid_sig_algs.contains(&ca.sig_alg.as_str()) {
485                return Err(crate::error::CoreError::Config(format!(
486                    "CA '{}' has invalid sig_alg '{}' — expected one of: {}",
487                    ca.label,
488                    ca.sig_alg,
489                    valid_sig_algs.join(", ")
490                )));
491            }
492            if ca.is_ml_dsa() && ca.nonce_policy == "live" {
493                return Err(crate::error::CoreError::Config(format!(
494                    "CA '{}': nonce_policy=live is not yet supported with {} signing",
495                    ca.label, ca.sig_alg
496                )));
497            }
498            let valid_certid_compat = ["dual", "sha256", "sha1"];
499            if !valid_certid_compat.contains(&ca.certid_compat.as_str()) {
500                return Err(crate::error::CoreError::Config(format!(
501                    "CA '{}' has invalid certid_compat '{}' — expected one of: {}",
502                    ca.label,
503                    ca.certid_compat,
504                    valid_certid_compat.join(", ")
505                )));
506            }
507            if !(ca.max_age_fraction > 0.0 && ca.max_age_fraction <= 1.0) {
508                return Err(crate::error::CoreError::Config(format!(
509                    "CA '{}' has invalid max_age_fraction {} — must be in the range (0, 1]",
510                    ca.label, ca.max_age_fraction
511                )));
512            }
513        }
514        if self.needs_signing() {
515            for ca in &self.ca {
516                if ca.source.is_none() {
517                    return Err(crate::error::CoreError::Config(format!(
518                        "CA '{}' has no source configured, required for {} mode",
519                        ca.label, self.server.mode
520                    )));
521                }
522                if ca.signing_key.is_none() {
523                    return Err(crate::error::CoreError::Config(format!(
524                        "CA '{}' has no signing_key configured, required for {} mode. \
525                         Use type='file' with a PKCS#8 key, type='pkcs11' for HSM, \
526                         or type='demo' for testing only.",
527                        ca.label, self.server.mode
528                    )));
529                }
530            }
531            if self.ca.is_empty() {
532                return Err(crate::error::CoreError::Config(format!(
533                    "{} mode requires at least one [[ca]] with a source",
534                    self.server.mode
535                )));
536            }
537        }
538        Ok(())
539    }
540}