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
135pub async fn keychain_get(
146 service: &str,
147 account: &str,
148) -> Result<Option<SecretString>, SecretError> {
149 let svc = service.to_string();
150 let acct = account.to_string();
151 tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
152 let entry = keyring::Entry::new(&svc, &acct)
153 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
154 match entry.get_password() {
155 Ok(s) => Ok(Some(s)),
156 Err(keyring::Error::NoEntry) => Ok(None),
157 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
158 }
159 })
160 .await
161 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
162 .map(|opt| opt.map(SecretString::from))
163}
164
165pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
168 let svc = service.to_string();
169 let acct = account.to_string();
170 let val = value.to_string();
171 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
172 let entry = keyring::Entry::new(&svc, &acct)
173 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
174 entry
175 .set_password(&val)
176 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
177 Ok(())
178 })
179 .await
180 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
181}
182
183pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
186 let svc = service.to_string();
187 let acct = account.to_string();
188 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
189 let entry = keyring::Entry::new(&svc, &acct)
190 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
191 match entry.delete_credential() {
192 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
193 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
194 }
195 })
196 .await
197 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
198}
199
200async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
201 let mut parts = shell_words::split(spec)
202 .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
203 if parts.is_empty() {
204 return Err(SecretError::Parse("empty cmd".into()));
205 }
206 let program = parts.remove(0);
207 let output = tokio::process::Command::new(&program)
208 .args(&parts)
209 .output()
210 .await
211 .map_err(|e| SecretError::Cmd {
212 cmd: format!("{spec} ({e})"),
213 status: -1,
214 })?;
215 if !output.status.success() {
216 return Err(SecretError::Cmd {
217 cmd: spec.to_string(),
218 status: output.status.code().unwrap_or(-1),
219 });
220 }
221 let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
222 cmd: format!("{spec} (non-utf8 stdout: {e})"),
223 status: -2,
224 })?;
225 Ok(SecretString::from(
226 s.trim_end_matches(['\n', '\r']).to_string(),
227 ))
228}
229
230async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
231 let expanded = shellexpand::full(&path.to_string_lossy())
232 .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
233 .to_string();
234 let p = std::path::PathBuf::from(expanded);
235
236 #[cfg(unix)]
237 {
238 use std::os::unix::fs::PermissionsExt;
239 let meta = tokio::fs::metadata(&p)
240 .await
241 .map_err(|e| SecretError::FileRead {
242 path: p.display().to_string(),
243 source: e,
244 })?;
245 let mode = meta.permissions().mode() & 0o777;
246 if mode & 0o077 != 0 {
247 return Err(SecretError::FileMode(format!(
248 "{}: mode {:o} grants group/world access",
249 p.display(),
250 mode
251 )));
252 }
253 }
254
255 let bytes = tokio::fs::read(&p)
256 .await
257 .map_err(|e| SecretError::FileRead {
258 path: p.display().to_string(),
259 source: e,
260 })?;
261
262 let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
263 decrypt_age(&bytes).await?
264 } else {
265 String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
266 };
267 let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
268 Ok(SecretString::from(trimmed))
269}
270
271async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
272 let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
273 Ok(p) => std::path::PathBuf::from(p),
274 Err(_) => dirs::home_dir()
275 .ok_or_else(|| {
276 SecretError::AgeDecrypt(
277 "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
278 )
279 })?
280 .join(".mur/age/identity.txt"),
281 };
282
283 let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
284 SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
285 })?;
286 let identity: age::x25519::Identity = id_str
287 .trim()
288 .parse()
289 .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
290
291 let decryptor =
292 age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
293 let mut reader = decryptor
294 .decrypt(std::iter::once(&identity as &dyn age::Identity))
295 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
296 let mut out = String::new();
297 use std::io::Read;
298 reader
299 .read_to_string(&mut out)
300 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
301 Ok(out)
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use serde_yaml_ng as yaml;
308
309 #[test]
310 fn parses_env_form() {
311 let s: SecretRef = yaml::from_str("env:ANTHROPIC_API_KEY").unwrap();
312 assert_eq!(s, SecretRef::Env("ANTHROPIC_API_KEY".into()));
313 }
314
315 #[test]
316 fn parses_keychain_form() {
317 let s: SecretRef = yaml::from_str("keychain:mur/anthropic-oauth").unwrap();
318 assert_eq!(
319 s,
320 SecretRef::Keychain {
321 service: "mur".into(),
322 account: "anthropic-oauth".into()
323 }
324 );
325 }
326
327 #[test]
328 fn parses_file_form() {
329 let s: SecretRef = yaml::from_str("file:/tmp/foo.age").unwrap();
330 assert_eq!(s, SecretRef::File(PathBuf::from("/tmp/foo.age")));
331 }
332
333 #[test]
334 fn parses_cmd_form() {
335 let s: SecretRef = yaml::from_str("cmd:op read op://vault/item/field").unwrap();
336 assert_eq!(s, SecretRef::Cmd("op read op://vault/item/field".into()));
337 }
338
339 #[test]
340 fn rejects_unknown_scheme() {
341 let r: Result<SecretRef, _> = yaml::from_str("plain:supersecret");
342 assert!(r.is_err());
343 }
344
345 #[test]
346 fn round_trip_serde() {
347 let cases = [
348 "env:X",
349 "keychain:svc/acct",
350 "file:/p",
351 "cmd:bin --flag arg",
352 ];
353 for s in cases {
354 let parsed: SecretRef = yaml::from_str(s).unwrap();
355 let back = yaml::to_string(&parsed).unwrap();
356 let normalized = back
358 .trim()
359 .trim_matches(|c: char| c == '"' || c == '\'')
360 .to_string();
361 let reparsed: SecretRef = yaml::from_str(&normalized).unwrap();
362 assert_eq!(parsed, reparsed, "round-trip drift for {s}");
363 }
364 }
365}
366
367#[cfg(test)]
368mod resolve_env_tests {
369 use super::*;
370 use secrecy::ExposeSecret;
371
372 #[tokio::test]
373 async fn resolves_env_when_set() {
374 unsafe {
376 std::env::set_var("MUR_TEST_RESOLVE_ENV", "shhh");
377 }
378 let s = SecretRef::Env("MUR_TEST_RESOLVE_ENV".into());
379 let v = s.resolve().await.unwrap();
380 assert_eq!(v.expose_secret(), "shhh");
381 }
382
383 #[tokio::test]
384 async fn errors_when_env_missing() {
385 let s = SecretRef::Env("MUR_TEST_DEFINITELY_UNSET".into());
386 let err = s.resolve().await.unwrap_err();
387 assert!(matches!(err, SecretError::EnvNotSet(_)), "got {err:?}");
388 }
389}
390
391#[cfg(test)]
392mod keychain_test_fixture {
393 use keyring::credential::{
405 Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
406 };
407 use std::any::Any;
408 use std::collections::HashMap;
409 use std::sync::{Arc, Mutex};
410 use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};
411
412 type Store = Arc<Mutex<HashMap<(String, String), Vec<u8>>>>;
413
414 struct SharedMockCredential {
415 store: Store,
416 key: (String, String),
417 }
418
419 impl CredentialApi for SharedMockCredential {
420 fn set_secret(&self, password: &[u8]) -> keyring::Result<()> {
421 self.store
422 .lock()
423 .unwrap()
424 .insert(self.key.clone(), password.to_vec());
425 Ok(())
426 }
427 fn get_secret(&self) -> keyring::Result<Vec<u8>> {
428 self.store
429 .lock()
430 .unwrap()
431 .get(&self.key)
432 .cloned()
433 .ok_or(keyring::Error::NoEntry)
434 }
435 fn delete_credential(&self) -> keyring::Result<()> {
436 self.store
437 .lock()
438 .unwrap()
439 .remove(&self.key)
440 .map(|_| ())
441 .ok_or(keyring::Error::NoEntry)
442 }
443 fn as_any(&self) -> &dyn Any {
444 self
445 }
446 }
447
448 struct SharedMockBuilder {
449 store: Store,
450 }
451
452 impl CredentialBuilderApi for SharedMockBuilder {
453 fn build(
454 &self,
455 _target: Option<&str>,
456 service: &str,
457 user: &str,
458 ) -> keyring::Result<Box<Credential>> {
459 Ok(Box::new(SharedMockCredential {
460 store: self.store.clone(),
461 key: (service.to_string(), user.to_string()),
462 }))
463 }
464 fn as_any(&self) -> &dyn Any {
465 self
466 }
467 fn persistence(&self) -> CredentialPersistence {
468 CredentialPersistence::ProcessOnly
469 }
470 }
471
472 static MOCK_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
473
474 pub(super) async fn install_mock(
475 initial: Option<(&str, &str, &str)>,
476 ) -> AsyncMutexGuard<'static, ()> {
477 let g = MOCK_LOCK.lock().await;
478 let store: Store = Arc::new(Mutex::new(HashMap::new()));
479 if let Some((svc, user, pw)) = initial {
480 store
481 .lock()
482 .unwrap()
483 .insert((svc.to_string(), user.to_string()), pw.as_bytes().to_vec());
484 }
485 let builder: Box<CredentialBuilder> = Box::new(SharedMockBuilder { store });
486 keyring::set_default_credential_builder(builder);
487 g
488 }
489}
490
491#[cfg(test)]
492mod resolve_keychain_tests {
493 use super::keychain_test_fixture::install_mock;
494 use super::*;
495 use secrecy::ExposeSecret;
496
497 #[tokio::test]
498 async fn resolves_when_set() {
499 let _g = install_mock(Some(("mur-test", "kc-acct", "kc-secret"))).await;
500 let s = SecretRef::Keychain {
501 service: "mur-test".into(),
502 account: "kc-acct".into(),
503 };
504 let v = s.resolve().await.unwrap();
505 assert_eq!(v.expose_secret(), "kc-secret");
506 }
507
508 #[tokio::test]
509 async fn errors_when_missing() {
510 let _g = install_mock(None).await;
511 let s = SecretRef::Keychain {
512 service: "mur-test".into(),
513 account: "kc-acct".into(),
514 };
515 let err = s.resolve().await.unwrap_err();
516 assert!(
517 matches!(err, SecretError::KeychainNotFound { .. }),
518 "got {err:?}"
519 );
520 }
521}
522
523#[cfg(all(test, unix))]
524mod resolve_file_tests {
525 use super::*;
526 use secrecy::ExposeSecret;
527 use std::os::unix::fs::PermissionsExt;
528 use tempfile::tempdir;
529
530 #[tokio::test]
531 async fn reads_plaintext_0600() {
532 let dir = tempdir().unwrap();
533 let p = dir.path().join("k.txt");
534 std::fs::write(&p, "abc\n").unwrap();
535 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
536 let s = SecretRef::File(p);
537 let v = s.resolve().await.unwrap();
538 assert_eq!(v.expose_secret(), "abc"); }
540
541 #[tokio::test]
542 async fn rejects_world_readable() {
543 let dir = tempdir().unwrap();
544 let p = dir.path().join("k.txt");
545 std::fs::write(&p, "abc").unwrap();
546 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
547 let s = SecretRef::File(p);
548 let err = s.resolve().await.unwrap_err();
549 assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
550 }
551
552 #[tokio::test]
553 async fn decrypts_age_recipient_file() {
554 let dir = tempdir().unwrap();
555 let identity = age::x25519::Identity::generate();
556 let recipient = identity.to_public();
557 let payload = b"shh-from-age";
558
559 let mut encrypted: Vec<u8> = Vec::new();
560 let encryptor =
561 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
562 .unwrap();
563 let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
564 std::io::Write::write_all(&mut writer, payload).unwrap();
565 writer.finish().unwrap();
566
567 let enc_path = dir.path().join("k.age");
568 std::fs::write(&enc_path, &encrypted).unwrap();
569 std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
570 let id_path = dir.path().join("identity.txt");
571 use secrecy::ExposeSecret as _;
572 std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
573 std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
574 unsafe {
578 std::env::set_var("MUR_AGE_IDENTITY_PATH", &id_path);
579 }
580 let s = SecretRef::File(enc_path);
581 let v = s.resolve().await.unwrap();
582 assert_eq!(v.expose_secret(), "shh-from-age");
583 unsafe {
584 std::env::remove_var("MUR_AGE_IDENTITY_PATH");
585 }
586 }
587}
588
589#[cfg(all(test, unix))]
590mod resolve_cmd_tests {
591 use super::*;
592 use secrecy::ExposeSecret;
593
594 #[tokio::test]
595 async fn echoes_stdout() {
596 let s = SecretRef::Cmd("printf shh-from-cmd".into());
597 let v = s.resolve().await.unwrap();
598 assert_eq!(v.expose_secret(), "shh-from-cmd");
599 }
600
601 #[tokio::test]
602 async fn errors_on_non_zero_exit() {
603 let s = SecretRef::Cmd("sh -c 'exit 7'".into());
604 let err = s.resolve().await.unwrap_err();
605 match err {
606 SecretError::Cmd { status, .. } => assert_eq!(status, 7),
607 other => panic!("unexpected: {other:?}"),
608 }
609 }
610}
611
612#[cfg(test)]
613mod check_tests {
614 use super::*;
615
616 #[tokio::test]
617 async fn check_env_present() {
618 unsafe {
620 std::env::set_var("MUR_TEST_CHECK_ENV", "1");
621 }
622 assert!(SecretRef::Env("MUR_TEST_CHECK_ENV".into()).check().await);
623 }
624
625 #[tokio::test]
626 async fn check_env_absent() {
627 assert!(
628 !SecretRef::Env("MUR_TEST_CHECK_DEFINITELY_UNSET".into())
629 .check()
630 .await
631 );
632 }
633}
634
635#[cfg(test)]
636mod keychain_helpers_tests {
637 use super::keychain_test_fixture::install_mock;
638 use super::*;
639 use secrecy::ExposeSecret;
640
641 #[tokio::test]
642 async fn set_then_resolve_round_trips() {
643 let _g = install_mock(None).await;
644 keychain_set("mur-test", "round-trip", "v1").await.unwrap();
645 let v = SecretRef::Keychain {
646 service: "mur-test".into(),
647 account: "round-trip".into(),
648 }
649 .resolve()
650 .await
651 .unwrap();
652 assert_eq!(v.expose_secret(), "v1");
653 }
654
655 #[tokio::test]
656 async fn delete_works() {
657 let _g = install_mock(None).await;
658 keychain_set("mur-test", "to-delete", "v").await.unwrap();
659 keychain_delete("mur-test", "to-delete").await.unwrap();
660 let r = SecretRef::Keychain {
661 service: "mur-test".into(),
662 account: "to-delete".into(),
663 }
664 .resolve()
665 .await;
666 assert!(matches!(r, Err(SecretError::KeychainNotFound { .. })));
667 }
668
669 #[tokio::test]
670 async fn delete_missing_is_idempotent() {
671 let _g = install_mock(None).await;
672 keychain_delete("mur-test", "never-set").await.unwrap();
674 }
675}