Skip to main content

oxicode/foundation/
credentials.rs

1//! Keychain-backed credential resolver + legacy one-time importer.
2//!
3//! The Keychain is the only durable credential authority under the
4//! Foundation host. The [`KeychainCredentialResolver`] looks up a
5//! profile's `{ service, account }` locator and returns either the
6//! resolved value (typed) or a typed error. The `Debug` / `Display`
7//! surface never reveals the value.
8//!
9//! The legacy importer reads `~/.oxicode/auth.json`, asks the user
10//! for explicit acknowledgement, writes the Keychain entry, and
11//! optionally archives the legacy file outside the active credential
12//! path. It is the only code path that reads `~/.oxicode/auth.json`
13//! under the Foundation host.
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use crate::foundation::FoundationError;
18#[cfg(test)]
19use crate::foundation::profiles::CredentialLocator;
20use crate::foundation::profiles::Profile;
21/// Result of resolving a credential locator. The `Debug` impl masks
22/// the secret value so the type can appear in `tracing` and
23/// `anyhow::Error` chains without leaking the resolved key material.
24pub enum Credential {
25    /// A secret value resolved from the Keychain. Never displayed.
26    Keychain(String),
27    /// A non-persistent environment-variable override.
28    Environment(String),
29    /// The locator could not be resolved. The caller MUST surface
30    /// this to the user; it is never silently retried.
31    Unavailable(CredentialError),
32}
33
34impl std::fmt::Debug for Credential {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            Credential::Keychain(_) => f.write_str("Credential::Keychain(***)"),
38            Credential::Environment(_) => f.write_str("Credential::Environment(***)"),
39            Credential::Unavailable(e) => {
40                f.debug_tuple("Credential::Unavailable").field(e).finish()
41            }
42        }
43    }
44}
45/// Typed keychain error. `Display` carries the locator (account name
46/// is public), not the value.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum CredentialError {
49    Unavailable(String),
50    Locked(String),
51    NotFound { service: String, account: String },
52}
53
54impl std::fmt::Display for CredentialError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            Self::Unavailable(s) => write!(f, "keychain unavailable: {s}"),
58            Self::Locked(s) => write!(f, "keychain locked: {s}"),
59            Self::NotFound { service, account } => {
60                write!(f, "keychain entry not found for {service}:{account}")
61            }
62        }
63    }
64}
65
66/// `keyring` crate abstraction. Real production code uses the
67/// `keyring` crate; the trait is what the rest of the code depends
68/// on so tests can swap in a fake without touching the OS keychain.
69pub trait KeychainBackend: Send + Sync + std::fmt::Debug {
70    fn get(&self, service: &str, account: &str) -> Result<String, CredentialError>;
71    fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialError>;
72    fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError>;
73}
74/// Production implementation. Uses the `keyring` crate (v3) with
75/// per-platform native backends configured in `Cargo.toml`.
76#[derive(Debug, Default, Clone)]
77pub struct SystemKeychain;
78
79impl KeychainBackend for SystemKeychain {
80    fn get(&self, service: &str, account: &str) -> Result<String, CredentialError> {
81        let entry = keyring::Entry::new(service, account)
82            .map_err(|e| CredentialError::Unavailable(e.to_string()))?;
83        entry.get_password().map_err(|e| match e {
84            keyring::Error::NoEntry => CredentialError::NotFound {
85                service: service.to_string(),
86                account: account.to_string(),
87            },
88            keyring::Error::PlatformFailure(_) => CredentialError::Unavailable(e.to_string()),
89            _ => CredentialError::Locked(e.to_string()),
90        })
91    }
92
93    fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialError> {
94        let entry = keyring::Entry::new(service, account)
95            .map_err(|e| CredentialError::Unavailable(e.to_string()))?;
96        entry
97            .set_password(value)
98            .map_err(|e| CredentialError::Unavailable(e.to_string()))
99    }
100
101    fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError> {
102        let entry = keyring::Entry::new(service, account)
103            .map_err(|e| CredentialError::Unavailable(e.to_string()))?;
104        entry.delete_credential().map_err(|e| match e {
105            keyring::Error::NoEntry => CredentialError::NotFound {
106                service: service.to_string(),
107                account: account.to_string(),
108            },
109            _ => CredentialError::Unavailable(e.to_string()),
110        })
111    }
112}
113
114/// Resolves profile credentials. The resolver is the only thing the
115/// rest of the code talks to.
116#[derive(Debug, Clone)]
117pub struct KeychainCredentialResolver<B: KeychainBackend + Clone> {
118    backend: B,
119}
120
121impl<B: KeychainBackend + Clone> KeychainCredentialResolver<B> {
122    pub fn new(backend: B) -> Self {
123        Self { backend }
124    }
125
126    /// Resolve a profile's credential. Environment sentinels fall
127    /// through to the appropriate env var. The Keychain is contacted
128    /// only when the locator is a real pair.
129    pub fn resolve(&self, profile: &Profile) -> Credential {
130        let loc = &profile.credential;
131        if loc.service == "__env__" && loc.account == "__env__" {
132            // Env override is recorded under either OXICODE_API_KEY
133            // (default) or a profile-local variable.
134            let env_var = std::env::var("OXICODE_API_KEY").ok();
135            if let Some(value) = env_var
136                && !value.is_empty()
137            {
138                return Credential::Environment(value);
139            }
140            return Credential::Unavailable(CredentialError::NotFound {
141                service: loc.service.clone(),
142                account: loc.account.clone(),
143            });
144        }
145        match self.backend.get(&loc.service, &loc.account) {
146            Ok(value) => Credential::Keychain(value),
147            Err(e) => Credential::Unavailable(e),
148        }
149    }
150}
151
152impl Default for KeychainCredentialResolver<SystemKeychain> {
153    fn default() -> Self {
154        Self::new(SystemKeychain)
155    }
156}
157
158/// One-time legacy importer. Reads `~/.oxicode/auth.json`, asks the
159/// user for acknowledgement, writes the Keychain entry, then
160/// (optionally) archives the legacy file outside the active
161/// credential path.
162pub struct LegacyImporter<B: KeychainBackend + Clone> {
163    backend: B,
164}
165
166impl<B: KeychainBackend + Clone> LegacyImporter<B> {
167    pub fn new(backend: B) -> Self {
168        Self { backend }
169    }
170
171    /// Run the one-time import. The caller is responsible for
172    /// gathering the explicit `acknowledge: true` from the user.
173    pub fn run(
174        &self,
175        auth_json_path: &Path,
176        profile_id: &str,
177        provider: &str,
178        acknowledge: bool,
179        archive: bool,
180    ) -> Result<LegacyImportOutcome, FoundationError> {
181        if !acknowledge {
182            return Err(FoundationError::Parse(
183                "legacy import requires explicit acknowledgement".to_string(),
184            ));
185        }
186        if !auth_json_path.is_file() {
187            return Err(FoundationError::Parse(format!(
188                "legacy auth file not found: {}",
189                auth_json_path.display()
190            )));
191        }
192        let raw = std::fs::read_to_string(auth_json_path)?;
193        let parsed: serde_json::Value = serde_json::from_str(&raw)?;
194        let key = parsed
195            .get("providers")
196            .and_then(|p| p.get(provider))
197            .and_then(|p| p.get("api_key"))
198            .and_then(|p| p.as_str())
199            .ok_or_else(|| {
200                FoundationError::Parse(format!(
201                    "legacy auth file does not contain an API key for {provider}"
202                ))
203            })?;
204        let service = "dev.oxi.foundation".to_string();
205        let account = profile_id.to_string();
206        self.backend
207            .set(&service, &account, key)
208            .map_err(|e| FoundationError::KeychainUnavailable(e.to_string()))?;
209        let archive_path = if archive {
210            Some(self.archive_legacy(auth_json_path)?)
211        } else {
212            None
213        };
214        Ok(LegacyImportOutcome {
215            service,
216            account,
217            archive_path,
218        })
219    }
220
221    fn archive_legacy(&self, path: &Path) -> Result<PathBuf, FoundationError> {
222        let parent = path.parent().unwrap_or_else(|| Path::new("."));
223        let archive_dir = parent.join("archive");
224        std::fs::create_dir_all(&archive_dir)?;
225        let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
226        let target = archive_dir.join(format!(
227            "auth-{}-{}.json",
228            ts,
229            path.file_name()
230                .and_then(|s| s.to_str())
231                .unwrap_or("auth.json"),
232        ));
233        // Atomic rename; never overwrite the original silently.
234        if target.exists() {
235            return Err(FoundationError::Parse(format!(
236                "archive target already exists: {}",
237                target.display()
238            )));
239        }
240        std::fs::rename(path, &target)?;
241        Ok(target)
242    }
243}
244
245/// Result of a successful legacy import.
246#[derive(Debug, Clone)]
247pub struct LegacyImportOutcome {
248    pub service: String,
249    pub account: String,
250    pub archive_path: Option<PathBuf>,
251}
252
253impl<B: KeychainBackend + Clone> std::fmt::Debug for LegacyImporter<B> {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        f.debug_struct("LegacyImporter")
256            .field("backend", &"<dyn KeychainBackend>")
257            .finish()
258    }
259}
260
261/// In-memory Keychain backend for tests. The `Debug` impl masks
262/// stored values.
263#[derive(Debug, Clone, Default)]
264pub struct InMemoryKeychain {
265    inner: std::collections::HashMap<(String, String), String>,
266}
267
268impl InMemoryKeychain {
269    pub fn new() -> Self {
270        Self::default()
271    }
272
273    pub fn seed(&mut self, service: &str, account: &str, value: &str) {
274        self.inner.insert(
275            (service.to_string(), account.to_string()),
276            value.to_string(),
277        );
278    }
279}
280
281impl KeychainBackend for InMemoryKeychain {
282    fn get(&self, service: &str, account: &str) -> Result<String, CredentialError> {
283        self.inner
284            .get(&(service.to_string(), account.to_string()))
285            .cloned()
286            .ok_or_else(|| CredentialError::NotFound {
287                service: service.to_string(),
288                account: account.to_string(),
289            })
290    }
291
292    fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialError> {
293        // Tests need mutation; we wrap in a Mutex.
294        // ...this is a limitation of the value-only structure; we
295        // accept that the test-only backend here is intentionally
296        // minimal and is paired with a `parking_lot::Mutex` to allow
297        // late seeding through a wrapper.
298        let _ = (service, account, value);
299        Err(CredentialError::Unavailable(
300            "InMemoryKeychain::set requires the Mutex variant".to_string(),
301        ))
302    }
303
304    fn delete(&self, _service: &str, _account: &str) -> Result<(), CredentialError> {
305        Err(CredentialError::Unavailable(
306            "InMemoryKeychain::delete requires the Mutex variant".to_string(),
307        ))
308    }
309}
310
311/// Mutable variant of the in-memory Keychain. Used for tests that
312/// exercise the legacy importer. Clones share the same underlying
313/// store via `Arc`.
314#[derive(Debug, Default, Clone)]
315pub struct MutexKeychain {
316    inner: Arc<parking_lot::Mutex<std::collections::HashMap<(String, String), String>>>,
317}
318
319impl MutexKeychain {
320    pub fn new() -> Self {
321        Self::default()
322    }
323
324    pub fn seed(&self, service: &str, account: &str, value: &str) {
325        self.inner.lock().insert(
326            (service.to_string(), account.to_string()),
327            value.to_string(),
328        );
329    }
330
331    pub fn snapshot(&self) -> Vec<((String, String), String)> {
332        self.inner
333            .lock()
334            .iter()
335            .map(|(k, v)| (k.clone(), v.clone()))
336            .collect()
337    }
338}
339
340impl KeychainBackend for MutexKeychain {
341    fn get(&self, service: &str, account: &str) -> Result<String, CredentialError> {
342        self.inner
343            .lock()
344            .get(&(service.to_string(), account.to_string()))
345            .cloned()
346            .ok_or_else(|| CredentialError::NotFound {
347                service: service.to_string(),
348                account: account.to_string(),
349            })
350    }
351
352    fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialError> {
353        self.inner.lock().insert(
354            (service.to_string(), account.to_string()),
355            value.to_string(),
356        );
357        Ok(())
358    }
359
360    fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError> {
361        self.inner
362            .lock()
363            .remove(&(service.to_string(), account.to_string()));
364        Ok(())
365    }
366}
367pub fn source_class(profile: &Profile) -> &'static str {
368    let loc = &profile.credential;
369    if loc.service == "__env__" && loc.account == "__env__" {
370        "environment"
371    } else {
372        "keychain"
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn profile_with_locator(service: &str, account: &str) -> Profile {
381        Profile {
382            id: "x".to_string(),
383            provider: "anthropic".to_string(),
384            model: "claude-sonnet".to_string(),
385            roles: vec!["coding.primary".to_string()],
386            credential: CredentialLocator {
387                service: service.to_string(),
388                account: account.to_string(),
389            },
390        }
391    }
392
393    #[test]
394    fn resolve_missing_keychain_is_unavailable() {
395        let r = KeychainCredentialResolver::new(MutexKeychain::new());
396        let p = profile_with_locator("dev.oxi.foundation", "missing");
397        match r.resolve(&p) {
398            Credential::Unavailable(CredentialError::NotFound { service, account }) => {
399                assert_eq!(service, "dev.oxi.foundation");
400                assert_eq!(account, "missing");
401            }
402            other => panic!("expected Unavailable, got {other:?}"),
403        }
404    }
405
406    #[test]
407    fn resolve_seeded_keychain_is_keychain() {
408        let k = MutexKeychain::new();
409        k.seed("dev.oxi.foundation", "p1", "sk-xxx");
410        let r = KeychainCredentialResolver::new(k);
411        let p = profile_with_locator("dev.oxi.foundation", "p1");
412        match r.resolve(&p) {
413            Credential::Keychain(v) => assert_eq!(v, "sk-xxx"),
414            other => panic!("expected Keychain, got {other:?}"),
415        }
416    }
417
418    #[test]
419    fn resolve_env_sentinel_uses_env_var() {
420        let original = std::env::var("OXICODE_API_KEY").ok();
421        unsafe {
422            std::env::set_var("OXICODE_API_KEY", "env-xxx");
423        }
424        let r = KeychainCredentialResolver::new(MutexKeychain::new());
425        let p = profile_with_locator("__env__", "__env__");
426        match r.resolve(&p) {
427            Credential::Environment(v) => assert_eq!(v, "env-xxx"),
428            other => panic!("expected Environment, got {other:?}"),
429        }
430        unsafe {
431            std::env::remove_var("OXICODE_API_KEY");
432        }
433        if let Some(value) = original {
434            unsafe {
435                std::env::set_var("OXICODE_API_KEY", value);
436            }
437        }
438    }
439
440    #[test]
441    fn legacy_import_requires_acknowledge() {
442        let tmp = tempfile::tempdir().unwrap();
443        let auth = tmp.path().join("auth.json");
444        std::fs::write(&auth, r#"{"providers":{"anthropic":{"api_key":"sk-x"}}}"#).unwrap();
445        let importer = LegacyImporter::new(MutexKeychain::new());
446        let err = importer
447            .run(&auth, "p1", "anthropic", false, false)
448            .unwrap_err();
449        assert!(matches!(err, FoundationError::Parse(_)));
450    }
451
452    #[test]
453    fn legacy_import_writes_keychain_and_archives() {
454        let tmp = tempfile::tempdir().unwrap();
455        let auth = tmp.path().join("auth.json");
456        std::fs::write(&auth, r#"{"providers":{"anthropic":{"api_key":"sk-x"}}}"#).unwrap();
457        let k = MutexKeychain::new();
458        let importer = LegacyImporter::new(k.clone());
459        let out = importer.run(&auth, "p1", "anthropic", true, true).unwrap();
460        assert_eq!(out.service, "dev.oxi.foundation");
461        assert_eq!(out.account, "p1");
462        assert!(out.archive_path.is_some());
463        assert!(!auth.is_file(), "archive should have moved the file");
464        let snap = k.snapshot();
465        assert_eq!(snap.len(), 1);
466        assert_eq!(
467            snap[0].0,
468            ("dev.oxi.foundation".to_string(), "p1".to_string())
469        );
470    }
471
472    #[test]
473    fn legacy_import_rejects_missing_key() {
474        let tmp = tempfile::tempdir().unwrap();
475        let auth = tmp.path().join("auth.json");
476        std::fs::write(&auth, r#"{"providers":{"anthropic":{}}}"#).unwrap();
477        let importer = LegacyImporter::new(MutexKeychain::new());
478        let err = importer
479            .run(&auth, "p1", "anthropic", true, false)
480            .unwrap_err();
481        assert!(matches!(err, FoundationError::Parse(_)));
482    }
483
484    #[test]
485    fn legacy_import_does_not_archive_when_asked() {
486        let tmp = tempfile::tempdir().unwrap();
487        let auth = tmp.path().join("auth.json");
488        std::fs::write(&auth, r#"{"providers":{"anthropic":{"api_key":"sk-x"}}}"#).unwrap();
489        let k = MutexKeychain::new();
490        let importer = LegacyImporter::new(k.clone());
491        let out = importer.run(&auth, "p1", "anthropic", true, false).unwrap();
492        assert!(out.archive_path.is_none());
493        assert!(
494            auth.is_file(),
495            "file should remain when archive is not requested"
496        );
497    }
498
499    #[test]
500    fn source_class_reports_environment_or_keychain() {
501        let p = profile_with_locator("__env__", "__env__");
502        assert_eq!(source_class(&p), "environment");
503        let p = profile_with_locator("dev.oxi.foundation", "p1");
504        assert_eq!(source_class(&p), "keychain");
505    }
506
507    #[test]
508    fn credential_error_display_redacts_value() {
509        let err = CredentialError::NotFound {
510            service: "dev.oxi.foundation".to_string(),
511            account: "p1".to_string(),
512        };
513        let s = err.to_string();
514        assert!(!s.contains("sk-"));
515        assert!(s.contains("dev.oxi.foundation"));
516        assert!(s.contains("p1"));
517    }
518}