leviath_sys/keychain.rs
1//! The OS credential store: macOS Keychain, Windows Credential Manager, and
2//! the Secret Service on other Unixes.
3//!
4//! This is the only place in the workspace that talks to a platform credential
5//! store. Callers work in terms of an *account* string and get plain
6//! `Result<_, String>` back; which OS facility answers, and whether one exists
7//! at all, is entirely this module's problem.
8//!
9//! # Platforms without a credential store
10//!
11//! There are two separate ways this can be unavailable, and both are ordinary
12//! rather than exceptional:
13//!
14//! 1. **Not built in.** The `keychain` feature is on by default but can be
15//! turned off - for a target the `keyring` crate does not support, or for a
16//! minimal build that should not link platform credential libraries at all.
17//! With it off, [`is_supported`] is `false` and every operation returns a
18//! plain "not supported in this build" error.
19//! 2. **Built in, but nothing to talk to.** A headless Linux box with no Secret
20//! Service running, a locked keychain, a container, an SSH session with no
21//! session keyring. The code is present, the platform simply has no store.
22//!
23//! Neither is treated as a crash or a panic, and neither is silently swallowed.
24//! Leviath's default credential backend is its own `0600` files precisely
25//! because these situations are common; the keychain is opt-in, so an
26//! unavailable one costs a clear error at startup and nothing else.
27//!
28//! # Why the split into `raw_*` and `interpret_*`
29//!
30//! The functions that touch the OS are deliberately branch-free delegation, and
31//! every decision about what a result *means* lives in a pure function beside
32//! them. That is what makes this module testable: no CI runner has an unlocked
33//! login keychain, and a test that reached a developer's real one would raise an
34//! OS authorization prompt and hang. Tests install an in-memory store as the
35//! process default and drive this same code, unchanged, all the way down.
36
37/// Whether this build can talk to an OS credential store at all.
38///
39/// `false` when the `keychain` feature is off. Note that `true` does not promise
40/// a *working* store - see the module docs; use [`probe`] for that.
41pub const fn is_supported() -> bool {
42 cfg!(feature = "keychain")
43}
44
45/// Check that a usable credential store exists, without storing anything.
46///
47/// Worth calling once at startup rather than discovering the problem at the
48/// first secret read: on a headless box every key lookup would otherwise fail
49/// one at a time with an error that reads like a missing key rather than a
50/// missing keychain.
51pub fn probe(service: &str) -> Result<(), String> {
52 imp::probe(service)
53}
54
55/// The secret stored for `account`, or `None` when there is none.
56///
57/// A *missing* entry is `Ok(None)`; an unreachable or locked store is `Err`.
58/// The distinction matters: collapsing a locked keychain into `None` would
59/// surface to the user as "no API key configured" and send them to re-run
60/// `lev setup` instead of unlocking their keychain.
61pub fn get(service: &str, account: &str) -> Result<Option<String>, String> {
62 imp::get(service, account)
63}
64
65/// Store `secret` under `account`, replacing anything already there.
66pub fn set(service: &str, account: &str, secret: &str) -> Result<(), String> {
67 imp::set(service, account, secret)
68}
69
70/// Remove `account`, reporting whether anything was actually removed.
71///
72/// Deleting something that was not there is `Ok(false)`, not an error, so a
73/// caller can clean up unconditionally.
74pub fn delete(service: &str, account: &str) -> Result<bool, String> {
75 imp::delete(service, account)
76}
77
78/// The real implementation, when the `keychain` feature is on.
79///
80/// Mirrors the crate's `platform` module idiom: exactly one of the two `imp`
81/// modules is compiled, so the unused one is never emitted and never counts as
82/// a coverage gap.
83#[cfg(feature = "keychain")]
84mod imp {
85 /// Bind the process to the OS-native store and report whether that worked.
86 ///
87 /// `keyring::Entry::new` installs the platform store on first use, so
88 /// constructing one throwaway entry is both how the installation is
89 /// triggered and how its failure is reported. Constructing an entry does not
90 /// create a credential, so this leaves nothing behind in the user's
91 /// keychain.
92 ///
93 /// If a store is *already* installed, that one is kept: `keyring::Entry::new`
94 /// would otherwise replace it with the platform store, which is both wrong
95 /// for an embedder that chose its own and actively dangerous under test.
96 pub(super) fn probe(service: &str) -> Result<(), String> {
97 // Never replace a store that is already installed. `keyring::Entry::new`
98 // installs the *platform* store unconditionally on first use, so calling
99 // it blindly would clobber a store the embedding process chose - and,
100 // in tests, silently redirect every subsequent operation at the
101 // developer's real login keychain, where it would both prompt and write.
102 if keyring_core::get_default_store().is_some() {
103 return Ok(());
104 }
105 describe(keyring::Entry::new(service, PROBE_ACCOUNT).map(drop))
106 }
107
108 /// The account used only to check that a store exists. Never written to.
109 const PROBE_ACCOUNT: &str = "__leviath_probe__";
110
111 pub(super) fn get(service: &str, account: &str) -> Result<Option<String>, String> {
112 interpret_get(raw_get(service, account))
113 }
114
115 pub(super) fn set(service: &str, account: &str, secret: &str) -> Result<(), String> {
116 describe(raw_set(service, account, secret))
117 }
118
119 pub(super) fn delete(service: &str, account: &str) -> Result<bool, String> {
120 interpret_delete(raw_delete(service, account))
121 }
122
123 // ─── The OS-touching leaves: delegation only, no decisions ───────────────
124
125 fn raw_get(service: &str, account: &str) -> Result<String, keyring::Error> {
126 keyring_core::Entry::new(service, account).and_then(|e| e.get_password())
127 }
128
129 fn raw_set(service: &str, account: &str, secret: &str) -> Result<(), keyring::Error> {
130 keyring_core::Entry::new(service, account).and_then(|e| e.set_password(secret))
131 }
132
133 fn raw_delete(service: &str, account: &str) -> Result<(), keyring::Error> {
134 keyring_core::Entry::new(service, account).and_then(|e| e.delete_credential())
135 }
136
137 // ─── The decisions: pure, and directly tested ───────────────────────────
138
139 /// "No such entry" is an answer; everything else is a failure.
140 fn interpret_get(result: Result<String, keyring::Error>) -> Result<Option<String>, String> {
141 match result {
142 Ok(secret) => Ok(Some(secret)),
143 Err(keyring::Error::NoEntry) => Ok(None),
144 Err(e) => Err(unavailable(e)),
145 }
146 }
147
148 /// Deleting something absent is a no-op, not a failure.
149 fn interpret_delete(result: Result<(), keyring::Error>) -> Result<bool, String> {
150 match result {
151 Ok(()) => Ok(true),
152 Err(keyring::Error::NoEntry) => Ok(false),
153 Err(e) => Err(unavailable(e)),
154 }
155 }
156
157 fn describe(result: Result<(), keyring::Error>) -> Result<(), String> {
158 result.map_err(unavailable)
159 }
160
161 /// Render a store failure with the one piece of context users need.
162 ///
163 /// `keyring`'s own message describes the platform call that failed; on a
164 /// headless Linux box the answer is nearly always "no Secret Service is
165 /// running", and the fix is to go back to the file backend. Naming that here
166 /// saves a support round trip.
167 fn unavailable(e: keyring::Error) -> String {
168 format!(
169 "OS credential store unavailable: {e}. Set `[security] credential_store = \"file\"` \
170 in ~/.leviath/config.toml to keep secrets in Leviath's own 0600 files instead."
171 )
172 }
173
174 #[cfg(test)]
175 mod tests {
176 use super::*;
177
178 /// `keyring_core`'s default store is process-wide, so a test that
179 /// installs one races every test that reads it.
180 static STORE: std::sync::Mutex<()> = std::sync::Mutex::new(());
181
182 /// Install a fresh in-memory store as the process default, holding the
183 /// lock for the caller's lifetime. This is what lets the `raw_*`
184 /// functions - the real `keyring_core::Entry` path, unchanged - run in
185 /// CI on every platform.
186 fn with_mock_store() -> std::sync::MutexGuard<'static, ()> {
187 let guard = STORE.lock().expect("store lock");
188 keyring_core::set_default_store(keyring_core::mock::Store::new().expect("mock store"));
189 guard
190 }
191
192 const SVC: &str = "dev.leviath.test";
193
194 #[test]
195 fn a_secret_survives_a_set_get_delete_round_trip() {
196 let _guard = with_mock_store();
197
198 assert_eq!(get(SVC, "provider/anthropic").unwrap(), None);
199 set(SVC, "provider/anthropic", "sk-ant-secret").unwrap();
200 assert_eq!(
201 get(SVC, "provider/anthropic").unwrap().as_deref(),
202 Some("sk-ant-secret")
203 );
204
205 // A second set replaces rather than duplicating.
206 set(SVC, "provider/anthropic", "sk-ant-rotated").unwrap();
207 assert_eq!(
208 get(SVC, "provider/anthropic").unwrap().as_deref(),
209 Some("sk-ant-rotated")
210 );
211
212 assert!(delete(SVC, "provider/anthropic").unwrap(), "it existed");
213 assert_eq!(get(SVC, "provider/anthropic").unwrap(), None, "and is gone");
214 assert!(
215 !delete(SVC, "provider/anthropic").unwrap(),
216 "deleting again reports nothing was removed"
217 );
218 }
219
220 /// Accounts do not leak into each other.
221 /// The public shims at the crate root - the path every caller actually
222 /// takes - driven against the mock store under the lock.
223 #[test]
224 fn the_public_api_delegates_to_this_implementation() {
225 let _guard = with_mock_store();
226 const SVC: &str = "dev.leviath.test.shim";
227
228 crate::keychain::probe(SVC).expect("a mock store is available");
229 assert_eq!(crate::keychain::get(SVC, "provider/none").unwrap(), None);
230 crate::keychain::set(SVC, "provider/none", "v").unwrap();
231 assert_eq!(
232 crate::keychain::get(SVC, "provider/none")
233 .unwrap()
234 .as_deref(),
235 Some("v")
236 );
237 assert!(crate::keychain::delete(SVC, "provider/none").unwrap());
238 }
239
240 #[test]
241 fn accounts_are_independent() {
242 let _guard = with_mock_store();
243 set(SVC, "provider/openai", "one").unwrap();
244 set(SVC, "mcp/github", "two").unwrap();
245 assert_eq!(get(SVC, "provider/openai").unwrap().as_deref(), Some("one"));
246 assert_eq!(get(SVC, "mcp/github").unwrap().as_deref(), Some("two"));
247 }
248
249 /// A locked or absent store is an error, not a silent `None` - the
250 /// distinction the module docs turn on.
251 #[test]
252 fn a_missing_entry_is_none_but_a_broken_store_is_an_error() {
253 assert_eq!(interpret_get(Ok("s".into())).unwrap().as_deref(), Some("s"));
254 assert_eq!(interpret_get(Err(keyring::Error::NoEntry)).unwrap(), None);
255 assert!(interpret_get(Err(keyring::Error::NoDefaultStore)).is_err());
256
257 assert!(interpret_delete(Ok(())).unwrap());
258 assert!(!interpret_delete(Err(keyring::Error::NoEntry)).unwrap());
259 assert!(interpret_delete(Err(keyring::Error::NoDefaultStore)).is_err());
260
261 assert!(describe(Ok(())).is_ok());
262 assert!(describe(Err(keyring::Error::NoDefaultStore)).is_err());
263 }
264
265 /// Every failure must name the way out. A user whose keychain is
266 /// unavailable needs to know the file backend is one config line away,
267 /// not just that a platform call failed.
268 #[test]
269 fn failures_name_the_file_fallback() {
270 let msg = unavailable(keyring::Error::NoDefaultStore);
271 assert!(msg.contains("OS credential store unavailable"), "{msg}");
272 assert!(msg.contains("credential_store = \"file\""), "{msg}");
273 }
274
275 /// With no store installed at all, every operation reports rather than
276 /// panicking - the headless-Linux case.
277 #[test]
278 fn every_operation_reports_when_there_is_no_store() {
279 let _guard = STORE.lock().expect("store lock");
280 keyring_core::unset_default_store();
281
282 assert!(get(SVC, "provider/anthropic").is_err());
283 assert!(set(SVC, "provider/anthropic", "x").is_err());
284 assert!(delete(SVC, "provider/anthropic").is_err());
285 }
286
287 /// The real platform probe. Either outcome is legitimate - a developer
288 /// machine has a credential store and a CI runner does not - so what is
289 /// asserted is that it answers rather than panicking or prompting. It
290 /// contributes no branch of its own, which is why one call covers it on
291 /// every platform.
292 #[test]
293 fn the_platform_probe_answers_on_any_platform() {
294 let _guard = STORE.lock().expect("store lock");
295 // Clear the default store so the probe takes its *other* branch --
296 // the one that actually asks the platform. Constructing an `Entry`
297 // creates a handle, not a credential, so this reads and writes
298 // nothing in a developer's real keychain.
299 keyring_core::unset_default_store();
300 let _ = probe(SVC);
301 // The probe may have installed the real platform store; put the mock
302 // back before releasing the lock, so nothing that runs next can
303 // reach the OS.
304 keyring_core::set_default_store(keyring_core::mock::Store::new().expect("mock store"));
305 }
306
307 /// The short-circuit itself: with a store already installed, the probe
308 /// must accept it rather than replacing it with the platform store.
309 #[test]
310 fn the_probe_keeps_a_store_that_is_already_installed() {
311 let _guard = with_mock_store();
312 probe(SVC).expect("an installed store is a usable store");
313 // Still the mock: a real platform store would have replaced it, and
314 // every later operation would hit the OS.
315 set(SVC, "provider/probe-check", "v").unwrap();
316 assert_eq!(
317 get(SVC, "provider/probe-check").unwrap().as_deref(),
318 Some("v")
319 );
320 }
321 }
322}
323
324/// The stub implementation, when the `keychain` feature is off.
325///
326/// Every operation reports plainly that this build has no credential store,
327/// rather than pretending an empty one. A caller that asked for the keychain
328/// backend on a build without it has a configuration problem, and silently
329/// answering "no secrets found" would look identical to a wiped keychain.
330#[cfg(not(feature = "keychain"))]
331mod imp {
332 const UNSUPPORTED: &str = "this build of Leviath has no OS credential store support \
333 (the `keychain` feature is disabled). Set `[security] credential_store = \"file\"` \
334 in ~/.leviath/config.toml to keep secrets in Leviath's own 0600 files instead.";
335
336 pub(super) fn probe(_service: &str) -> Result<(), String> {
337 Err(UNSUPPORTED.to_string())
338 }
339
340 pub(super) fn get(_service: &str, _account: &str) -> Result<Option<String>, String> {
341 Err(UNSUPPORTED.to_string())
342 }
343
344 pub(super) fn set(_service: &str, _account: &str, _secret: &str) -> Result<(), String> {
345 Err(UNSUPPORTED.to_string())
346 }
347
348 pub(super) fn delete(_service: &str, _account: &str) -> Result<bool, String> {
349 Err(UNSUPPORTED.to_string())
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 /// `is_supported` reports what was actually compiled in - a build with the
358 /// feature off must say so rather than claiming a store it cannot reach.
359 ///
360 /// The other public shims are exercised from `imp`'s tests, which hold the
361 /// process-wide store lock. Calling them from here would race those tests,
362 /// and on a machine that *has* a keychain an unguarded call reaches it.
363 #[test]
364 fn support_matches_the_feature() {
365 assert_eq!(is_supported(), cfg!(feature = "keychain"));
366 }
367}