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