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
47impl std::fmt::Display for SecretRef {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match self {
50 SecretRef::Env(v) => write!(f, "env:{v}"),
51 SecretRef::Keychain { service, account } => {
52 write!(f, "keychain:{service}/{account}")
53 }
54 SecretRef::File(p) => write!(f, "file:{}", p.display()),
55 SecretRef::Cmd(c) => write!(f, "cmd:{c}"),
56 }
57 }
58}
59
60impl std::str::FromStr for SecretRef {
61 type Err = SecretError;
62 fn from_str(s: &str) -> Result<Self, Self::Err> {
63 let (scheme, rest) = s
64 .split_once(':')
65 .ok_or_else(|| SecretError::Parse(format!("missing scheme: {s}")))?;
66 match scheme {
67 "env" => Ok(SecretRef::Env(rest.to_string())),
68 "keychain" => {
69 let (service, account) = rest.split_once('/').ok_or_else(|| {
70 SecretError::Parse(format!("keychain ref needs service/account: {s}"))
71 })?;
72 Ok(SecretRef::Keychain {
73 service: service.to_string(),
74 account: account.to_string(),
75 })
76 }
77 "file" => Ok(SecretRef::File(PathBuf::from(rest))),
78 "cmd" => Ok(SecretRef::Cmd(rest.to_string())),
79 other => Err(SecretError::Parse(format!("unknown scheme: {other}"))),
80 }
81 }
82}
83
84impl Serialize for SecretRef {
85 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
86 s.collect_str(self)
87 }
88}
89
90impl<'de> Deserialize<'de> for SecretRef {
91 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
92 let s = String::deserialize(d)?;
93 s.parse().map_err(serde::de::Error::custom)
94 }
95}
96
97impl SecretRef {
98 pub async fn resolve(&self) -> Result<SecretString, SecretError> {
99 match self {
100 SecretRef::Env(var) => std::env::var(var)
101 .map(SecretString::from)
102 .map_err(|_| SecretError::EnvNotSet(var.clone())),
103 SecretRef::Keychain { service, account } => {
104 let svc = service.clone();
105 let acct = account.clone();
106 let res = tokio::task::spawn_blocking(move || -> Result<String, SecretError> {
107 let entry = keyring::Entry::new(&svc, &acct)
108 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
109 match entry.get_password() {
110 Ok(s) => Ok(s),
111 Err(keyring::Error::NoEntry) => Err(SecretError::KeychainNotFound {
112 service: svc.clone(),
113 account: acct.clone(),
114 }),
115 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
116 }
117 })
118 .await
119 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?;
120 res.map(SecretString::from)
121 }
122 SecretRef::File(path) => resolve_file(path).await,
123 SecretRef::Cmd(spec) => resolve_cmd(spec).await,
124 }
125 }
126
127 pub async fn check(&self) -> bool {
131 self.resolve().await.is_ok()
132 }
133
134 pub async fn resolve_to_string(&self) -> Option<String> {
140 use secrecy::ExposeSecret;
141 self.resolve()
142 .await
143 .ok()
144 .map(|s| s.expose_secret().to_string())
145 }
146
147 pub fn resolve_blocking(&self) -> Result<SecretString, SecretError> {
151 match tokio::runtime::Handle::try_current() {
152 Ok(h) => tokio::task::block_in_place(|| h.block_on(self.resolve())),
153 Err(_) => tokio::runtime::Builder::new_current_thread()
154 .enable_all()
155 .build()
156 .map_err(|e| SecretError::KeychainBackend(format!("runtime: {e}")))?
157 .block_on(self.resolve()),
158 }
159 }
160
161 pub fn resolve_to_string_blocking(&self) -> Option<String> {
164 use secrecy::ExposeSecret;
165 self.resolve_blocking()
166 .ok()
167 .map(|s| s.expose_secret().to_string())
168 }
169}
170
171pub async fn keychain_get(
182 service: &str,
183 account: &str,
184) -> Result<Option<SecretString>, SecretError> {
185 let svc = service.to_string();
186 let acct = account.to_string();
187 tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
188 let entry = keyring::Entry::new(&svc, &acct)
189 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
190 match entry.get_password() {
191 Ok(s) => Ok(Some(s)),
192 Err(keyring::Error::NoEntry) => Ok(None),
193 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
194 }
195 })
196 .await
197 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
198 .map(|opt| opt.map(SecretString::from))
199}
200
201pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
204 let svc = service.to_string();
205 let acct = account.to_string();
206 let val = value.to_string();
207 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
208 let entry = keyring::Entry::new(&svc, &acct)
209 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
210 entry
211 .set_password(&val)
212 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
213 Ok(())
214 })
215 .await
216 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
217}
218
219pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
222 let svc = service.to_string();
223 let acct = account.to_string();
224 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
225 let entry = keyring::Entry::new(&svc, &acct)
226 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
227 match entry.delete_credential() {
228 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
229 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
230 }
231 })
232 .await
233 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
234}
235
236async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
237 let mut parts = shell_words::split(spec)
238 .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
239 if parts.is_empty() {
240 return Err(SecretError::Parse("empty cmd".into()));
241 }
242 let program = parts.remove(0);
243 let output = tokio::process::Command::new(&program)
244 .args(&parts)
245 .output()
246 .await
247 .map_err(|e| SecretError::Cmd {
248 cmd: format!("{spec} ({e})"),
249 status: -1,
250 })?;
251 if !output.status.success() {
252 return Err(SecretError::Cmd {
253 cmd: spec.to_string(),
254 status: output.status.code().unwrap_or(-1),
255 });
256 }
257 let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
258 cmd: format!("{spec} (non-utf8 stdout: {e})"),
259 status: -2,
260 })?;
261 Ok(SecretString::from(
262 s.trim_end_matches(['\n', '\r']).to_string(),
263 ))
264}
265
266async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
267 let expanded = shellexpand::full(&path.to_string_lossy())
268 .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
269 .to_string();
270 let p = std::path::PathBuf::from(expanded);
271
272 #[cfg(unix)]
273 {
274 use std::os::unix::fs::PermissionsExt;
275 let meta = tokio::fs::metadata(&p)
276 .await
277 .map_err(|e| SecretError::FileRead {
278 path: p.display().to_string(),
279 source: e,
280 })?;
281 let mode = meta.permissions().mode() & 0o777;
282 if mode & 0o077 != 0 {
283 return Err(SecretError::FileMode(format!(
284 "{}: mode {:o} grants group/world access",
285 p.display(),
286 mode
287 )));
288 }
289 }
290
291 let bytes = tokio::fs::read(&p)
292 .await
293 .map_err(|e| SecretError::FileRead {
294 path: p.display().to_string(),
295 source: e,
296 })?;
297
298 let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
299 decrypt_age(&bytes).await?
300 } else {
301 String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
302 };
303 let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
304 Ok(SecretString::from(trimmed))
305}
306
307async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
308 let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
309 Ok(p) => std::path::PathBuf::from(p),
310 Err(_) => dirs::home_dir()
311 .ok_or_else(|| {
312 SecretError::AgeDecrypt(
313 "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
314 )
315 })?
316 .join(".mur/age/identity.txt"),
317 };
318
319 let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
320 SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
321 })?;
322 let identity: age::x25519::Identity = id_str
323 .trim()
324 .parse()
325 .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
326
327 let decryptor =
328 age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
329 let mut reader = decryptor
330 .decrypt(std::iter::once(&identity as &dyn age::Identity))
331 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
332 let mut out = String::new();
333 use std::io::Read;
334 reader
335 .read_to_string(&mut out)
336 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
337 Ok(out)
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use serde_yaml_ng as yaml;
344
345 #[test]
346 fn parses_env_form() {
347 let s: SecretRef = yaml::from_str("env:ANTHROPIC_API_KEY").unwrap();
348 assert_eq!(s, SecretRef::Env("ANTHROPIC_API_KEY".into()));
349 }
350
351 #[test]
352 fn parses_keychain_form() {
353 let s: SecretRef = yaml::from_str("keychain:mur/anthropic-oauth").unwrap();
354 assert_eq!(
355 s,
356 SecretRef::Keychain {
357 service: "mur".into(),
358 account: "anthropic-oauth".into()
359 }
360 );
361 }
362
363 #[test]
364 fn parses_file_form() {
365 let s: SecretRef = yaml::from_str("file:/tmp/foo.age").unwrap();
366 assert_eq!(s, SecretRef::File(PathBuf::from("/tmp/foo.age")));
367 }
368
369 #[test]
370 fn parses_cmd_form() {
371 let s: SecretRef = yaml::from_str("cmd:op read op://vault/item/field").unwrap();
372 assert_eq!(s, SecretRef::Cmd("op read op://vault/item/field".into()));
373 }
374
375 #[test]
376 fn rejects_unknown_scheme() {
377 let r: Result<SecretRef, _> = yaml::from_str("plain:supersecret");
378 assert!(r.is_err());
379 }
380
381 #[test]
382 fn round_trip_serde() {
383 let cases = [
384 "env:X",
385 "keychain:svc/acct",
386 "file:/p",
387 "cmd:bin --flag arg",
388 ];
389 for s in cases {
390 let parsed: SecretRef = yaml::from_str(s).unwrap();
391 let back = yaml::to_string(&parsed).unwrap();
392 let normalized = back
394 .trim()
395 .trim_matches(|c: char| c == '"' || c == '\'')
396 .to_string();
397 let reparsed: SecretRef = yaml::from_str(&normalized).unwrap();
398 assert_eq!(parsed, reparsed, "round-trip drift for {s}");
399 }
400 }
401}
402
403#[cfg(test)]
404mod resolve_env_tests {
405 use super::*;
406 use secrecy::ExposeSecret;
407
408 #[tokio::test]
409 async fn resolves_env_when_set() {
410 unsafe {
412 std::env::set_var("MUR_TEST_RESOLVE_ENV", "shhh");
413 }
414 let s = SecretRef::Env("MUR_TEST_RESOLVE_ENV".into());
415 let v = s.resolve().await.unwrap();
416 assert_eq!(v.expose_secret(), "shhh");
417 }
418
419 #[tokio::test]
420 async fn errors_when_env_missing() {
421 let s = SecretRef::Env("MUR_TEST_DEFINITELY_UNSET".into());
422 let err = s.resolve().await.unwrap_err();
423 assert!(matches!(err, SecretError::EnvNotSet(_)), "got {err:?}");
424 }
425
426 #[tokio::test]
427 async fn resolve_to_string_exposes_value_or_none() {
428 unsafe {
430 std::env::set_var("MUR_TEST_RESOLVE_TO_STRING", "kc-abc");
431 }
432 let set = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING".into());
433 assert_eq!(set.resolve_to_string().await.as_deref(), Some("kc-abc"));
434
435 let missing = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING_UNSET".into());
436 assert_eq!(missing.resolve_to_string().await, None);
437 }
438}
439
440#[cfg(test)]
441mod keychain_test_fixture {
442 use keyring::credential::{
454 Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
455 };
456 use std::any::Any;
457 use std::collections::HashMap;
458 use std::sync::{Arc, Mutex};
459 use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};
460
461 type Store = Arc<Mutex<HashMap<(String, String), Vec<u8>>>>;
462
463 struct SharedMockCredential {
464 store: Store,
465 key: (String, String),
466 }
467
468 impl CredentialApi for SharedMockCredential {
469 fn set_secret(&self, password: &[u8]) -> keyring::Result<()> {
470 self.store
471 .lock()
472 .unwrap()
473 .insert(self.key.clone(), password.to_vec());
474 Ok(())
475 }
476 fn get_secret(&self) -> keyring::Result<Vec<u8>> {
477 self.store
478 .lock()
479 .unwrap()
480 .get(&self.key)
481 .cloned()
482 .ok_or(keyring::Error::NoEntry)
483 }
484 fn delete_credential(&self) -> keyring::Result<()> {
485 self.store
486 .lock()
487 .unwrap()
488 .remove(&self.key)
489 .map(|_| ())
490 .ok_or(keyring::Error::NoEntry)
491 }
492 fn as_any(&self) -> &dyn Any {
493 self
494 }
495 }
496
497 struct SharedMockBuilder {
498 store: Store,
499 }
500
501 impl CredentialBuilderApi for SharedMockBuilder {
502 fn build(
503 &self,
504 _target: Option<&str>,
505 service: &str,
506 user: &str,
507 ) -> keyring::Result<Box<Credential>> {
508 Ok(Box::new(SharedMockCredential {
509 store: self.store.clone(),
510 key: (service.to_string(), user.to_string()),
511 }))
512 }
513 fn as_any(&self) -> &dyn Any {
514 self
515 }
516 fn persistence(&self) -> CredentialPersistence {
517 CredentialPersistence::ProcessOnly
518 }
519 }
520
521 static MOCK_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
522
523 pub(super) async fn install_mock(
524 initial: Option<(&str, &str, &str)>,
525 ) -> AsyncMutexGuard<'static, ()> {
526 let g = MOCK_LOCK.lock().await;
527 let store: Store = Arc::new(Mutex::new(HashMap::new()));
528 if let Some((svc, user, pw)) = initial {
529 store
530 .lock()
531 .unwrap()
532 .insert((svc.to_string(), user.to_string()), pw.as_bytes().to_vec());
533 }
534 let builder: Box<CredentialBuilder> = Box::new(SharedMockBuilder { store });
535 keyring::set_default_credential_builder(builder);
536 g
537 }
538}
539
540#[cfg(test)]
541mod resolve_keychain_tests {
542 use super::keychain_test_fixture::install_mock;
543 use super::*;
544 use secrecy::ExposeSecret;
545
546 #[tokio::test]
547 async fn resolves_when_set() {
548 let _g = install_mock(Some(("mur-test", "kc-acct", "kc-secret"))).await;
549 let s = SecretRef::Keychain {
550 service: "mur-test".into(),
551 account: "kc-acct".into(),
552 };
553 let v = s.resolve().await.unwrap();
554 assert_eq!(v.expose_secret(), "kc-secret");
555 }
556
557 #[tokio::test]
558 async fn errors_when_missing() {
559 let _g = install_mock(None).await;
560 let s = SecretRef::Keychain {
561 service: "mur-test".into(),
562 account: "kc-acct".into(),
563 };
564 let err = s.resolve().await.unwrap_err();
565 assert!(
566 matches!(err, SecretError::KeychainNotFound { .. }),
567 "got {err:?}"
568 );
569 }
570}
571
572#[cfg(all(test, unix))]
573mod resolve_file_tests {
574 use super::*;
575 use secrecy::ExposeSecret;
576 use std::os::unix::fs::PermissionsExt;
577 use tempfile::tempdir;
578
579 #[tokio::test]
580 async fn reads_plaintext_0600() {
581 let dir = tempdir().unwrap();
582 let p = dir.path().join("k.txt");
583 std::fs::write(&p, "abc\n").unwrap();
584 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
585 let s = SecretRef::File(p);
586 let v = s.resolve().await.unwrap();
587 assert_eq!(v.expose_secret(), "abc"); }
589
590 #[tokio::test]
591 async fn rejects_world_readable() {
592 let dir = tempdir().unwrap();
593 let p = dir.path().join("k.txt");
594 std::fs::write(&p, "abc").unwrap();
595 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
596 let s = SecretRef::File(p);
597 let err = s.resolve().await.unwrap_err();
598 assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
599 }
600
601 #[tokio::test]
602 async fn decrypts_age_recipient_file() {
603 let dir = tempdir().unwrap();
604 let identity = age::x25519::Identity::generate();
605 let recipient = identity.to_public();
606 let payload = b"shh-from-age";
607
608 let mut encrypted: Vec<u8> = Vec::new();
609 let encryptor =
610 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
611 .unwrap();
612 let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
613 std::io::Write::write_all(&mut writer, payload).unwrap();
614 writer.finish().unwrap();
615
616 let enc_path = dir.path().join("k.age");
617 std::fs::write(&enc_path, &encrypted).unwrap();
618 std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
619 let id_path = dir.path().join("identity.txt");
620 use secrecy::ExposeSecret as _;
621 std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
622 std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
623 unsafe {
627 std::env::set_var("MUR_AGE_IDENTITY_PATH", &id_path);
628 }
629 let s = SecretRef::File(enc_path);
630 let v = s.resolve().await.unwrap();
631 assert_eq!(v.expose_secret(), "shh-from-age");
632 unsafe {
633 std::env::remove_var("MUR_AGE_IDENTITY_PATH");
634 }
635 }
636}
637
638#[cfg(all(test, unix))]
639mod resolve_cmd_tests {
640 use super::*;
641 use secrecy::ExposeSecret;
642
643 #[tokio::test]
644 async fn echoes_stdout() {
645 let s = SecretRef::Cmd("printf shh-from-cmd".into());
646 let v = s.resolve().await.unwrap();
647 assert_eq!(v.expose_secret(), "shh-from-cmd");
648 }
649
650 #[tokio::test]
651 async fn errors_on_non_zero_exit() {
652 let s = SecretRef::Cmd("sh -c 'exit 7'".into());
653 let err = s.resolve().await.unwrap_err();
654 match err {
655 SecretError::Cmd { status, .. } => assert_eq!(status, 7),
656 other => panic!("unexpected: {other:?}"),
657 }
658 }
659}
660
661#[cfg(test)]
662mod check_tests {
663 use super::*;
664
665 #[tokio::test]
666 async fn check_env_present() {
667 unsafe {
669 std::env::set_var("MUR_TEST_CHECK_ENV", "1");
670 }
671 assert!(SecretRef::Env("MUR_TEST_CHECK_ENV".into()).check().await);
672 }
673
674 #[tokio::test]
675 async fn check_env_absent() {
676 assert!(
677 !SecretRef::Env("MUR_TEST_CHECK_DEFINITELY_UNSET".into())
678 .check()
679 .await
680 );
681 }
682}
683
684#[cfg(test)]
685mod keychain_helpers_tests {
686 use super::keychain_test_fixture::install_mock;
687 use super::*;
688 use secrecy::ExposeSecret;
689
690 #[tokio::test]
691 async fn set_then_resolve_round_trips() {
692 let _g = install_mock(None).await;
693 keychain_set("mur-test", "round-trip", "v1").await.unwrap();
694 let v = SecretRef::Keychain {
695 service: "mur-test".into(),
696 account: "round-trip".into(),
697 }
698 .resolve()
699 .await
700 .unwrap();
701 assert_eq!(v.expose_secret(), "v1");
702 }
703
704 #[tokio::test]
705 async fn delete_works() {
706 let _g = install_mock(None).await;
707 keychain_set("mur-test", "to-delete", "v").await.unwrap();
708 keychain_delete("mur-test", "to-delete").await.unwrap();
709 let r = SecretRef::Keychain {
710 service: "mur-test".into(),
711 account: "to-delete".into(),
712 }
713 .resolve()
714 .await;
715 assert!(matches!(r, Err(SecretError::KeychainNotFound { .. })));
716 }
717
718 #[tokio::test]
719 async fn delete_missing_is_idempotent() {
720 let _g = install_mock(None).await;
721 keychain_delete("mur-test", "never-set").await.unwrap();
723 }
724}
725
726#[cfg(test)]
727mod resolve_blocking_tests {
728 use super::*;
729
730 #[test]
731 fn resolve_blocking_env_and_missing() {
732 unsafe { std::env::set_var("MUR_TEST_SECRET_BLOCKING", "s3cret") };
733 let r: SecretRef = "env:MUR_TEST_SECRET_BLOCKING".parse().unwrap();
734 assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
735 unsafe { std::env::remove_var("MUR_TEST_SECRET_BLOCKING") };
736 assert!(r.resolve_blocking().is_err());
737 }
738}