Skip to main content

deepseek_secrets/
lib.rs

1//! Secret storage for DeepSeek API keys.
2//!
3//! Provides a small abstraction (`KeyringStore`) plus a default
4//! implementation backed by the OS keyring (`DefaultKeyringStore`),
5//! a file-based fallback for headless Linux (`FileKeyringStore`), and
6//! an in-memory store for tests (`InMemoryKeyringStore`).
7//!
8//! Higher-level lookup goes through [`Secrets::resolve`], which checks
9//! the keyring first and falls back to environment variables. The
10//! caller (typically the config crate) then falls back to plaintext
11//! TOML if both are empty — that final layer lives outside this crate
12//! so the precedence is explicit at the call site.
13//!
14//! Hard rule: **keyring → env → config-file**. Never swap.
15#![deny(missing_docs)]
16
17use std::collections::HashMap;
18use std::fs;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21
22use serde::{Deserialize, Serialize};
23use thiserror::Error;
24
25/// Default OS keychain service name. macOS users can verify entries with
26/// `security find-generic-password -s deepseek -a <provider>`.
27pub const DEFAULT_SERVICE: &str = "deepseek";
28
29/// Errors that may arise from a [`KeyringStore`] backend.
30#[derive(Debug, Error)]
31pub enum SecretsError {
32    /// Underlying OS keyring backend reported an error.
33    #[error("keyring backend error: {0}")]
34    Keyring(String),
35    /// File-backed fallback I/O error.
36    #[error("file-backed secret store I/O error: {0}")]
37    Io(#[from] std::io::Error),
38    /// File-backed fallback JSON (de)serialisation error.
39    #[error("file-backed secret store JSON error: {0}")]
40    Json(#[from] serde_json::Error),
41    /// Caught when a stored secret on disk has unsafe permissions.
42    #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
43    InsecurePermissions {
44        /// Absolute path to the secrets file.
45        path: PathBuf,
46        /// Observed unix permission mode.
47        mode: u32,
48    },
49}
50
51/// Abstract secret store; concrete implementations may use the OS
52/// keyring, a JSON file under `~/.deepseek/secrets/`, or an in-memory
53/// map (tests).
54pub trait KeyringStore: Send + Sync {
55    /// Read a secret. Returns `Ok(None)` if no entry exists.
56    fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
57    /// Write a secret, replacing any existing value.
58    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
59    /// Remove a secret. Should not error if the entry is absent.
60    fn delete(&self, key: &str) -> Result<(), SecretsError>;
61    /// Short, human-readable name of the backend (used by `doctor`).
62    fn backend_name(&self) -> &'static str;
63}
64
65/// OS keyring backend (macOS Keychain, Windows Credential Manager,
66/// Linux Secret Service / kwallet).
67#[derive(Debug, Clone)]
68pub struct DefaultKeyringStore {
69    /// Keyring service name (defaults to [`DEFAULT_SERVICE`]).
70    service: String,
71}
72
73impl Default for DefaultKeyringStore {
74    fn default() -> Self {
75        Self::new(DEFAULT_SERVICE)
76    }
77}
78
79impl DefaultKeyringStore {
80    /// Build a new store with the given service name.
81    #[must_use]
82    pub fn new(service: impl Into<String>) -> Self {
83        Self {
84            service: service.into(),
85        }
86    }
87
88    /// Probe the OS keyring without writing anything. Returns `Ok(())` if
89    /// a backend is reachable, otherwise an error describing why not.
90    pub fn probe(&self) -> Result<(), SecretsError> {
91        // `Entry::new` is enough to surface "no backend / no storage" on
92        // headless Linux; no actual read happens until `.get_password()`.
93        let entry = keyring::Entry::new(&self.service, "__probe__")
94            .map_err(|err| SecretsError::Keyring(err.to_string()))?;
95        match entry.get_password() {
96            Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
97            Err(keyring::Error::PlatformFailure(err)) => {
98                Err(SecretsError::Keyring(format!("platform failure: {err}")))
99            }
100            Err(keyring::Error::NoStorageAccess(err)) => {
101                Err(SecretsError::Keyring(format!("no storage access: {err}")))
102            }
103            Err(other) => Err(SecretsError::Keyring(other.to_string())),
104        }
105    }
106}
107
108impl KeyringStore for DefaultKeyringStore {
109    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
110        let entry = keyring::Entry::new(&self.service, key)
111            .map_err(|err| SecretsError::Keyring(err.to_string()))?;
112        match entry.get_password() {
113            Ok(value) => Ok(Some(value)),
114            Err(keyring::Error::NoEntry) => Ok(None),
115            Err(err) => Err(SecretsError::Keyring(err.to_string())),
116        }
117    }
118
119    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
120        let entry = keyring::Entry::new(&self.service, key)
121            .map_err(|err| SecretsError::Keyring(err.to_string()))?;
122        entry
123            .set_password(value)
124            .map_err(|err| SecretsError::Keyring(err.to_string()))
125    }
126
127    fn delete(&self, key: &str) -> Result<(), SecretsError> {
128        let entry = keyring::Entry::new(&self.service, key)
129            .map_err(|err| SecretsError::Keyring(err.to_string()))?;
130        match entry.delete_credential() {
131            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
132            Err(err) => Err(SecretsError::Keyring(err.to_string())),
133        }
134    }
135
136    fn backend_name(&self) -> &'static str {
137        "system keyring"
138    }
139}
140
141/// In-memory keyring (tests only).
142#[derive(Debug, Default)]
143pub struct InMemoryKeyringStore {
144    entries: Mutex<HashMap<String, String>>,
145}
146
147impl InMemoryKeyringStore {
148    /// Create an empty store.
149    #[must_use]
150    pub fn new() -> Self {
151        Self::default()
152    }
153}
154
155impl KeyringStore for InMemoryKeyringStore {
156    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
157        Ok(self.entries.lock().unwrap().get(key).cloned())
158    }
159
160    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
161        self.entries
162            .lock()
163            .unwrap()
164            .insert(key.to_string(), value.to_string());
165        Ok(())
166    }
167
168    fn delete(&self, key: &str) -> Result<(), SecretsError> {
169        self.entries.lock().unwrap().remove(key);
170        Ok(())
171    }
172
173    fn backend_name(&self) -> &'static str {
174        "in-memory (test)"
175    }
176}
177
178/// JSON-on-disk fallback for headless environments without a Secret
179/// Service / dbus. Stored at `<home>/.deepseek/secrets/secrets.json`
180/// with mode `0600`.
181#[derive(Debug, Clone)]
182pub struct FileKeyringStore {
183    /// Absolute path to the JSON file.
184    path: PathBuf,
185}
186
187#[derive(Debug, Default, Serialize, Deserialize)]
188struct FileSecretsBlob {
189    #[serde(default)]
190    entries: HashMap<String, String>,
191}
192
193impl FileKeyringStore {
194    /// Build a store backed by the given JSON file path.
195    #[must_use]
196    pub fn new(path: impl Into<PathBuf>) -> Self {
197        Self { path: path.into() }
198    }
199
200    /// Default path: `<home>/.deepseek/secrets/secrets.json`. Honours
201    /// `HOME` (Unix) and `USERPROFILE` (Windows) via the `dirs` crate.
202    pub fn default_path() -> Result<PathBuf, SecretsError> {
203        let home = dirs::home_dir().ok_or_else(|| {
204            SecretsError::Io(std::io::Error::new(
205                std::io::ErrorKind::NotFound,
206                "could not resolve home directory for FileKeyringStore",
207            ))
208        })?;
209        Ok(home.join(".deepseek").join("secrets").join("secrets.json"))
210    }
211
212    /// Path used for storage.
213    #[must_use]
214    pub fn path(&self) -> &Path {
215        &self.path
216    }
217
218    fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
219        if !self.path.exists() {
220            return Ok(FileSecretsBlob::default());
221        }
222        // Reject files with unsafe permissions on unix. On Windows the
223        // ACL model is too different to enforce here; the caller is
224        // responsible for placing the file in a per-user directory.
225        #[cfg(unix)]
226        {
227            use std::os::unix::fs::PermissionsExt;
228            let meta = fs::metadata(&self.path)?;
229            let mode = meta.permissions().mode() & 0o777;
230            if mode & 0o077 != 0 {
231                return Err(SecretsError::InsecurePermissions {
232                    path: self.path.clone(),
233                    mode,
234                });
235            }
236        }
237        let raw = fs::read_to_string(&self.path)?;
238        if raw.trim().is_empty() {
239            return Ok(FileSecretsBlob::default());
240        }
241        let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
242        Ok(blob)
243    }
244
245    fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
246        if let Some(parent) = self.path.parent() {
247            fs::create_dir_all(parent)?;
248            #[cfg(unix)]
249            {
250                use std::os::unix::fs::PermissionsExt;
251                let mut perms = fs::metadata(parent)?.permissions();
252                perms.set_mode(0o700);
253                let _ = fs::set_permissions(parent, perms);
254            }
255        }
256        let body = serde_json::to_string_pretty(blob)?;
257        fs::write(&self.path, body)?;
258        #[cfg(unix)]
259        {
260            use std::os::unix::fs::PermissionsExt;
261            let mut perms = fs::metadata(&self.path)?.permissions();
262            perms.set_mode(0o600);
263            fs::set_permissions(&self.path, perms)?;
264        }
265        Ok(())
266    }
267}
268
269impl KeyringStore for FileKeyringStore {
270    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
271        let blob = self.load_unlocked()?;
272        Ok(blob.entries.get(key).cloned())
273    }
274
275    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
276        let mut blob = self.load_unlocked().unwrap_or_default();
277        blob.entries.insert(key.to_string(), value.to_string());
278        self.store_unlocked(&blob)
279    }
280
281    fn delete(&self, key: &str) -> Result<(), SecretsError> {
282        let mut blob = self.load_unlocked().unwrap_or_default();
283        blob.entries.remove(key);
284        self.store_unlocked(&blob)
285    }
286
287    fn backend_name(&self) -> &'static str {
288        "file-based (~/.deepseek/secrets/)"
289    }
290}
291
292/// High-level façade combining a [`KeyringStore`] with environment
293/// variable fallbacks.
294///
295/// Lookup precedence: **keyring → env → none**. Callers that also have
296/// a TOML config layer must wire that themselves at the very end of
297/// the chain.
298#[derive(Clone)]
299pub struct Secrets {
300    /// Underlying secret store.
301    pub store: Arc<dyn KeyringStore>,
302    /// Owner identifier within the keyring (typically "deepseek"); the
303    /// `key` parameter passed to `resolve` is mapped to a slot in the
304    /// store as-is, while envs are looked up by canonical name.
305    service: String,
306}
307
308impl std::fmt::Debug for Secrets {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        f.debug_struct("Secrets")
311            .field("backend", &self.store.backend_name())
312            .field("service", &self.service)
313            .finish()
314    }
315}
316
317impl Secrets {
318    /// Build a new façade around a store.
319    #[must_use]
320    pub fn new(store: Arc<dyn KeyringStore>) -> Self {
321        Self {
322            store,
323            service: DEFAULT_SERVICE.to_string(),
324        }
325    }
326
327    /// Construct the platform-appropriate default backend. On platforms
328    /// where an OS keyring backend is reachable this returns
329    /// [`DefaultKeyringStore`]; otherwise it falls back to
330    /// [`FileKeyringStore`] under `~/.deepseek/secrets/`.
331    pub fn auto_detect() -> Self {
332        let default_store = DefaultKeyringStore::default();
333        match default_store.probe() {
334            Ok(()) => Self::new(Arc::new(default_store)),
335            Err(err) => {
336                tracing::warn!(
337                    "OS keyring unavailable ({err}); falling back to file-backed secret store"
338                );
339                let path = FileKeyringStore::default_path()
340                    .unwrap_or_else(|_| PathBuf::from(".deepseek-secrets.json"));
341                Self::new(Arc::new(FileKeyringStore::new(path)))
342            }
343        }
344    }
345
346    /// Backend label, suitable for `doctor` output.
347    #[must_use]
348    pub fn backend_name(&self) -> &'static str {
349        self.store.backend_name()
350    }
351
352    /// Resolve a secret with `keyring → env → none` precedence.
353    ///
354    /// `name` is the canonical provider name (`"deepseek"`,
355    /// `"openrouter"`, `"novita"`, `"nvidia"`/`"nvidia-nim"`, `"openai"`).
356    /// Empty strings on either layer are treated as "not set".
357    #[must_use]
358    pub fn resolve(&self, name: &str) -> Option<String> {
359        if let Ok(Some(v)) = self.store.get(name)
360            && !v.trim().is_empty()
361        {
362            return Some(v);
363        }
364        env_for(name)
365    }
366
367    /// Convenience: write a secret through the underlying store.
368    pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
369        self.store.set(name, value)
370    }
371
372    /// Convenience: delete a secret through the underlying store.
373    pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
374        self.store.delete(name)
375    }
376
377    /// Convenience: read a secret directly (no env fallback).
378    pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
379        self.store.get(name)
380    }
381}
382
383/// Map a canonical provider name to its environment variable, returning
384/// the value if non-empty.
385#[must_use]
386pub fn env_for(name: &str) -> Option<String> {
387    let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
388        "deepseek" => &["DEEPSEEK_API_KEY"],
389        "openrouter" => &["OPENROUTER_API_KEY"],
390        "novita" => &["NOVITA_API_KEY"],
391        // NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
392        // catalog endpoint accepts the same DeepSeek-issued key when no
393        // dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
394        "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
395            &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
396        }
397        "openai" => &["OPENAI_API_KEY"],
398        _ => return None,
399    };
400    for var in candidates {
401        if let Ok(value) = std::env::var(var)
402            && !value.trim().is_empty()
403        {
404            return Some(value);
405        }
406    }
407    None
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use std::sync::{Mutex, OnceLock};
414
415    /// Serialise env-mutating tests: tests in this module poke
416    /// `DEEPSEEK_API_KEY` etc., which is process-global.
417    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
418        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
419        LOCK.get_or_init(|| Mutex::new(()))
420            .lock()
421            .unwrap_or_else(|p| p.into_inner())
422    }
423
424    fn clear_known_envs() {
425        for var in [
426            "DEEPSEEK_API_KEY",
427            "OPENROUTER_API_KEY",
428            "NOVITA_API_KEY",
429            "NVIDIA_API_KEY",
430            "NVIDIA_NIM_API_KEY",
431            "OPENAI_API_KEY",
432        ] {
433            // Safety: tests serialise on env_lock(); the broader
434            // workspace has the same pattern in `crates/config`.
435            unsafe { std::env::remove_var(var) };
436        }
437    }
438
439    #[test]
440    fn in_memory_store_round_trips() {
441        let store = InMemoryKeyringStore::new();
442        assert_eq!(store.get("deepseek").unwrap(), None);
443        store.set("deepseek", "sk-test").unwrap();
444        assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
445        store.set("deepseek", "sk-replaced").unwrap();
446        assert_eq!(
447            store.get("deepseek").unwrap(),
448            Some("sk-replaced".to_string())
449        );
450        store.delete("deepseek").unwrap();
451        assert_eq!(store.get("deepseek").unwrap(), None);
452        // Deleting an absent key is a no-op.
453        store.delete("missing").unwrap();
454    }
455
456    #[test]
457    fn resolve_prefers_keyring_over_env() {
458        let _lock = env_lock();
459        clear_known_envs();
460        // Safety: env mutation guarded by env_lock().
461        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
462
463        let store = Arc::new(InMemoryKeyringStore::new());
464        store.set("deepseek", "ring-key").unwrap();
465        let secrets = Secrets::new(store);
466
467        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
468        // Safety: env mutation guarded by env_lock().
469        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
470    }
471
472    #[test]
473    fn resolve_falls_back_to_env_when_keyring_empty() {
474        let _lock = env_lock();
475        clear_known_envs();
476        // Safety: env mutation guarded by env_lock().
477        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
478
479        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
480        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
481        // Safety: env mutation guarded by env_lock().
482        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
483    }
484
485    #[test]
486    fn resolve_returns_none_when_both_layers_empty() {
487        let _lock = env_lock();
488        clear_known_envs();
489        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
490        assert_eq!(secrets.resolve("deepseek"), None);
491    }
492
493    #[test]
494    fn resolve_treats_blank_keyring_value_as_unset() {
495        let _lock = env_lock();
496        clear_known_envs();
497        // Safety: env mutation guarded by env_lock().
498        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
499
500        let store = Arc::new(InMemoryKeyringStore::new());
501        store.set("deepseek", "   ").unwrap();
502        let secrets = Secrets::new(store);
503        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
504        // Safety: env mutation guarded by env_lock().
505        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
506    }
507
508    #[test]
509    fn nvidia_env_aliases_resolve() {
510        let _lock = env_lock();
511        clear_known_envs();
512        // Safety: env mutation guarded by env_lock().
513        unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
514        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
515        assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
516        assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
517        // Safety: env mutation guarded by env_lock().
518        unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
519    }
520
521    #[cfg(unix)]
522    #[test]
523    fn file_store_round_trips_with_secure_perms() {
524        use std::os::unix::fs::PermissionsExt;
525
526        let tmp = tempfile::tempdir().unwrap();
527        let path = tmp.path().join("nested").join("secrets.json");
528        let store = FileKeyringStore::new(path.clone());
529        assert_eq!(store.get("deepseek").unwrap(), None);
530        store.set("deepseek", "sk-disk").unwrap();
531        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
532
533        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
534        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
535
536        store.set("openrouter", "or-disk").unwrap();
537        assert_eq!(
538            store.get("openrouter").unwrap(),
539            Some("or-disk".to_string())
540        );
541        // First entry must still be intact.
542        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
543
544        store.delete("deepseek").unwrap();
545        assert_eq!(store.get("deepseek").unwrap(), None);
546    }
547
548    #[cfg(unix)]
549    #[test]
550    fn file_store_rejects_world_readable_file() {
551        use std::os::unix::fs::PermissionsExt;
552        let tmp = tempfile::tempdir().unwrap();
553        let path = tmp.path().join("secrets.json");
554        fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
555        let mut perms = fs::metadata(&path).unwrap().permissions();
556        perms.set_mode(0o644);
557        fs::set_permissions(&path, perms).unwrap();
558
559        let store = FileKeyringStore::new(path);
560        let err = store.get("deepseek").unwrap_err();
561        assert!(
562            matches!(err, SecretsError::InsecurePermissions { .. }),
563            "unexpected error: {err}"
564        );
565    }
566
567    #[test]
568    fn file_store_default_path_uses_home() {
569        // We don't override HOME here (other tests do); we just check the
570        // shape of the path is `<home>/.deepseek/secrets/secrets.json`.
571        let path = FileKeyringStore::default_path().unwrap();
572        assert!(
573            path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
574            "unexpected default path: {}",
575            path.display()
576        );
577    }
578}