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}