1use 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#[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#[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#[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
120pub 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 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 {
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 {
245 let mut cache = self.cache.write();
246 cache.insert(field.to_string(), value.clone());
247 }
248
249 Ok(value)
250 }
251
252 #[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 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 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 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#[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 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 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 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}