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}