Skip to main content

leviath_core/
credentials.rs

1//! Where Leviath's long-lived secrets live.
2//!
3//! Two backends, chosen at runtime by `[security] credential_store`:
4//!
5//! - **`file`** (the default) - provider API keys sit in `~/.leviath/config.toml`
6//!   and MCP OAuth tokens in `~/.leviath/mcp-auth.json`, both written `0600` by
7//!   `leviath_sys::write_private` so they are never briefly world-readable. This
8//!   is what Claude Code and Codex do, and it is a reasonable default: it works
9//!   headless, in containers, over SSH, and on a CI runner, none of which have
10//!   an unlocked keychain.
11//! - **`keychain`** - the OS credential store. Secrets never touch disk in
12//!   Leviath's own files, so a stolen `~/.leviath` directory yields nothing, and
13//!   on macOS the OS gates access per-application.
14//!
15//! `file` stays the default deliberately. A keychain that is unavailable is not
16//! a degraded experience, it is a broken one - every inference fails with no
17//! obvious cause - and the environments where Leviath is most useful are exactly
18//! the ones least likely to have a working credential store. Opting in is one
19//! line; being unable to opt out would be a support burden.
20//!
21//! This module is the platform-neutral half: the vocabulary (which backend, what
22//! an account is called) and the [`CredentialStore`] trait everything is written
23//! against. The OS binding lives in `leviath-sys`, with the rest of the
24//! platform-specific code and its own no-store fallback.
25
26use std::collections::BTreeMap;
27
28/// The service name every Leviath credential is filed under.
29///
30/// One service, many accounts: this is what a user sees if they open Keychain
31/// Access or `secret-tool` and search for it.
32pub const SERVICE: &str = "dev.leviath.lev";
33
34/// Which backend holds secrets.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum CredentialStoreKind {
38    /// `~/.leviath/config.toml` and `~/.leviath/mcp-auth.json`, mode `0600`.
39    #[default]
40    File,
41    /// The OS credential store.
42    Keychain,
43}
44
45impl CredentialStoreKind {
46    /// True when secrets belong in the OS store rather than in Leviath's files.
47    pub fn is_keychain(self) -> bool {
48        matches!(self, Self::Keychain)
49    }
50}
51
52/// The account name for a provider's API key.
53///
54/// Namespaced so a future non-provider secret cannot collide with a provider
55/// literally named `github`, and so `lev auth status` can tell the two apart
56/// without a second lookup.
57pub fn provider_account(provider: &str) -> String {
58    format!("provider/{provider}")
59}
60
61/// The account name for an MCP server's stored OAuth grant.
62pub fn mcp_account(server: &str) -> String {
63    format!("mcp/{server}")
64}
65
66/// A place secrets can be put and taken back out.
67///
68/// A trait rather than a concrete type so the config and MCP-auth layers can be
69/// tested against an in-memory store without reaching the OS, and so a future
70/// backend (a remote secret manager, say) has somewhere to land.
71pub trait CredentialStore: Send + Sync {
72    /// The stored secret for `account`, or `None` if there is none.
73    ///
74    /// A *missing* entry is `Ok(None)`; an unreachable or locked store is `Err`.
75    /// The distinction matters: collapsing a locked keychain into `None` would
76    /// surface to the user as "no API key configured" and send them to re-run
77    /// `lev setup` instead of unlocking their keychain.
78    fn get(&self, account: &str) -> Result<Option<String>, String>;
79
80    /// Store `secret` under `account`, replacing anything already there.
81    fn set(&self, account: &str, secret: &str) -> Result<(), String>;
82
83    /// Remove `account`, reporting whether anything was actually removed, so a
84    /// caller can clean up unconditionally.
85    fn delete(&self, account: &str) -> Result<bool, String>;
86
87    /// Every one of `accounts` that is present, with its secret.
88    ///
89    /// The OS stores offer no portable "list everything under this service"
90    /// operation, so the caller supplies the accounts to look for - it knows
91    /// them: the provider list is fixed and the MCP server list comes from the
92    /// config.
93    ///
94    /// An entry the store cannot return is skipped rather than aborting the
95    /// whole read: one stale keychain item should not stop a user from running.
96    fn read_all(&self, accounts: &[String]) -> BTreeMap<String, String> {
97        accounts
98            .iter()
99            .filter_map(|a| match self.get(a) {
100                Ok(Some(secret)) => Some((a.clone(), secret)),
101                _ => None,
102            })
103            .collect()
104    }
105}
106
107/// An in-memory [`CredentialStore`], for tests and for callers that want the
108/// keychain code paths without a keychain.
109#[derive(Debug, Default)]
110pub struct MemoryStore {
111    entries: std::sync::Mutex<BTreeMap<String, String>>,
112}
113
114impl MemoryStore {
115    /// An empty store.
116    pub fn new() -> Self {
117        Self::default()
118    }
119}
120
121impl CredentialStore for MemoryStore {
122    fn get(&self, account: &str) -> Result<Option<String>, String> {
123        Ok(self.lock().get(account).cloned())
124    }
125
126    fn set(&self, account: &str, secret: &str) -> Result<(), String> {
127        self.lock().insert(account.to_string(), secret.to_string());
128        Ok(())
129    }
130
131    fn delete(&self, account: &str) -> Result<bool, String> {
132        Ok(self.lock().remove(account).is_some())
133    }
134}
135
136impl MemoryStore {
137    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, String>> {
138        // Poisoning here would mean a test panicked while holding the lock; the
139        // stored secrets are still perfectly readable, and turning that into a
140        // second failure hides the first.
141        self.entries
142            .lock()
143            .unwrap_or_else(std::sync::PoisonError::into_inner)
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn accounts_are_namespaced_by_kind() {
153        assert_eq!(provider_account("anthropic"), "provider/anthropic");
154        assert_eq!(mcp_account("github"), "mcp/github");
155        assert_ne!(provider_account("x"), mcp_account("x"));
156    }
157
158    /// The default has to stay `file`: it is the only backend that works
159    /// headless, in a container, and over SSH.
160    #[test]
161    fn file_is_the_default_backend() {
162        assert_eq!(CredentialStoreKind::default(), CredentialStoreKind::File);
163        assert!(!CredentialStoreKind::default().is_keychain());
164        assert!(CredentialStoreKind::Keychain.is_keychain());
165    }
166
167    #[test]
168    fn the_kind_round_trips_through_config_syntax() {
169        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
170        struct Section {
171            credential_store: CredentialStoreKind,
172        }
173
174        let parsed: Section = toml::from_str("credential_store = \"keychain\"\n").unwrap();
175        assert_eq!(parsed.credential_store, CredentialStoreKind::Keychain);
176        assert_eq!(
177            toml::to_string(&Section {
178                credential_store: CredentialStoreKind::File
179            })
180            .unwrap()
181            .trim(),
182            "credential_store = \"file\""
183        );
184        assert!(toml::from_str::<Section>("credential_store = \"vault\"\n").is_err());
185    }
186
187    #[test]
188    fn the_memory_store_round_trips_and_reports_absence() {
189        let store = MemoryStore::new();
190        assert_eq!(store.get("a").unwrap(), None);
191        store.set("a", "one").unwrap();
192        assert_eq!(store.get("a").unwrap().as_deref(), Some("one"));
193        store.set("a", "two").unwrap();
194        assert_eq!(store.get("a").unwrap().as_deref(), Some("two"));
195        assert!(store.delete("a").unwrap(), "it was there");
196        assert!(!store.delete("a").unwrap(), "and now it is not");
197        assert!(format!("{:?}", MemoryStore::default()).contains("MemoryStore"));
198    }
199
200    /// `read_all` is the default trait method every backend inherits: present
201    /// accounts come back, absent ones are simply not in the map.
202    #[test]
203    fn read_all_returns_only_the_accounts_that_exist() {
204        let store = MemoryStore::new();
205        store.set(&provider_account("openai"), "sk-openai").unwrap();
206
207        let found = store.read_all(&[
208            provider_account("openai"),
209            provider_account("google"),
210            mcp_account("github"),
211        ]);
212
213        assert_eq!(found.len(), 1);
214        assert_eq!(found[&provider_account("openai")], "sk-openai");
215    }
216
217    /// One unreadable entry must not blank out the rest.
218    #[test]
219    fn read_all_skips_an_entry_the_store_cannot_return() {
220        struct Broken;
221        impl CredentialStore for Broken {
222            fn get(&self, account: &str) -> Result<Option<String>, String> {
223                if account == "good" {
224                    Ok(Some("v".into()))
225                } else {
226                    Err("locked".into())
227                }
228            }
229            fn set(&self, _: &str, _: &str) -> Result<(), String> {
230                Ok(())
231            }
232            fn delete(&self, _: &str) -> Result<bool, String> {
233                Ok(false)
234            }
235        }
236
237        let found = Broken.read_all(&["good".into(), "bad".into()]);
238        assert_eq!(found.len(), 1, "the readable entry still comes back");
239        assert!(found.contains_key("good"));
240
241        // `read_all` is the default trait method; the other two are the stub's
242        // own, and a store implementing the trait has to answer all three.
243        assert!(Broken.set("good", "v").is_ok());
244        assert!(!Broken.delete("good").unwrap());
245    }
246
247    /// A poisoned lock must not turn a readable store into a second panic.
248    #[test]
249    fn the_memory_store_survives_a_poisoned_lock() {
250        let store = std::sync::Arc::new(MemoryStore::new());
251        store.set("a", "one").unwrap();
252
253        let poisoner = std::sync::Arc::clone(&store);
254        let _ = std::thread::spawn(move || {
255            let _held = poisoner.lock();
256            panic!("poison the lock");
257        })
258        .join();
259
260        assert_eq!(store.get("a").unwrap().as_deref(), Some("one"));
261    }
262}