Skip to main content

kipuka/config/
secret.rs

1//! Unified secret management — no plaintext credentials in deployed config.
2//!
3//! All config fields that hold secrets use [`SecretRef`], a serde-transparent
4//! newtype that encodes the resolution backend via a URI-like prefix:
5//!
6//! | Prefix | Backend |
7//! |--------|---------|
8//! | `env:VAR` | Environment variable |
9//! | `file:/path` | File contents (trimmed) |
10//! | `prompt:Label` | Interactive terminal input |
11//! | `keyring:name` | Linux kernel keyring |
12//! | `systemd-creds:name` | systemd `LoadCredential=` |
13//! | (none) | Literal — logs a warning |
14
15use std::collections::HashMap;
16use std::io::IsTerminal;
17use std::sync::Arc;
18
19use parking_lot::RwLock;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22use tracing::{debug, warn};
23
24/// A reference to a secret in a config file.
25///
26/// Deserializes transparently from a TOML string. The prefix selects the
27/// resolution backend; prefix-free values are treated as literals with a
28/// startup warning.
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30#[serde(transparent)]
31pub struct SecretRef(String);
32
33impl From<&str> for SecretRef {
34    fn from(s: &str) -> Self {
35        Self(s.to_string())
36    }
37}
38
39impl PartialEq<&str> for SecretRef {
40    fn eq(&self, other: &&str) -> bool {
41        self.0 == *other
42    }
43}
44
45impl SecretRef {
46    pub fn backend(&self) -> SecretBackend {
47        let s = &self.0;
48        if let Some(var) = s.strip_prefix("env:") {
49            SecretBackend::Env(var.to_string())
50        } else if let Some(path) = s.strip_prefix("file:") {
51            SecretBackend::File(path.to_string())
52        } else if let Some(label) = s.strip_prefix("prompt:") {
53            SecretBackend::Prompt(label.to_string())
54        } else if let Some(key) = s.strip_prefix("keyring:") {
55            SecretBackend::Keyring(key.to_string())
56        } else if let Some(name) = s.strip_prefix("systemd-creds:") {
57            SecretBackend::SystemdCreds(name.to_string())
58        } else {
59            SecretBackend::Literal(s.to_string())
60        }
61    }
62
63    pub fn is_empty(&self) -> bool {
64        self.0.is_empty()
65    }
66
67    pub fn raw(&self) -> &str {
68        &self.0
69    }
70}
71
72/// Resolution backend for a single secret.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum SecretBackend {
75    Env(String),
76    File(String),
77    Prompt(String),
78    Keyring(String),
79    SystemdCreds(String),
80    Literal(String),
81}
82
83/// Errors during secret resolution.
84#[derive(Debug, Error)]
85pub enum SecretError {
86    #[error("{field}: environment variable {var:?} is not set")]
87    EnvNotSet { field: String, var: String },
88
89    #[error("{field}: secret file {path:?} not found")]
90    FileNotFound { field: String, path: String },
91
92    #[error("{field}: secret file {path:?} read error: {source}")]
93    FileRead {
94        field: String,
95        path: String,
96        source: std::io::Error,
97    },
98
99    #[error("{field}: no TTY available for interactive prompt; use env: or file: instead")]
100    NoTty { field: String },
101
102    #[error("{field}: interactive prompt failed: {reason}")]
103    PromptFailed { field: String, reason: String },
104
105    #[error("{field}: kernel keyring lookup failed for {key:?}: {reason}")]
106    KeyringFailed {
107        field: String,
108        key: String,
109        reason: String,
110    },
111
112    #[error("{field}: systemd credential {name:?} not found at {path}")]
113    SystemdCredNotFound {
114        field: String,
115        name: String,
116        path: String,
117    },
118}
119
120/// Resolves [`SecretRef`] values at startup and caches results in memory.
121///
122/// Never writes secrets to disk. Detects whether stdin is a TTY to gate
123/// the `prompt:` backend.
124pub struct SecretResolver {
125    cache: Arc<RwLock<HashMap<String, String>>>,
126    interactive: bool,
127}
128
129impl Default for SecretResolver {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135impl SecretResolver {
136    pub fn new() -> Self {
137        Self {
138            cache: Arc::new(RwLock::new(HashMap::new())),
139            interactive: std::io::stdin().is_terminal(),
140        }
141    }
142
143    pub fn is_interactive(&self) -> bool {
144        self.interactive
145    }
146
147    /// Resolve a single secret by config field name.
148    pub fn resolve(&self, field: &str, secret_ref: &SecretRef) -> Result<String, SecretError> {
149        if secret_ref.is_empty() {
150            return Ok(String::new());
151        }
152
153        // Check cache
154        {
155            let cache = self.cache.read();
156            if let Some(val) = cache.get(field) {
157                return Ok(val.clone());
158            }
159        }
160
161        let value = match secret_ref.backend() {
162            SecretBackend::Env(var) => {
163                if var.is_empty() {
164                    return Err(SecretError::EnvNotSet {
165                        field: field.to_string(),
166                        var: "(empty — env: prefix requires a variable name)".to_string(),
167                    });
168                }
169                debug!(field = field, var = %var, "resolving secret from environment");
170                std::env::var(&var).map_err(|_| SecretError::EnvNotSet {
171                    field: field.to_string(),
172                    var,
173                })?
174            }
175            SecretBackend::File(path) => {
176                if path.is_empty() {
177                    return Err(SecretError::FileNotFound {
178                        field: field.to_string(),
179                        path: "(empty — file: prefix requires a path)".to_string(),
180                    });
181                }
182                debug!(field = field, path = %path, "resolving secret from file");
183                std::fs::read_to_string(&path)
184                    .map_err(|e| {
185                        if e.kind() == std::io::ErrorKind::NotFound {
186                            SecretError::FileNotFound {
187                                field: field.to_string(),
188                                path: path.clone(),
189                            }
190                        } else {
191                            SecretError::FileRead {
192                                field: field.to_string(),
193                                path: path.clone(),
194                                source: e,
195                            }
196                        }
197                    })?
198                    .trim_end()
199                    .to_string()
200            }
201            SecretBackend::Prompt(label) => {
202                if !self.interactive {
203                    return Err(SecretError::NoTty {
204                        field: field.to_string(),
205                    });
206                }
207                rpassword::prompt_password(format!("Enter {label}: ")).map_err(|e| {
208                    SecretError::PromptFailed {
209                        field: field.to_string(),
210                        reason: e.to_string(),
211                    }
212                })?
213            }
214            SecretBackend::Keyring(key) => {
215                debug!(field = field, key = %key, "resolving secret from kernel keyring");
216                Self::read_keyring(field, &key)?
217            }
218            SecretBackend::SystemdCreds(name) => {
219                let creds_dir = std::env::var("CREDENTIALS_DIRECTORY")
220                    .unwrap_or_else(|_| "/run/credentials/kipuka.service".to_string());
221                let path = format!("{creds_dir}/{name}");
222                debug!(field = field, path = %path, "resolving secret from systemd credentials");
223                std::fs::read_to_string(&path)
224                    .map_err(|_| SecretError::SystemdCredNotFound {
225                        field: field.to_string(),
226                        name,
227                        path,
228                    })?
229                    .trim_end()
230                    .to_string()
231            }
232            SecretBackend::Literal(val) => {
233                if !val.is_empty() && !val.starts_with("sqlite:") {
234                    warn!(
235                        field = field,
236                        "secret stored as plaintext in config; use env:, file:, or prompt: prefix in production"
237                    );
238                }
239                val
240            }
241        };
242
243        // Cache
244        {
245            let mut cache = self.cache.write();
246            cache.insert(field.to_string(), value.clone());
247        }
248
249        Ok(value)
250    }
251
252    /// Store a secret in the Linux kernel keyring for restart persistence.
253    #[cfg(target_os = "linux")]
254    pub fn store_keyring(key: &str, value: &str) -> Result<(), SecretError> {
255        use std::process::Command;
256
257        let status = Command::new("keyctl")
258            .args(["padd", "user", key, "@s"])
259            .stdin(std::process::Stdio::piped())
260            .stdout(std::process::Stdio::null())
261            .stderr(std::process::Stdio::null())
262            .spawn()
263            .and_then(|mut child| {
264                use std::io::Write;
265                if let Some(ref mut stdin) = child.stdin {
266                    stdin.write_all(value.as_bytes())?;
267                }
268                child.wait()
269            });
270
271        match status {
272            Ok(s) if s.success() => {
273                debug!(key = key, "stored secret in kernel keyring");
274                Ok(())
275            }
276            Ok(s) => Err(SecretError::KeyringFailed {
277                field: key.to_string(),
278                key: key.to_string(),
279                reason: format!("keyctl exited with {s}"),
280            }),
281            Err(e) => Err(SecretError::KeyringFailed {
282                field: key.to_string(),
283                key: key.to_string(),
284                reason: format!("failed to run keyctl: {e}"),
285            }),
286        }
287    }
288
289    #[cfg(not(target_os = "linux"))]
290    pub fn store_keyring(key: &str, _value: &str) -> Result<(), SecretError> {
291        Err(SecretError::KeyringFailed {
292            field: key.to_string(),
293            key: key.to_string(),
294            reason: "kernel keyring is only available on Linux".to_string(),
295        })
296    }
297
298    #[cfg(target_os = "linux")]
299    fn read_keyring(field: &str, key: &str) -> Result<String, SecretError> {
300        use std::process::Command;
301
302        let output = Command::new("keyctl")
303            .args(["pipe", &format!("%user:{key}")])
304            .output()
305            .map_err(|e| SecretError::KeyringFailed {
306                field: field.to_string(),
307                key: key.to_string(),
308                reason: format!("failed to run keyctl: {e}"),
309            })?;
310
311        if !output.status.success() {
312            return Err(SecretError::KeyringFailed {
313                field: field.to_string(),
314                key: key.to_string(),
315                reason: "key not found in session keyring".to_string(),
316            });
317        }
318
319        String::from_utf8(output.stdout).map_err(|e| SecretError::KeyringFailed {
320            field: field.to_string(),
321            key: key.to_string(),
322            reason: format!("keyring value is not valid UTF-8: {e}"),
323        })
324    }
325
326    #[cfg(not(target_os = "linux"))]
327    fn read_keyring(field: &str, key: &str) -> Result<String, SecretError> {
328        Err(SecretError::KeyringFailed {
329            field: field.to_string(),
330            key: key.to_string(),
331            reason: "kernel keyring is only available on Linux".to_string(),
332        })
333    }
334
335    /// Resolve all configured secrets and return a bundle.
336    pub fn resolve_config(&self, config: &super::Config) -> Result<ResolvedSecrets, SecretError> {
337        let db_url = self.resolve("database.url", &config.database.url)?;
338
339        let hsm_pin = if let Some(ref hsm) = config.hsm {
340            if hsm.pin.is_empty() {
341                None
342            } else {
343                Some(self.resolve("hsm.pin", &hsm.pin)?)
344            }
345        } else {
346            None
347        };
348
349        let admin_bearer_token = if let Some(ref admin) = config.admin {
350            if let Some(ref token_ref) = admin.bearer_token {
351                Some(self.resolve("admin.bearer_token", token_ref)?)
352            } else {
353                None
354            }
355        } else {
356            None
357        };
358
359        let auditor_bearer_token = if let Some(ref admin) = config.admin {
360            if let Some(ref token_ref) = admin.auditor_bearer_token {
361                Some(self.resolve("admin.auditor_bearer_token", token_ref)?)
362            } else {
363                None
364            }
365        } else {
366            None
367        };
368
369        let ldap_bind_password = if config.otp.enabled {
370            if let Some(ref ldap) = config.otp.ldap {
371                if ldap.bind_password.is_empty() {
372                    None
373                } else {
374                    Some(self.resolve("otp.ldap.bind_password", &ldap.bind_password)?)
375                }
376            } else {
377                None
378            }
379        } else {
380            None
381        };
382
383        let mut cmp_mac_secrets = HashMap::new();
384        if let Some(ref cmp) = config.cmp {
385            for secret in &cmp.mac_secrets {
386                let resolved = self.resolve(
387                    &format!("cmp.mac_secrets.{}", secret.reference),
388                    &secret.secret_hex,
389                )?;
390                cmp_mac_secrets.insert(secret.reference.clone(), resolved);
391            }
392        }
393
394        // Keyed audit-chain integrity (FAU_STG.1); resolved only when the
395        // operator configured `[audit].integrity_key`.  `AuditConfig::validate`
396        // has already enforced that a key is present whenever `signed = true`.
397        let audit_integrity_key = match &config.audit.integrity_key {
398            Some(key_ref) if !key_ref.is_empty() => {
399                Some(self.resolve("audit.integrity_key", key_ref)?)
400            }
401            _ => None,
402        };
403
404        Ok(ResolvedSecrets {
405            db_url,
406            hsm_pin,
407            admin_bearer_token,
408            auditor_bearer_token,
409            ldap_bind_password,
410            cmp_mac_secrets,
411            audit_integrity_key,
412        })
413    }
414
415    /// Persist all resolved secrets to the kernel keyring.
416    pub fn persist_to_keyring(&self) {
417        let cache = self.cache.read();
418        for (field, val) in cache.iter() {
419            let key = format!("kipuka/{}", field.replace('.', "/"));
420            if let Err(e) = Self::store_keyring(&key, val) {
421                warn!(field = field, error = %e, "failed to persist secret to keyring");
422            }
423        }
424    }
425}
426
427/// All resolved secrets for the running instance. Never serialized to disk.
428#[derive(Clone)]
429pub struct ResolvedSecrets {
430    pub db_url: String,
431    pub hsm_pin: Option<String>,
432    pub admin_bearer_token: Option<String>,
433    pub auditor_bearer_token: Option<String>,
434    pub ldap_bind_password: Option<String>,
435    pub cmp_mac_secrets: HashMap<String, String>,
436    /// HMAC key for keyed audit-chain integrity (`[audit].signed = true`).
437    pub audit_integrity_key: Option<String>,
438}
439
440impl std::fmt::Debug for ResolvedSecrets {
441    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442        f.debug_struct("ResolvedSecrets")
443            .field("db_url", &"<redacted>")
444            .field("hsm_pin", &self.hsm_pin.as_ref().map(|_| "<redacted>"))
445            .field(
446                "admin_bearer_token",
447                &self.admin_bearer_token.as_ref().map(|_| "<redacted>"),
448            )
449            .field(
450                "auditor_bearer_token",
451                &self.auditor_bearer_token.as_ref().map(|_| "<redacted>"),
452            )
453            .field(
454                "ldap_bind_password",
455                &self.ldap_bind_password.as_ref().map(|_| "<redacted>"),
456            )
457            .field(
458                "cmp_mac_secrets",
459                &format!("{} entries", self.cmp_mac_secrets.len()),
460            )
461            .field(
462                "audit_integrity_key",
463                &self.audit_integrity_key.as_ref().map(|_| "<redacted>"),
464            )
465            .finish()
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn parse_env_prefix() {
475        let r = SecretRef("env:MY_VAR".into());
476        assert_eq!(r.backend(), SecretBackend::Env("MY_VAR".into()));
477    }
478
479    #[test]
480    fn parse_file_prefix() {
481        let r = SecretRef("file:/run/secrets/db-pass".into());
482        assert_eq!(
483            r.backend(),
484            SecretBackend::File("/run/secrets/db-pass".into())
485        );
486    }
487
488    #[test]
489    fn parse_prompt_prefix() {
490        let r = SecretRef("prompt:HSM PIN".into());
491        assert_eq!(r.backend(), SecretBackend::Prompt("HSM PIN".into()));
492    }
493
494    #[test]
495    fn parse_keyring_prefix() {
496        let r = SecretRef("keyring:kipuka/hsm-pin".into());
497        assert_eq!(r.backend(), SecretBackend::Keyring("kipuka/hsm-pin".into()));
498    }
499
500    #[test]
501    fn parse_systemd_creds_prefix() {
502        let r = SecretRef("systemd-creds:hsm-pin".into());
503        assert_eq!(r.backend(), SecretBackend::SystemdCreds("hsm-pin".into()));
504    }
505
506    #[test]
507    fn parse_literal_no_prefix() {
508        let r = SecretRef("plaintext-value".into());
509        assert_eq!(
510            r.backend(),
511            SecretBackend::Literal("plaintext-value".into())
512        );
513    }
514
515    #[test]
516    fn resolve_env_backend() {
517        // SAFETY: env var name is unique to this test; no other test uses it.
518        unsafe { std::env::set_var("KIPUKA_TEST_SECRET", "test-value-42") };
519        let resolver = SecretResolver::new();
520        let r = SecretRef("env:KIPUKA_TEST_SECRET".into());
521        let val = resolver.resolve("test.field", &r).unwrap();
522        assert_eq!(val, "test-value-42");
523        unsafe { std::env::remove_var("KIPUKA_TEST_SECRET") };
524    }
525
526    #[test]
527    fn resolve_env_missing() {
528        let resolver = SecretResolver::new();
529        let r = SecretRef("env:KIPUKA_NONEXISTENT_VAR_XYZ".into());
530        let err = resolver.resolve("test.field", &r).unwrap_err();
531        assert!(err.to_string().contains("is not set"));
532    }
533
534    #[test]
535    fn resolve_file_backend() {
536        let dir = tempfile::tempdir().unwrap();
537        let path = dir.path().join("test-secret");
538        std::fs::write(&path, "file-secret-value\n").unwrap();
539
540        let resolver = SecretResolver::new();
541        let r = SecretRef(format!("file:{}", path.display()));
542        let val = resolver.resolve("test.field", &r).unwrap();
543        assert_eq!(val, "file-secret-value");
544    }
545
546    #[test]
547    fn resolve_caches_value() {
548        // SAFETY: env var name is unique to this test; no other test uses it.
549        unsafe { std::env::set_var("KIPUKA_CACHE_TEST", "cached") };
550        let resolver = SecretResolver::new();
551        let r = SecretRef("env:KIPUKA_CACHE_TEST".into());
552
553        let val1 = resolver.resolve("cache.test", &r).unwrap();
554        unsafe { std::env::remove_var("KIPUKA_CACHE_TEST") };
555        let val2 = resolver.resolve("cache.test", &r).unwrap();
556
557        assert_eq!(val1, "cached");
558        assert_eq!(val2, "cached");
559    }
560}