Skip to main content

nika_engine/secrets/
keyring.rs

1//! Secure API key storage via system keychain.
2//!
3//! Uses keyring-rs for cross-platform credential storage:
4//! - macOS: Keychain Access
5//! - Windows: Credential Manager
6//! - Linux: Secret Service (GNOME Keyring, KWallet)
7//!
8//! ## native-keychain Feature
9//!
10//! The keyring crate is optional (via `native-keychain` feature).
11//! Docker builds disable it since containers don't have OS keychains.
12//! When disabled, fallback implementations return errors for keyring operations.
13//!
14
15use secrecy::SecretString;
16use thiserror::Error;
17use zeroize::Zeroizing;
18
19// Imports used only with native-keychain feature
20#[cfg(feature = "native-keychain")]
21use colored::Colorize;
22
23// Use nika::core directly instead of tui::providers::env_var
24#[cfg(feature = "native-keychain")]
25use crate::core::provider_to_env_var;
26
27/// Service name for keyring entries.
28#[cfg(feature = "native-keychain")]
29const SERVICE_NAME: &str = "nika";
30
31/// Keyring error types.
32#[derive(Debug, Error)]
33pub enum KeyringError {
34    #[error("Failed to access keyring: {0}")]
35    AccessError(String),
36    #[error("Key not found for provider: {0}")]
37    NotFound(String),
38    #[error("Failed to store key: {0}")]
39    StoreError(String),
40    #[error("Failed to delete key: {0}")]
41    DeleteError(String),
42}
43
44// ═══════════════════════════════════════════════════════════════════════════════
45// NATIVE KEYCHAIN IMPLEMENTATION (requires OS keychain access)
46// ═══════════════════════════════════════════════════════════════════════════════
47
48#[cfg(feature = "native-keychain")]
49mod native {
50    use super::*;
51    use keyring::Entry;
52
53    /// Keyring wrapper for Nika API keys.
54    pub struct NikaKeyring;
55
56    impl NikaKeyring {
57        /// Get API key for a provider as zeroizing string.
58        ///
59        /// The returned string will be automatically zeroized when dropped.
60        ///
61        /// Returns NotFound error if NIKA_SKIP_KEYCHAIN is set (for CI/testing).
62        pub fn get(provider: &str) -> Result<Zeroizing<String>, KeyringError> {
63            // Skip keychain access if NIKA_SKIP_KEYCHAIN is truthy
64            if super::should_skip_keychain() {
65                return Err(KeyringError::NotFound(format!(
66                    "{} (keychain skipped via NIKA_SKIP_KEYCHAIN)",
67                    provider
68                )));
69            }
70
71            let entry = Entry::new(SERVICE_NAME, provider)
72                .map_err(|e| KeyringError::AccessError(e.to_string()))?;
73
74            let password = entry.get_password().map_err(|e| match e {
75                keyring::Error::NoEntry => KeyringError::NotFound(provider.to_string()),
76                _ => KeyringError::AccessError(e.to_string()),
77            })?;
78
79            Ok(Zeroizing::new(password))
80        }
81
82        /// Get API key wrapped in SecretString for maximum safety.
83        pub fn get_secret(provider: &str) -> Result<SecretString, KeyringError> {
84            let key = Self::get(provider)?;
85            Ok(SecretString::from((*key).clone()))
86        }
87
88        /// Store API key for a provider.
89        ///
90        /// Guarded: returns error in test mode or when NIKA_SKIP_KEYCHAIN is set.
91        pub fn set(provider: &str, key: &str) -> Result<(), KeyringError> {
92            if cfg!(test) || super::should_skip_keychain() {
93                return Err(KeyringError::StoreError(format!(
94                    "{} (keychain skipped)",
95                    provider
96                )));
97            }
98
99            let entry = Entry::new(SERVICE_NAME, provider)
100                .map_err(|e| KeyringError::AccessError(e.to_string()))?;
101
102            entry
103                .set_password(key)
104                .map_err(|e| KeyringError::StoreError(e.to_string()))
105        }
106
107        /// Delete API key for a provider.
108        ///
109        /// Guarded: returns error in test mode or when NIKA_SKIP_KEYCHAIN is set.
110        pub fn delete(provider: &str) -> Result<(), KeyringError> {
111            if cfg!(test) || super::should_skip_keychain() {
112                return Err(KeyringError::DeleteError(format!(
113                    "{} (keychain skipped)",
114                    provider
115                )));
116            }
117
118            let entry = Entry::new(SERVICE_NAME, provider)
119                .map_err(|e| KeyringError::AccessError(e.to_string()))?;
120
121            entry
122                .delete_credential()
123                .map_err(|e| KeyringError::DeleteError(e.to_string()))
124        }
125
126        /// Check if key exists for a provider.
127        pub fn exists(provider: &str) -> bool {
128            Self::get(provider).is_ok()
129        }
130
131        /// Get masked version of stored key.
132        ///
133        /// Returns None if NIKA_SKIP_KEYCHAIN is set (for CI/testing).
134        pub fn get_masked(provider: &str) -> Option<String> {
135            // Skip keychain access if NIKA_SKIP_KEYCHAIN is truthy
136            if super::should_skip_keychain() {
137                return None;
138            }
139            Self::get(provider).ok().map(|k| super::mask_api_key(&k))
140        }
141    }
142}
143
144/// Check if keychain access should be skipped.
145///
146/// Returns `true` in any of these cases:
147/// - Binary was compiled in test mode (`cfg!(test)`)
148/// - `NIKA_SKIP_KEYCHAIN` env var is truthy ("1", "true", "yes")
149///
150/// This prevents macOS Keychain popup storms during development.
151/// Each `cargo build`/`cargo test` produces a binary with a new CDHash,
152/// causing macOS to re-prompt for keychain access every time.
153pub fn should_skip_keychain() -> bool {
154    cfg!(test)
155        || std::env::var("NIKA_SKIP_KEYCHAIN")
156            .map(|v| matches!(v.as_str(), "1" | "true" | "yes"))
157            .unwrap_or(false)
158}
159
160// ═══════════════════════════════════════════════════════════════════════════════
161// STUB IMPLEMENTATION (when native-keychain feature is disabled)
162// Used in Docker builds where OS keychain is not available.
163// ═══════════════════════════════════════════════════════════════════════════════
164
165#[cfg(not(feature = "native-keychain"))]
166mod stub {
167    use super::*;
168
169    /// Stub keyring for environments without OS keychain (Docker, CI, etc.).
170    /// All operations return errors - use environment variables instead.
171    pub struct NikaKeyring;
172
173    impl NikaKeyring {
174        /// Always returns NotFound error (no keychain access in Docker).
175        pub fn get(_provider: &str) -> Result<Zeroizing<String>, KeyringError> {
176            Err(KeyringError::AccessError(
177                "Keychain not available (native-keychain feature disabled)".into(),
178            ))
179        }
180
181        /// Always returns error (no keychain access).
182        pub fn get_secret(_provider: &str) -> Result<SecretString, KeyringError> {
183            Err(KeyringError::AccessError(
184                "Keychain not available (native-keychain feature disabled)".into(),
185            ))
186        }
187
188        /// Always returns error (no keychain access).
189        pub fn set(_provider: &str, _key: &str) -> Result<(), KeyringError> {
190            Err(KeyringError::StoreError(
191                "Keychain not available (native-keychain feature disabled)".into(),
192            ))
193        }
194
195        /// Always returns error (no keychain access).
196        pub fn delete(_provider: &str) -> Result<(), KeyringError> {
197            Err(KeyringError::DeleteError(
198                "Keychain not available (native-keychain feature disabled)".into(),
199            ))
200        }
201
202        /// Always returns false (no keychain access).
203        pub fn exists(_provider: &str) -> bool {
204            false
205        }
206
207        /// Always returns None (no keychain access).
208        pub fn get_masked(_provider: &str) -> Option<String> {
209            None
210        }
211    }
212}
213
214// Re-export the appropriate implementation
215#[cfg(feature = "native-keychain")]
216pub use native::NikaKeyring;
217
218#[cfg(not(feature = "native-keychain"))]
219pub use stub::NikaKeyring;
220
221// ═══════════════════════════════════════════════════════════════════════════════
222// UTILITY FUNCTIONS (always available)
223// ═══════════════════════════════════════════════════════════════════════════════
224
225/// Mask API key for display.
226///
227/// Shows first 8 chars + "..." for keys longer than 8 chars.
228pub fn mask_api_key(key: &str) -> String {
229    if key.len() > 8 {
230        format!("{}...", &key[..8])
231    } else {
232        "***".to_string()
233    }
234}
235
236/// Validate API key format using nika::core provider definitions.
237///
238/// Returns Ok(()) if valid, Err(reason) if invalid.
239pub fn validate_key_format(provider: &str, key: &str) -> Result<(), String> {
240    use crate::core::{find_provider, validate_key_format as core_validate};
241
242    // Empty key is always invalid
243    if key.is_empty() {
244        return Err("API key cannot be empty".to_string());
245    }
246
247    // Look up the provider
248    let Some(prov) = find_provider(provider) else {
249        // Unknown provider - accept any key format
250        return Ok(());
251    };
252
253    // Validate key format against provider's prefix requirement
254    if core_validate(prov, key) {
255        Ok(())
256    } else {
257        Err(format!(
258            "Invalid API key format for {}. Expected prefix: {}",
259            provider,
260            prov.key_prefix.unwrap_or("(any)")
261        ))
262    }
263}
264
265// ═══════════════════════════════════════════════════════════════════════════════
266// MIGRATION (env → keyring) - Only available with native-keychain
267// ═══════════════════════════════════════════════════════════════════════════════
268
269const MIGRATEABLE_PROVIDERS: &[&str] = &[
270    "anthropic",
271    "openai",
272    "mistral",
273    "groq",
274    "deepseek",
275    "gemini",
276    "xai",
277];
278
279#[derive(Debug, Default)]
280pub struct MigrationReport {
281    pub migrated: usize,
282    pub skipped: usize,
283    pub not_found: Vec<String>,
284    pub errors: Vec<(String, String)>,
285}
286
287impl MigrationReport {
288    pub fn summary(&self) -> String {
289        format!(
290            "Migration complete: {} migrated, {} skipped, {} not found",
291            self.migrated,
292            self.skipped,
293            self.not_found.len()
294        )
295    }
296}
297
298/// Migrate API keys from environment variables to system keychain.
299///
300/// With native-keychain: Actually migrates keys to OS keychain
301/// Without: Returns report indicating migration not available
302#[cfg(feature = "native-keychain")]
303pub fn migrate_env_to_keyring() -> MigrationReport {
304    let mut report = MigrationReport::default();
305
306    for provider in MIGRATEABLE_PROVIDERS {
307        // Use core::provider_to_env_var directly instead of tui::providers::env_var
308        let env_var = provider_to_env_var(provider).unwrap_or("UNKNOWN_API_KEY");
309
310        match std::env::var(env_var) {
311            Ok(key) if !key.is_empty() => {
312                if NikaKeyring::exists(provider) {
313                    println!(
314                        "  ├── {}: Found → {}",
315                        env_var,
316                        "Already in keychain".yellow()
317                    );
318                    report.skipped += 1;
319                    continue;
320                }
321
322                print!("  ├── {}: Found → Migrating... ", env_var);
323                match NikaKeyring::set(provider, &key) {
324                    Ok(()) => {
325                        println!("{}", "✓".green());
326                        report.migrated += 1;
327                    }
328                    Err(e) => {
329                        println!("{} ({})", "✗".red(), e);
330                        report.errors.push((provider.to_string(), e.to_string()));
331                    }
332                }
333            }
334            _ => {
335                println!("  ├── {}: {}", env_var, "Not found".dimmed());
336                report.not_found.push(provider.to_string());
337            }
338        }
339    }
340
341    report
342}
343
344#[cfg(not(feature = "native-keychain"))]
345pub fn migrate_env_to_keyring() -> MigrationReport {
346    println!("  ⚠ Migration not available (native-keychain feature disabled)");
347    println!("  ⚠ Use environment variables instead in Docker/container environments");
348    MigrationReport {
349        not_found: MIGRATEABLE_PROVIDERS
350            .iter()
351            .map(|s| s.to_string())
352            .collect(),
353        ..Default::default()
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    #[cfg(feature = "native-keychain")]
363    fn test_service_name_is_nika() {
364        assert_eq!(SERVICE_NAME, "nika");
365    }
366
367    #[test]
368    fn test_mask_api_key_standard() {
369        let key = "sk-ant-api03-abc123xyz789def456ghi";
370        assert_eq!(mask_api_key(key), "sk-ant-a...");
371    }
372
373    #[test]
374    fn test_mask_api_key_short() {
375        assert_eq!(mask_api_key("short"), "***");
376        assert_eq!(mask_api_key("12345678"), "***");
377    }
378
379    #[test]
380    fn test_mask_api_key_boundary() {
381        assert_eq!(mask_api_key("123456789"), "12345678...");
382    }
383
384    #[test]
385    fn test_mask_api_key_empty() {
386        assert_eq!(mask_api_key(""), "***");
387    }
388
389    #[test]
390    fn test_validate_anthropic_key_valid() {
391        let result =
392            validate_key_format("anthropic", "sk-ant-api03-abcdefghijklmnopqrstuvwxyz123456");
393        assert!(result.is_ok());
394    }
395
396    #[test]
397    fn test_validate_anthropic_key_wrong_prefix() {
398        let result = validate_key_format("anthropic", "sk-wrong-prefix");
399        assert!(result.is_err());
400    }
401
402    #[test]
403    fn test_validate_openai_key_valid() {
404        let result = validate_key_format("openai", "sk-proj-abcdefghijklmnop");
405        assert!(result.is_ok());
406    }
407
408    #[test]
409    fn test_validate_empty_key_rejected() {
410        let result = validate_key_format("anthropic", "");
411        assert!(result.is_err());
412    }
413
414    #[test]
415    fn test_keyring_error_display() {
416        let err = KeyringError::NotFound("anthropic".to_string());
417        assert!(err.to_string().contains("anthropic"));
418    }
419
420    #[test]
421    fn test_migration_report_summary() {
422        let report = MigrationReport {
423            migrated: 2,
424            skipped: 1,
425            not_found: vec!["groq".into()],
426            errors: vec![],
427        };
428        let summary = report.summary();
429        assert!(summary.contains("2 migrated"));
430        assert!(summary.contains("1 skipped"));
431    }
432
433    // ─── Bug 34: set/delete must be guarded in test mode ──────────────
434
435    #[test]
436    fn test_keyring_set_guarded_in_test_mode() {
437        // In cfg!(test), NikaKeyring::set() must return an error
438        // to prevent accidental keychain access during tests
439        let result = NikaKeyring::set("test_provider", "test-key-value");
440        assert!(
441            result.is_err(),
442            "NikaKeyring::set() must be guarded in test mode"
443        );
444    }
445
446    #[test]
447    fn test_keyring_delete_guarded_in_test_mode() {
448        // In cfg!(test), NikaKeyring::delete() must return an error
449        let result = NikaKeyring::delete("test_provider");
450        assert!(
451            result.is_err(),
452            "NikaKeyring::delete() must be guarded in test mode"
453        );
454    }
455
456    // ─── Bug 35: MIGRATEABLE_PROVIDERS must include xai ─────────────
457
458    #[test]
459    fn test_migrateable_providers_includes_xai() {
460        assert!(
461            MIGRATEABLE_PROVIDERS.contains(&"xai"),
462            "MIGRATEABLE_PROVIDERS must include xai, got: {:?}",
463            MIGRATEABLE_PROVIDERS
464        );
465    }
466
467    #[test]
468    fn test_migrateable_providers_count() {
469        assert_eq!(
470            MIGRATEABLE_PROVIDERS.len(),
471            7,
472            "Expected 7 migrateable providers (all LLM), got: {:?}",
473            MIGRATEABLE_PROVIDERS
474        );
475    }
476
477    // Tests that are only relevant with native-keychain
478    // These tests are #[ignore] by default to avoid macOS Keychain popups.
479    // Run explicitly with: cargo test -- --ignored
480    #[cfg(feature = "native-keychain")]
481    mod native_tests {
482        use super::*;
483
484        #[test]
485        #[ignore = "Requires real OS keychain access — causes macOS popup"]
486        fn test_nika_keyring_not_found() {
487            // Test that querying a non-existent key returns NotFound
488            let result = NikaKeyring::get("nonexistent_provider_test_xyz");
489            assert!(matches!(
490                result,
491                Err(KeyringError::NotFound(_)) | Err(KeyringError::AccessError(_))
492            ));
493        }
494    }
495
496    // Tests for stub implementation
497    #[cfg(not(feature = "native-keychain"))]
498    mod stub_tests {
499        use super::*;
500
501        #[test]
502        fn test_stub_get_returns_error() {
503            let result = NikaKeyring::get("anthropic");
504            assert!(result.is_err());
505        }
506
507        #[test]
508        fn test_stub_exists_returns_false() {
509            assert!(!NikaKeyring::exists("anthropic"));
510        }
511
512        #[test]
513        fn test_stub_set_returns_error() {
514            let result = NikaKeyring::set("anthropic", "test-key");
515            assert!(result.is_err());
516        }
517    }
518}