Skip to main content

lore_cli/cloud/
credentials.rs

1//! Credential storage for cloud authentication.
2//!
3//! Provides secure storage for API keys and encryption keys using the OS
4//! keychain when available, with a file-based fallback for systems where
5//! the keychain is not accessible.
6
7use anyhow::{Context, Result};
8use keyring::Entry;
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::PathBuf;
12
13use super::{CloudError, KEYRING_API_KEY_USER, KEYRING_ENCRYPTION_KEY_USER, KEYRING_SERVICE};
14
15/// Cloud service credentials.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Credentials {
18    /// The API key for authenticating with the cloud service.
19    pub api_key: String,
20
21    /// User email address associated with the account.
22    pub email: String,
23
24    /// Subscription plan (e.g., "free", "pro").
25    pub plan: String,
26
27    /// Cloud service URL (for custom deployments).
28    #[serde(default = "default_cloud_url")]
29    pub cloud_url: String,
30}
31
32fn default_cloud_url() -> String {
33    super::DEFAULT_CLOUD_URL.to_string()
34}
35
36/// Credential storage abstraction.
37///
38/// By default, stores credentials in a JSON file (~/.lore/credentials.json).
39/// Can optionally use the OS keychain (macOS Keychain, GNOME Keyring, Windows
40/// Credential Manager) when enabled via `use_keychain` config option.
41pub struct CredentialsStore {
42    /// Whether to use keyring (enabled via config and available on system).
43    use_keyring: bool,
44    /// Base directory for file-backed storage (defaults to ~/.lore).
45    base_dir: Option<PathBuf>,
46}
47
48impl CredentialsStore {
49    /// Creates a new credential store with file-based storage (default).
50    ///
51    /// Credentials are stored in ~/.lore/credentials.json with restricted permissions.
52    pub fn new() -> Self {
53        Self {
54            use_keyring: false,
55            base_dir: None,
56        }
57    }
58
59    /// Creates a credential store with optional keychain support.
60    ///
61    /// If `use_keychain` is true and the OS keychain is available, credentials
62    /// will be stored in the keychain. Otherwise, falls back to file storage.
63    ///
64    /// Note: On first keychain access, the OS may prompt for permission.
65    pub fn with_keychain(use_keychain: bool) -> Self {
66        let use_keyring = if use_keychain {
67            Self::is_keyring_available()
68        } else {
69            false
70        };
71        Self {
72            use_keyring,
73            base_dir: None,
74        }
75    }
76
77    #[cfg(test)]
78    pub(crate) fn with_base_dir(base_dir: PathBuf, use_keychain: bool) -> Self {
79        let use_keyring = if use_keychain {
80            Self::is_keyring_available()
81        } else {
82            false
83        };
84        Self {
85            use_keyring,
86            base_dir: Some(base_dir),
87        }
88    }
89
90    /// Tests whether the keyring is available by attempting a dummy operation.
91    ///
92    /// This is useful for checking if the OS keychain can be used before
93    /// prompting the user about credential storage options.
94    pub fn is_keyring_available() -> bool {
95        // Try to create an entry - this will fail on systems without keyring support
96        match Entry::new(KEYRING_SERVICE, "test-availability") {
97            Ok(entry) => {
98                // Try to get a non-existent key - should return NotFound, not an error
99                match entry.get_password() {
100                    Ok(_) => true,
101                    Err(keyring::Error::NoEntry) => true,
102                    Err(_) => false,
103                }
104            }
105            Err(_) => false,
106        }
107    }
108
109    /// Checks if a secret service is likely available on Linux.
110    ///
111    /// On Linux, the keyring crate requires a running secret service
112    /// (gnome-keyring, kwallet, etc.) to function. This method checks
113    /// for common indicators that a secret service is available.
114    ///
115    /// On non-Linux platforms, this always returns true since they have
116    /// built-in credential storage (macOS Keychain, Windows Credential Manager).
117    #[cfg(target_os = "linux")]
118    pub fn is_secret_service_available() -> bool {
119        // Check for common secret service environment indicators
120        // DBUS_SESSION_BUS_ADDRESS is required for secret service communication
121        if std::env::var("DBUS_SESSION_BUS_ADDRESS").is_err() {
122            return false;
123        }
124
125        // Try to actually test the keyring - this is the most reliable check
126        Self::is_keyring_available()
127    }
128
129    /// On non-Linux platforms, secret service is always available.
130    #[cfg(not(target_os = "linux"))]
131    pub fn is_secret_service_available() -> bool {
132        true
133    }
134
135    /// Stores credentials securely.
136    ///
137    /// Uses file storage by default, or keychain if enabled and available.
138    pub fn store(&self, credentials: &Credentials) -> Result<(), CloudError> {
139        if self.use_keyring {
140            self.store_to_keyring(credentials)
141        } else {
142            self.store_to_file(credentials)
143        }
144    }
145
146    /// Loads stored credentials.
147    ///
148    /// Loads from keychain if enabled, otherwise from file storage.
149    /// Also checks the alternate location for migration purposes.
150    pub fn load(&self) -> Result<Option<Credentials>, CloudError> {
151        if self.use_keyring {
152            // Try keyring first, fall back to file
153            if let Some(creds) = self.load_from_keyring()? {
154                return Ok(Some(creds));
155            }
156            self.load_from_file()
157        } else {
158            // Try file first, fall back to keyring (for migration)
159            if let Some(creds) = self.load_from_file()? {
160                return Ok(Some(creds));
161            }
162            // Check keyring as fallback (user may have stored there previously)
163            if Self::is_keyring_available() {
164                self.load_from_keyring()
165            } else {
166                Ok(None)
167            }
168        }
169    }
170
171    /// Deletes stored credentials.
172    ///
173    /// Removes credentials from both file and keyring storage to ensure
174    /// complete cleanup regardless of how they were stored.
175    pub fn delete(&self) -> Result<(), CloudError> {
176        // Delete from file
177        self.delete_from_file()?;
178
179        // Also delete from keyring if available (cleanup any legacy storage)
180        if Self::is_keyring_available() {
181            self.delete_from_keyring()?;
182        }
183
184        Ok(())
185    }
186
187    /// Stores the derived encryption key securely.
188    ///
189    /// The encryption key is stored separately from credentials and should
190    /// be a hex-encoded string of the derived key bytes.
191    pub fn store_encryption_key(&self, key_hex: &str) -> Result<(), CloudError> {
192        if self.use_keyring {
193            let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER)
194                .map_err(|e| CloudError::KeyringError(e.to_string()))?;
195            entry
196                .set_password(key_hex)
197                .map_err(|e| CloudError::KeyringError(e.to_string()))?;
198        } else {
199            // Use file storage
200            let path = self.encryption_key_path()?;
201            if let Some(parent) = path.parent() {
202                fs::create_dir_all(parent)
203                    .map_err(|e| CloudError::KeyringError(format!("Failed to create dir: {e}")))?;
204            }
205            fs::write(&path, key_hex)
206                .map_err(|e| CloudError::KeyringError(format!("Failed to write key: {e}")))?;
207
208            // Set restrictive permissions on Unix
209            #[cfg(unix)]
210            {
211                use std::os::unix::fs::PermissionsExt;
212                let perms = fs::Permissions::from_mode(0o600);
213                fs::set_permissions(&path, perms).map_err(|e| {
214                    CloudError::KeyringError(format!("Failed to set permissions: {e}"))
215                })?;
216            }
217        }
218        Ok(())
219    }
220
221    /// Loads the stored encryption key.
222    ///
223    /// Returns the hex-encoded encryption key, or None if not stored.
224    pub fn load_encryption_key(&self) -> Result<Option<String>, CloudError> {
225        if self.use_keyring {
226            let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER)
227                .map_err(|e| CloudError::KeyringError(e.to_string()))?;
228            match entry.get_password() {
229                Ok(key) => return Ok(Some(key)),
230                Err(keyring::Error::NoEntry) => {}
231                Err(e) => return Err(CloudError::KeyringError(e.to_string())),
232            }
233        }
234
235        // Check file storage
236        let path = self.encryption_key_path()?;
237        if path.exists() {
238            let key = fs::read_to_string(&path)
239                .map_err(|e| CloudError::KeyringError(format!("Failed to read key: {e}")))?;
240            return Ok(Some(key.trim().to_string()));
241        }
242
243        // Check keyring as fallback (for migration from keyring to file)
244        if !self.use_keyring && Self::is_keyring_available() {
245            let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER)
246                .map_err(|e| CloudError::KeyringError(e.to_string()))?;
247            match entry.get_password() {
248                Ok(key) => return Ok(Some(key)),
249                Err(keyring::Error::NoEntry) => {}
250                Err(e) => return Err(CloudError::KeyringError(e.to_string())),
251            }
252        }
253
254        Ok(None)
255    }
256
257    /// Deletes the stored encryption key.
258    ///
259    /// Removes from both file and keyring to ensure complete cleanup.
260    pub fn delete_encryption_key(&self) -> Result<(), CloudError> {
261        // Delete from file
262        let path = self.encryption_key_path()?;
263        if path.exists() {
264            fs::remove_file(&path)
265                .map_err(|e| CloudError::KeyringError(format!("Failed to delete key file: {e}")))?;
266        }
267
268        // Also delete from keyring if available (cleanup any legacy storage)
269        if Self::is_keyring_available() {
270            let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER)
271                .map_err(|e| CloudError::KeyringError(e.to_string()))?;
272            match entry.delete_credential() {
273                Ok(()) => {}
274                Err(keyring::Error::NoEntry) => {}
275                Err(e) => return Err(CloudError::KeyringError(e.to_string())),
276            }
277        }
278
279        Ok(())
280    }
281
282    // ==================== Keyring operations ====================
283
284    fn store_to_keyring(&self, credentials: &Credentials) -> Result<(), CloudError> {
285        let entry = Entry::new(KEYRING_SERVICE, KEYRING_API_KEY_USER)
286            .map_err(|e| CloudError::KeyringError(e.to_string()))?;
287
288        // Store credentials as JSON
289        let json = serde_json::to_string(credentials)
290            .map_err(|e| CloudError::KeyringError(format!("Serialization error: {e}")))?;
291
292        entry
293            .set_password(&json)
294            .map_err(|e| CloudError::KeyringError(e.to_string()))?;
295
296        Ok(())
297    }
298
299    fn load_from_keyring(&self) -> Result<Option<Credentials>, CloudError> {
300        let entry = Entry::new(KEYRING_SERVICE, KEYRING_API_KEY_USER)
301            .map_err(|e| CloudError::KeyringError(e.to_string()))?;
302
303        match entry.get_password() {
304            Ok(json) => {
305                let credentials: Credentials = serde_json::from_str(&json)
306                    .map_err(|e| CloudError::KeyringError(format!("Deserialization error: {e}")))?;
307                Ok(Some(credentials))
308            }
309            Err(keyring::Error::NoEntry) => Ok(None),
310            Err(e) => Err(CloudError::KeyringError(e.to_string())),
311        }
312    }
313
314    fn delete_from_keyring(&self) -> Result<(), CloudError> {
315        let entry = Entry::new(KEYRING_SERVICE, KEYRING_API_KEY_USER)
316            .map_err(|e| CloudError::KeyringError(e.to_string()))?;
317
318        match entry.delete_credential() {
319            Ok(()) => Ok(()),
320            Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
321            Err(e) => Err(CloudError::KeyringError(e.to_string())),
322        }
323    }
324
325    // ==================== File operations ====================
326
327    fn credentials_path(&self) -> Result<PathBuf, CloudError> {
328        let config_dir = match &self.base_dir {
329            Some(base_dir) => base_dir.clone(),
330            None => dirs::home_dir()
331                .ok_or_else(|| {
332                    CloudError::KeyringError("Could not find home directory".to_string())
333                })?
334                .join(".lore"),
335        };
336
337        Ok(config_dir.join("credentials.json"))
338    }
339
340    fn encryption_key_path(&self) -> Result<PathBuf, CloudError> {
341        let config_dir = match &self.base_dir {
342            Some(base_dir) => base_dir.clone(),
343            None => dirs::home_dir()
344                .ok_or_else(|| {
345                    CloudError::KeyringError("Could not find home directory".to_string())
346                })?
347                .join(".lore"),
348        };
349
350        Ok(config_dir.join("encryption.key"))
351    }
352
353    fn store_to_file(&self, credentials: &Credentials) -> Result<(), CloudError> {
354        let path = self.credentials_path()?;
355
356        if let Some(parent) = path.parent() {
357            fs::create_dir_all(parent).map_err(|e| {
358                CloudError::KeyringError(format!("Failed to create config directory: {e}"))
359            })?;
360        }
361
362        let json = serde_json::to_string_pretty(credentials)
363            .map_err(|e| CloudError::KeyringError(format!("Serialization error: {e}")))?;
364
365        fs::write(&path, json).map_err(|e| {
366            CloudError::KeyringError(format!("Failed to write credentials file: {e}"))
367        })?;
368
369        // Set restrictive permissions on Unix
370        #[cfg(unix)]
371        {
372            use std::os::unix::fs::PermissionsExt;
373            let perms = fs::Permissions::from_mode(0o600);
374            fs::set_permissions(&path, perms).map_err(|e| {
375                CloudError::KeyringError(format!("Failed to set file permissions: {e}"))
376            })?;
377        }
378
379        Ok(())
380    }
381
382    fn load_from_file(&self) -> Result<Option<Credentials>, CloudError> {
383        let path = self.credentials_path()?;
384
385        if !path.exists() {
386            return Ok(None);
387        }
388
389        let json = fs::read_to_string(&path).map_err(|e| {
390            CloudError::KeyringError(format!("Failed to read credentials file: {e}"))
391        })?;
392
393        let credentials: Credentials = serde_json::from_str(&json)
394            .map_err(|e| CloudError::KeyringError(format!("Invalid credentials file: {e}")))?;
395
396        Ok(Some(credentials))
397    }
398
399    fn delete_from_file(&self) -> Result<(), CloudError> {
400        let path = self.credentials_path()?;
401
402        if path.exists() {
403            fs::remove_file(&path).map_err(|e| {
404                CloudError::KeyringError(format!("Failed to delete credentials file: {e}"))
405            })?;
406        }
407
408        Ok(())
409    }
410}
411
412impl Default for CredentialsStore {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418/// Checks if the user is currently logged in.
419///
420/// Returns true if valid credentials are stored, false otherwise.
421/// Respects the `use_keychain` config setting.
422#[allow(dead_code)]
423pub fn is_logged_in() -> bool {
424    let use_keychain = crate::config::Config::load()
425        .map(|c| c.use_keychain)
426        .unwrap_or(false);
427    let store = CredentialsStore::with_keychain(use_keychain);
428    matches!(store.load(), Ok(Some(_)))
429}
430
431/// Gets the current credentials if logged in.
432///
433/// Returns None if not logged in or credentials cannot be loaded.
434/// Respects the `use_keychain` config setting.
435#[allow(dead_code)]
436pub fn get_credentials() -> Option<Credentials> {
437    let use_keychain = crate::config::Config::load()
438        .map(|c| c.use_keychain)
439        .unwrap_or(false);
440    let store = CredentialsStore::with_keychain(use_keychain);
441    get_credentials_with_store(&store)
442}
443
444/// Requires login, returning an error if not logged in.
445///
446/// This is a convenience function for commands that require authentication.
447/// Respects the `use_keychain` config setting.
448pub fn require_login() -> Result<Credentials> {
449    let use_keychain = crate::config::Config::load()
450        .map(|c| c.use_keychain)
451        .unwrap_or(false);
452    let store = CredentialsStore::with_keychain(use_keychain);
453    require_login_with_store(&store)
454}
455
456fn get_credentials_with_store(store: &CredentialsStore) -> Option<Credentials> {
457    store.load().ok().flatten()
458}
459
460fn require_login_with_store(store: &CredentialsStore) -> Result<Credentials> {
461    store
462        .load()
463        .context("Failed to check login status")?
464        .ok_or_else(|| anyhow::anyhow!("Not logged in. Run 'lore login' first."))
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn test_credentials_default_cloud_url() {
473        let creds = Credentials {
474            api_key: "test".to_string(),
475            email: "test@example.com".to_string(),
476            plan: "free".to_string(),
477            cloud_url: default_cloud_url(),
478        };
479        assert_eq!(creds.cloud_url, super::super::DEFAULT_CLOUD_URL);
480    }
481
482    #[test]
483    fn test_credentials_serialization() {
484        let creds = Credentials {
485            api_key: "lore_test123".to_string(),
486            email: "user@example.com".to_string(),
487            plan: "pro".to_string(),
488            cloud_url: "https://custom.example.com".to_string(),
489        };
490
491        let json = serde_json::to_string(&creds).unwrap();
492        let parsed: Credentials = serde_json::from_str(&json).unwrap();
493
494        assert_eq!(parsed.api_key, creds.api_key);
495        assert_eq!(parsed.email, creds.email);
496        assert_eq!(parsed.plan, creds.plan);
497        assert_eq!(parsed.cloud_url, creds.cloud_url);
498    }
499
500    #[test]
501    fn test_credentials_deserialization_default_url() {
502        // Test that cloud_url gets a default value when not present in JSON
503        let json = r#"{"api_key":"test","email":"test@example.com","plan":"free"}"#;
504        let creds: Credentials = serde_json::from_str(json).unwrap();
505        assert_eq!(creds.cloud_url, super::super::DEFAULT_CLOUD_URL);
506    }
507
508    #[test]
509    fn test_is_logged_in_returns_bool() {
510        // This test verifies the function exists and runs without panic.
511        // The actual result depends on whether there are existing
512        // credentials on the system.
513        let _result: bool = is_logged_in();
514    }
515
516    #[test]
517    fn test_is_keyring_available_smoke() {
518        // This test verifies the function exists and returns a boolean.
519        // The actual result depends on the system's keychain support.
520        let _result: bool = CredentialsStore::is_keyring_available();
521    }
522
523    #[test]
524    fn test_is_secret_service_available_smoke() {
525        // This test verifies the function exists and returns a boolean.
526        // On macOS and Windows, this should always return true.
527        // On Linux, it depends on whether a secret service is running.
528        let _result: bool = CredentialsStore::is_secret_service_available();
529    }
530
531    #[test]
532    fn test_require_login_with_store_deterministic() {
533        let temp_dir = tempfile::TempDir::new().unwrap();
534        let store = CredentialsStore::with_base_dir(temp_dir.path().to_path_buf(), false);
535
536        let creds = Credentials {
537            api_key: "test_key".to_string(),
538            email: "user@example.com".to_string(),
539            plan: "pro".to_string(),
540            cloud_url: default_cloud_url(),
541        };
542
543        store.store(&creds).unwrap();
544        let loaded = require_login_with_store(&store).unwrap();
545        assert_eq!(loaded.email, creds.email);
546        assert_eq!(loaded.api_key, creds.api_key);
547    }
548
549    #[test]
550    fn test_get_credentials_with_store_deterministic() {
551        let temp_dir = tempfile::TempDir::new().unwrap();
552        let store = CredentialsStore::with_base_dir(temp_dir.path().to_path_buf(), false);
553
554        let creds = Credentials {
555            api_key: "test_key".to_string(),
556            email: "user@example.com".to_string(),
557            plan: "free".to_string(),
558            cloud_url: default_cloud_url(),
559        };
560
561        store.store(&creds).unwrap();
562        let loaded = get_credentials_with_store(&store).unwrap();
563        assert_eq!(loaded.email, creds.email);
564        assert_eq!(loaded.api_key, creds.api_key);
565    }
566}