1use secrecy::SecretString;
12use serde::{Deserialize, Serialize};
13use std::path::PathBuf;
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum SecretRef {
17 Env(String),
18 Keychain { service: String, account: String },
19 File(PathBuf),
20 Cmd(String),
21}
22
23#[derive(thiserror::Error, Debug)]
24pub enum SecretError {
25 #[error("env var {0} not set")]
26 EnvNotSet(String),
27 #[error("keychain item not found: {service}/{account}")]
28 KeychainNotFound { service: String, account: String },
29 #[error("keychain backend error: {0}")]
30 KeychainBackend(String),
31 #[error("read file {path}: {source}")]
32 FileRead {
33 path: String,
34 #[source]
35 source: std::io::Error,
36 },
37 #[error("file mode is not 0600: {0}")]
38 FileMode(String),
39 #[error("decrypt {0}")]
40 AgeDecrypt(String),
41 #[error("cmd {cmd} exited with {status}")]
42 Cmd { cmd: String, status: i32 },
43 #[error("invalid SecretRef syntax: {0}")]
44 Parse(String),
45}
46
47static PRESEAL_CACHE: std::sync::OnceLock<std::sync::Mutex<Vec<(SecretRef, SecretString)>>> =
65 std::sync::OnceLock::new();
66
67fn preseal_cache() -> &'static std::sync::Mutex<Vec<(SecretRef, SecretString)>> {
68 PRESEAL_CACHE.get_or_init(|| std::sync::Mutex::new(Vec::new()))
69}
70
71pub fn cache_before_seal(r: &SecretRef) -> Result<(), SecretError> {
76 let v = r.resolve_blocking()?;
77 let mut c = preseal_cache().lock().unwrap_or_else(|e| e.into_inner());
78 if !c.iter().any(|(k, _)| k == r) {
79 c.push((r.clone(), v));
80 }
81 Ok(())
82}
83
84pub fn preseal_cached_count() -> usize {
86 preseal_cache()
87 .lock()
88 .map(|c| c.len())
89 .unwrap_or_else(|e| e.into_inner().len())
90}
91
92fn preseal_lookup(r: &SecretRef) -> Option<SecretString> {
93 let c = preseal_cache().lock().unwrap_or_else(|e| e.into_inner());
94 c.iter().find(|(k, _)| k == r).map(|(_, v)| v.clone())
95}
96
97impl SecretRef {
111 pub fn label(&self) -> String {
112 match self {
113 SecretRef::Cmd(c) => {
114 let program = c.split_whitespace().next().unwrap_or("");
115 if c.split_whitespace().nth(1).is_some() {
116 format!("cmd:{program} (arguments hidden)")
117 } else {
118 format!("cmd:{program}")
119 }
120 }
121 other => other.to_string(),
122 }
123 }
124}
125
126impl std::fmt::Display for SecretRef {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 match self {
129 SecretRef::Env(v) => write!(f, "env:{v}"),
130 SecretRef::Keychain { service, account } => {
131 write!(f, "keychain:{service}/{account}")
132 }
133 SecretRef::File(p) => write!(f, "file:{}", p.display()),
134 SecretRef::Cmd(c) => write!(f, "cmd:{c}"),
135 }
136 }
137}
138
139impl std::str::FromStr for SecretRef {
140 type Err = SecretError;
141 fn from_str(s: &str) -> Result<Self, Self::Err> {
142 let (scheme, rest) = s
143 .split_once(':')
144 .ok_or_else(|| SecretError::Parse(format!("missing scheme: {s}")))?;
145 match scheme {
146 "env" => Ok(SecretRef::Env(rest.to_string())),
147 "keychain" => {
148 let (service, account) = rest.split_once('/').ok_or_else(|| {
149 SecretError::Parse(format!("keychain ref needs service/account: {s}"))
150 })?;
151 Ok(SecretRef::Keychain {
152 service: service.to_string(),
153 account: account.to_string(),
154 })
155 }
156 "file" => Ok(SecretRef::File(PathBuf::from(rest))),
157 "cmd" => Ok(SecretRef::Cmd(rest.to_string())),
158 other => Err(SecretError::Parse(format!("unknown scheme: {other}"))),
159 }
160 }
161}
162
163impl Serialize for SecretRef {
164 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
165 s.collect_str(self)
166 }
167}
168
169impl<'de> Deserialize<'de> for SecretRef {
170 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
171 let s = String::deserialize(d)?;
172 s.parse().map_err(serde::de::Error::custom)
173 }
174}
175
176pub const ENV_KEYCHAIN_DISABLED: &str = "MUR_KEYCHAIN_DISABLED";
180pub const ENV_KEYCHAIN_ALLOW: &str = "MUR_KEYCHAIN_ALLOW";
183
184fn keychain_blocked() -> bool {
190 if std::env::var_os(ENV_KEYCHAIN_ALLOW).is_some() {
191 return false;
192 }
193 std::env::var_os(ENV_KEYCHAIN_DISABLED).is_some() || std::env::var_os("NEXTEST").is_some()
194}
195
196impl SecretRef {
197 pub async fn resolve(&self) -> Result<SecretString, SecretError> {
198 match self {
199 SecretRef::Env(var) => std::env::var(var)
200 .map(SecretString::from)
201 .map_err(|_| SecretError::EnvNotSet(var.clone())),
202 SecretRef::Keychain { service, account } if keychain_blocked() => {
203 Err(SecretError::KeychainNotFound {
204 service: service.clone(),
205 account: account.clone(),
206 })
207 }
208 SecretRef::Keychain { service, account } => {
209 let svc = service.clone();
210 let acct = account.clone();
211 let res = tokio::task::spawn_blocking(move || -> Result<String, SecretError> {
212 let entry = keyring::Entry::new(&svc, &acct)
213 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
214 match entry.get_password() {
215 Ok(s) => Ok(s),
216 Err(keyring::Error::NoEntry) => Err(SecretError::KeychainNotFound {
217 service: svc.clone(),
218 account: acct.clone(),
219 }),
220 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
221 }
222 })
223 .await
224 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?;
225 res.map(SecretString::from)
226 }
227 SecretRef::File(path) => resolve_file(path).await,
228 SecretRef::Cmd(spec) => resolve_cmd(spec).await,
229 }
230 }
231
232 pub async fn check(&self) -> bool {
236 self.resolve().await.is_ok()
237 }
238
239 pub async fn resolve_to_string(&self) -> Option<String> {
245 use secrecy::ExposeSecret;
246 self.resolve()
247 .await
248 .ok()
249 .map(|s| s.expose_secret().to_string())
250 }
251
252 pub fn resolve_preseal_cached(&self) -> Option<SecretString> {
264 preseal_lookup(self)
265 }
266
267 pub fn resolve_blocking(&self) -> Result<SecretString, SecretError> {
268 if let Some(v) = preseal_lookup(self) {
273 return Ok(v);
274 }
275 fn fresh_runtime_resolve(r: &SecretRef) -> Result<SecretString, SecretError> {
276 tokio::runtime::Builder::new_current_thread()
277 .enable_all()
278 .build()
279 .map_err(|e| SecretError::KeychainBackend(format!("runtime: {e}")))?
280 .block_on(r.resolve())
281 }
282 match tokio::runtime::Handle::try_current() {
283 Ok(h) if h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
284 tokio::task::block_in_place(|| h.block_on(self.resolve()))
285 }
286 Ok(_) => std::thread::scope(|s| {
289 s.spawn(|| fresh_runtime_resolve(self))
290 .join()
291 .unwrap_or_else(|_| {
292 Err(SecretError::KeychainBackend(
293 "resolver thread panicked".into(),
294 ))
295 })
296 }),
297 Err(_) => fresh_runtime_resolve(self),
298 }
299 }
300
301 pub fn resolve_to_string_blocking(&self) -> Option<String> {
304 use secrecy::ExposeSecret;
305 self.resolve_blocking()
306 .ok()
307 .map(|s| s.expose_secret().to_string())
308 }
309}
310
311pub async fn keychain_get(
322 service: &str,
323 account: &str,
324) -> Result<Option<SecretString>, SecretError> {
325 if keychain_blocked() {
326 return Ok(None);
327 }
328 let svc = service.to_string();
329 let acct = account.to_string();
330 tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
331 let entry = keyring::Entry::new(&svc, &acct)
332 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
333 match entry.get_password() {
334 Ok(s) => Ok(Some(s)),
335 Err(keyring::Error::NoEntry) => Ok(None),
336 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
337 }
338 })
339 .await
340 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
341 .map(|opt| opt.map(SecretString::from))
342}
343
344#[cfg(target_os = "macos")]
347const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300;
348
349pub fn keychain_item_exists(service: &str, account: &str) -> Result<bool, SecretError> {
360 if keychain_blocked() {
361 return Ok(false);
362 }
363 #[cfg(target_os = "macos")]
364 {
365 use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
366 let found = ItemSearchOptions::new()
367 .class(ItemClass::generic_password())
368 .service(service)
369 .account(account)
370 .load_attributes(true)
371 .load_data(false)
372 .limit(Limit::Max(1))
373 .search();
374 match found {
375 Ok(items) => Ok(!items.is_empty()),
376 Err(e) if e.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(false),
377 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
378 }
379 }
380 #[cfg(not(target_os = "macos"))]
381 {
382 let entry = keyring::Entry::new(service, account)
383 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
384 match entry.get_password() {
385 Ok(_) => Ok(true),
386 Err(keyring::Error::NoEntry) => Ok(false),
387 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
388 }
389 }
390}
391
392pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
395 if keychain_blocked() {
396 return Err(SecretError::KeychainBackend(format!(
397 "keychain access disabled in this process ({ENV_KEYCHAIN_DISABLED}/test); \
398 set {ENV_KEYCHAIN_ALLOW}=1 to override"
399 )));
400 }
401 let svc = service.to_string();
402 let acct = account.to_string();
403 let val = value.to_string();
404 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
405 let entry = keyring::Entry::new(&svc, &acct)
406 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
407 entry
408 .set_password(&val)
409 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
410 Ok(())
411 })
412 .await
413 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
414}
415
416pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
419 if keychain_blocked() {
420 return Ok(());
421 }
422 let svc = service.to_string();
423 let acct = account.to_string();
424 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
425 let entry = keyring::Entry::new(&svc, &acct)
426 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
427 match entry.delete_credential() {
428 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
429 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
430 }
431 })
432 .await
433 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
434}
435
436async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
437 let mut parts = shell_words::split(spec)
438 .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
439 if parts.is_empty() {
440 return Err(SecretError::Parse("empty cmd".into()));
441 }
442 let program = parts.remove(0);
443 let output = tokio::process::Command::new(&program)
444 .args(&parts)
445 .output()
446 .await
447 .map_err(|e| SecretError::Cmd {
448 cmd: format!("{spec} ({e})"),
449 status: -1,
450 })?;
451 if !output.status.success() {
452 return Err(SecretError::Cmd {
453 cmd: spec.to_string(),
454 status: output.status.code().unwrap_or(-1),
455 });
456 }
457 let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
458 cmd: format!("{spec} (non-utf8 stdout: {e})"),
459 status: -2,
460 })?;
461 Ok(SecretString::from(
462 s.trim_end_matches(['\n', '\r']).to_string(),
463 ))
464}
465
466async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
467 let expanded = shellexpand::full(&path.to_string_lossy())
468 .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
469 .to_string();
470 let p = std::path::PathBuf::from(expanded);
471
472 #[cfg(unix)]
473 {
474 use std::os::unix::fs::PermissionsExt;
475 let meta = tokio::fs::metadata(&p)
476 .await
477 .map_err(|e| SecretError::FileRead {
478 path: p.display().to_string(),
479 source: e,
480 })?;
481 let mode = meta.permissions().mode() & 0o777;
482 if mode & 0o077 != 0 {
483 return Err(SecretError::FileMode(format!(
484 "{}: mode {:o} grants group/world access",
485 p.display(),
486 mode
487 )));
488 }
489 }
490
491 let bytes = tokio::fs::read(&p)
492 .await
493 .map_err(|e| SecretError::FileRead {
494 path: p.display().to_string(),
495 source: e,
496 })?;
497
498 let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
499 decrypt_age(&bytes).await?
500 } else {
501 String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
502 };
503 let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
504 Ok(SecretString::from(trimmed))
505}
506
507async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
508 let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
509 Ok(p) => std::path::PathBuf::from(p),
510 Err(_) => dirs::home_dir()
511 .ok_or_else(|| {
512 SecretError::AgeDecrypt(
513 "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
514 )
515 })?
516 .join(".mur/age/identity.txt"),
517 };
518
519 let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
520 SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
521 })?;
522 let identity: age::x25519::Identity = id_str
523 .trim()
524 .parse()
525 .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
526
527 let decryptor =
528 age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
529 let mut reader = decryptor
530 .decrypt(std::iter::once(&identity as &dyn age::Identity))
531 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
532 let mut out = String::new();
533 use std::io::Read;
534 reader
535 .read_to_string(&mut out)
536 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
537 Ok(out)
538}
539
540#[cfg(test)]
541mod tests;
542
543#[cfg(test)]
544mod resolve_env_tests;
545
546#[cfg(test)]
547mod keychain_test_fixture;
548
549#[cfg(test)]
550mod resolve_keychain_tests;
551
552#[cfg(all(test, unix))]
553mod resolve_file_tests {
554 use super::*;
555 use secrecy::ExposeSecret;
556 use std::os::unix::fs::PermissionsExt;
557 use tempfile::tempdir;
558
559 #[tokio::test]
560 async fn reads_plaintext_0600() {
561 let dir = tempdir().unwrap();
562 let p = dir.path().join("k.txt");
563 std::fs::write(&p, "abc\n").unwrap();
564 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
565 let s = SecretRef::File(p);
566 let v = s.resolve().await.unwrap();
567 assert_eq!(v.expose_secret(), "abc"); }
569
570 #[tokio::test]
571 async fn rejects_world_readable() {
572 let dir = tempdir().unwrap();
573 let p = dir.path().join("k.txt");
574 std::fs::write(&p, "abc").unwrap();
575 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
576 let s = SecretRef::File(p);
577 let err = s.resolve().await.unwrap_err();
578 assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
579 }
580
581 #[tokio::test]
582 async fn decrypts_age_recipient_file() {
583 let dir = tempdir().unwrap();
584 let identity = age::x25519::Identity::generate();
585 let recipient = identity.to_public();
586 let payload = b"shh-from-age";
587
588 let mut encrypted: Vec<u8> = Vec::new();
589 let encryptor =
590 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
591 .unwrap();
592 let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
593 std::io::Write::write_all(&mut writer, payload).unwrap();
594 writer.finish().unwrap();
595
596 let enc_path = dir.path().join("k.age");
597 std::fs::write(&enc_path, &encrypted).unwrap();
598 std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
599 let id_path = dir.path().join("identity.txt");
600 use secrecy::ExposeSecret as _;
601 std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
602 std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
603 let _env = crate::test_env::EnvGuard::set([("MUR_AGE_IDENTITY_PATH", &id_path)]);
604 let s = SecretRef::File(enc_path);
605 let v = s.resolve().await.unwrap();
606 assert_eq!(v.expose_secret(), "shh-from-age");
607 }
608}
609
610#[cfg(all(test, unix))]
611mod resolve_cmd_tests {
612 use super::*;
613 use secrecy::ExposeSecret;
614
615 #[tokio::test]
616 async fn echoes_stdout() {
617 let s = SecretRef::Cmd("printf shh-from-cmd".into());
618 let v = s.resolve().await.unwrap();
619 assert_eq!(v.expose_secret(), "shh-from-cmd");
620 }
621
622 #[tokio::test]
623 async fn errors_on_non_zero_exit() {
624 let s = SecretRef::Cmd("sh -c 'exit 7'".into());
625 let err = s.resolve().await.unwrap_err();
626 match err {
627 SecretError::Cmd { status, .. } => assert_eq!(status, 7),
628 other => panic!("unexpected: {other:?}"),
629 }
630 }
631}
632
633#[cfg(test)]
634mod check_tests;
635
636#[cfg(test)]
637mod keychain_helpers_tests;
638
639#[cfg(test)]
640mod resolve_blocking_tests;