Skip to main content

key_vault/fetcher/
keychain.rs

1//! [`KeychainFetch`] — OS keychain [`KeyFetch`] backend.
2//!
3//! Wraps the [`keyring`](https://crates.io/crates/keyring) crate to read
4//! secrets from the host's native credential store:
5//!
6//! - **macOS** — Keychain Services
7//! - **Windows** — Credential Manager
8//! - **Linux** — Secret Service (gnome-keyring, KWallet)
9//!
10//! Gated behind the `fetcher-keychain` Cargo feature.
11//!
12//! # Threat profile
13//!
14//! The native credential stores enforce OS-level access control: secrets
15//! are scoped to the user account and (on macOS) to the requesting
16//! application's signing identity. They are the highest-security
17//! general-purpose backend short of dedicated hardware. Use this whenever
18//! you have the option.
19
20use alloc::borrow::Cow;
21use alloc::format;
22use alloc::string::{String, ToString};
23
24use super::{FetchContext, KeyFetch, RawKey};
25use crate::Result;
26use crate::error::Error;
27
28/// `KeyFetch` implementation that reads from the OS native credential
29/// store. Cross-platform via the `keyring` crate.
30///
31/// Construct with [`KeychainFetch::new`] and the `(service, account)`
32/// pair that identifies your entry. Both values are stored verbatim; they
33/// appear in failure messages for diagnostics.
34///
35/// # Examples
36///
37/// ```no_run
38/// use key_vault::{FetchContext, KeyFetch, KeychainFetch};
39///
40/// # fn main() -> Result<(), key_vault::Error> {
41/// let fetcher = KeychainFetch::new("my-app", "primary-key");
42/// let raw = fetcher.fetch(&FetchContext::new("primary"))?;
43/// # let _ = raw;
44/// # Ok(())
45/// # }
46/// ```
47#[derive(Debug, Clone)]
48pub struct KeychainFetch {
49    service: String,
50    account: String,
51}
52
53impl KeychainFetch {
54    /// Construct a fetcher for the given keychain entry.
55    ///
56    /// `service` is the application or namespace name (e.g. `"my-app"`).
57    /// `account` is the entry identifier within that service (e.g.
58    /// `"primary-key"`).
59    #[must_use]
60    pub fn new(service: impl Into<String>, account: impl Into<String>) -> Self {
61        Self {
62            service: service.into(),
63            account: account.into(),
64        }
65    }
66}
67
68impl KeyFetch for KeychainFetch {
69    fn fetch(&self, _ctx: &FetchContext) -> Result<RawKey> {
70        let entry =
71            keyring::Entry::new(&self.service, &self.account).map_err(|e| Error::Acquisition {
72                source: Cow::Borrowed("keychain"),
73                reason: format!(
74                    "could not open keychain entry {}/{}: {}",
75                    self.service,
76                    self.account,
77                    redact_keyring_error(&e),
78                ),
79            })?;
80        let value = entry.get_password().map_err(|e| Error::Acquisition {
81            source: Cow::Borrowed("keychain"),
82            reason: format!(
83                "could not read keychain entry {}/{}: {}",
84                self.service,
85                self.account,
86                redact_keyring_error(&e),
87            ),
88        })?;
89        Ok(RawKey::new(value.into_bytes()))
90    }
91
92    fn describe(&self) -> Cow<'_, str> {
93        Cow::Borrowed("keychain")
94    }
95}
96
97/// Redact the `keyring` crate's error message.
98///
99/// `keyring` error types carry platform-specific detail (OS error
100/// numbers, internal API failures). We expose only the discriminant
101/// name — never any embedded credential-adjacent strings.
102fn redact_keyring_error(e: &keyring::Error) -> String {
103    use keyring::Error;
104    match e {
105        Error::NoEntry => "no such entry".to_string(),
106        Error::BadEncoding(_) => "stored value is not UTF-8".to_string(),
107        Error::TooLong(field, _) => format!("{field} too long for platform"),
108        Error::Invalid(field, _) => format!("invalid {field}"),
109        Error::PlatformFailure(_) => "platform-specific keyring failure".to_string(),
110        Error::NoStorageAccess(_) => "keyring service inaccessible".to_string(),
111        Error::Ambiguous(_) => "multiple matching entries (ambiguous)".to_string(),
112        // Future variants of keyring::Error get a generic label.
113        _ => "keyring error".to_string(),
114    }
115}
116
117#[cfg(test)]
118#[allow(clippy::unwrap_used, clippy::expect_used)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn describe_returns_keychain() {
124        let f = KeychainFetch::new("svc", "acct");
125        assert_eq!(f.describe(), "keychain");
126    }
127
128    #[test]
129    fn construction_holds_service_and_account() {
130        let f = KeychainFetch::new("test-service", "test-account");
131        assert_eq!(f.service, "test-service");
132        assert_eq!(f.account, "test-account");
133    }
134
135    // Live integration test against the real OS keychain is gated by an
136    // env var so it does not run in default CI (which has no keychain).
137    // Set `KEY_VAULT_KEYCHAIN_TEST=1` and seed an entry manually to run it.
138    #[test]
139    fn live_keychain_test_skipped_when_not_opted_in() {
140        if std::env::var("KEY_VAULT_KEYCHAIN_TEST").ok().as_deref() != Some("1") {
141            return; // skipped — the common case in CI
142        }
143        let fetcher = KeychainFetch::new("key-vault-test", "ci-key");
144        // The opt-in environment is expected to have seeded this entry;
145        // we just verify the fetch contract.
146        let raw = fetcher.fetch(&FetchContext::new("k")).unwrap();
147        assert!(!raw.is_empty());
148    }
149}