Skip to main content

pawan/
credentials.rs

1//! Secure credential storage using OS-native keyring.
2//!
3//! This module provides a secure way to store and retrieve API keys
4//! using the operating system's native credential store:
5//! - Linux: libsecret or KWallet
6//! - macOS: Keychain
7//! - Windows: Credential Manager
8
9use keyring::{Entry, Error};
10use thiserror::Error;
11use tracing::warn;
12
13const SERVICE_NAME: &str = "pawan";
14const USER: &str = "api_keys";
15
16/// Errors that can occur during credential operations.
17#[derive(Error, Debug)]
18pub enum CredentialError {
19    #[error("Failed to access credential store: {0}")]
20    StoreError(String),
21
22    #[error("Credential not found")]
23    NotFound,
24
25    #[error("Invalid credential data")]
26    InvalidData,
27}
28
29impl From<Error> for CredentialError {
30    fn from(err: Error) -> Self {
31        match err {
32            Error::PlatformFailure(e) => {
33                CredentialError::StoreError(format!("Platform error: {}", e))
34            }
35            Error::NoEntry => CredentialError::NotFound,
36            _ => CredentialError::StoreError(format!("Credential store error: {}", err)),
37        }
38    }
39}
40
41/// Securely stores an API key in the OS-native credential store.
42///
43/// # Arguments
44/// * `key_name` - The name of the key (e.g., "nvidia_api_key", "openai_api_key")
45/// * `api_key` - The API key to store
46///
47/// # Returns
48/// * `Ok(())` if the key was stored successfully
49/// * `Err(CredentialError)` if storage failed
50pub fn store_api_key(key_name: &str, api_key: &str) -> Result<(), CredentialError> {
51    let entry = Entry::new(SERVICE_NAME, &format!("{}_{}", USER, key_name))?;
52    entry.set_password(api_key)?;
53    warn!("API key '{}' stored securely", key_name);
54    Ok(())
55}
56
57/// Retrieves an API key from the OS-native credential store.
58///
59/// # Arguments
60/// * `key_name` - The name of the key (e.g., "nvidia_api_key", "openai_api_key")
61///
62/// # Returns
63/// * `Ok(Some(String))` if the key was found
64/// * `Ok(None)` if the key was not found
65/// * `Err(CredentialError)` if retrieval failed
66pub fn get_api_key(key_name: &str) -> Result<Option<String>, CredentialError> {
67    let entry = Entry::new(SERVICE_NAME, &format!("{}_{}", USER, key_name))?;
68
69    match entry.get_password() {
70        Ok(key) => Ok(Some(key)),
71        Err(Error::NoEntry) => Ok(None),
72        Err(e) => Err(e.into()),
73    }
74}
75
76/// Deletes an API key from the OS-native credential store.
77///
78/// # Arguments
79/// * `key_name` - The name of the key to delete
80///
81/// # Returns
82/// * `Ok(())` if the key was deleted successfully or didn't exist
83/// * `Err(CredentialError)` if deletion failed
84pub fn delete_api_key(key_name: &str) -> Result<(), CredentialError> {
85    let entry = Entry::new(SERVICE_NAME, &format!("{}_{}", USER, key_name))?;
86
87    match entry.delete_credential() {
88        Ok(()) => {
89            warn!("API key '{}' deleted from secure store", key_name);
90            Ok(())
91        }
92        Err(Error::NoEntry) => Ok(()),
93        Err(e) => Err(e.into()),
94    }
95}
96
97/// Checks if a secure credential store is available on this system.
98///
99/// # Returns
100/// * `true` if a credential store is available
101/// * `false` if no credential store is available
102pub fn is_secure_store_available() -> bool {
103    let test_entry = Entry::new(SERVICE_NAME, "test_check");
104    test_entry.is_ok()
105}
106
107/// Convenience function for NVIDIA API key operations.
108pub fn store_nvidia_api_key(key: &str) -> Result<(), CredentialError> {
109    store_api_key("nvidia_api_key", key)
110}
111
112pub fn get_nvidia_api_key() -> Result<Option<String>, CredentialError> {
113    get_api_key("nvidia_api_key")
114}
115
116pub fn delete_nvidia_api_key() -> Result<(), CredentialError> {
117    delete_api_key("nvidia_api_key")
118}
119
120/// Convenience function for OpenAI API key operations.
121pub fn store_openai_api_key(key: &str) -> Result<(), CredentialError> {
122    store_api_key("openai_api_key", key)
123}
124
125pub fn get_openai_api_key() -> Result<Option<String>, CredentialError> {
126    get_api_key("openai_api_key")
127}
128
129pub fn delete_openai_api_key() -> Result<(), CredentialError> {
130    delete_api_key("openai_api_key")
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use keyring::Error;
137
138    // ============================================================
139    // CredentialError tests
140    // ============================================================
141
142    #[test]
143    fn test_credential_error_store_error() {
144        let err = CredentialError::StoreError("test error".to_string());
145        assert_eq!(
146            err.to_string(),
147            "Failed to access credential store: test error"
148        );
149    }
150
151    #[test]
152    fn test_credential_error_not_found() {
153        let err = CredentialError::NotFound;
154        assert_eq!(err.to_string(), "Credential not found");
155    }
156
157    #[test]
158    fn test_credential_error_invalid_data() {
159        let err = CredentialError::InvalidData;
160        assert_eq!(err.to_string(), "Invalid credential data");
161    }
162
163    // ============================================================
164    // From<Error> conversion tests
165    // ============================================================
166
167    #[test]
168    fn test_from_error_platform_failure() {
169        let platform_err = Error::PlatformFailure(Box::new(std::io::Error::new(
170            std::io::ErrorKind::Other,
171            "DBus error",
172        )));
173        let cred_err: CredentialError = platform_err.into();
174        match cred_err {
175            CredentialError::StoreError(msg) => {
176                assert!(msg.contains("Platform error:"));
177                assert!(msg.contains("DBus error"));
178            }
179            _ => panic!("Expected StoreError variant"),
180        }
181    }
182
183    #[test]
184    fn test_from_error_no_entry() {
185        let no_entry_err = Error::NoEntry;
186        let cred_err: CredentialError = no_entry_err.into();
187        match cred_err {
188            CredentialError::NotFound => {}
189            _ => panic!("Expected NotFound variant"),
190        }
191    }
192
193    #[test]
194    fn test_from_error_invalid_credential() {
195        // InvalidCredential doesn't exist in keyring::Error
196        // Test with BadEncoding instead
197        let bad_encoding_err = Error::BadEncoding(vec![0xFF, 0xFE]);
198        let cred_err: CredentialError = bad_encoding_err.into();
199        match cred_err {
200            CredentialError::StoreError(msg) => {
201                assert!(msg.contains("Credential store error:"));
202            }
203            _ => panic!("Expected StoreError variant"),
204        }
205    }
206
207    #[test]
208    fn test_from_error_no_storage_access() {
209        // NoSolution doesn't exist in keyring::Error
210        // Test with NoStorageAccess instead
211        let no_storage_err = Error::NoStorageAccess(Box::new(std::io::Error::new(
212            std::io::ErrorKind::PermissionDenied,
213            "Access denied",
214        )));
215        let cred_err: CredentialError = no_storage_err.into();
216        match cred_err {
217            CredentialError::StoreError(msg) => {
218                assert!(msg.contains("Credential store error:"));
219            }
220            _ => panic!("Expected StoreError variant"),
221        }
222    }
223
224    #[test]
225    fn test_from_error_too_long() {
226        // DuplicateItem doesn't exist in keyring::Error
227        // Test with TooLong instead
228        let too_long_err = Error::TooLong("username".to_string(), 255);
229        let cred_err: CredentialError = too_long_err.into();
230        match cred_err {
231            CredentialError::StoreError(msg) => {
232                assert!(msg.contains("Credential store error:"));
233            }
234            _ => panic!("Expected StoreError variant"),
235        }
236    }
237
238    // ============================================================
239    // is_secure_store_available tests
240    // ============================================================
241
242    #[test]
243    fn test_is_secure_store_available_returns_bool() {
244        // Just verify it returns a boolean without panicking
245        let result = is_secure_store_available();
246        assert!(result == true || result == false);
247    }
248
249    // ============================================================
250    // Convenience function tests
251    // ============================================================
252
253    #[test]
254    fn test_nvidia_key_names_are_consistent() {
255        // Verify the key name constants used by convenience functions
256        let nvidia_key = "nvidia_api_key";
257        assert_eq!(nvidia_key, "nvidia_api_key");
258    }
259
260    #[test]
261    fn test_openai_key_names_are_consistent() {
262        // Verify the key name constants used by convenience functions
263        let openai_key = "openai_api_key";
264        assert_eq!(openai_key, "openai_api_key");
265    }
266
267    // ============================================================
268    // Error propagation and edge case tests
269    // ============================================================
270
271    #[test]
272    fn test_service_and_user_constants() {
273        // Verify the constants are correctly defined
274        assert_eq!(SERVICE_NAME, "pawan");
275        assert_eq!(USER, "api_keys");
276    }
277
278    #[test]
279    fn test_key_name_formatting() {
280        // Test the key name format used by all functions
281        let key_name = "test_key";
282        let formatted = format!("{}_{}", USER, key_name);
283        assert_eq!(formatted, "api_keys_test_key");
284    }
285
286    // ============================================================
287    // Integration tests that require credential store
288    // These remain ignored by default but can be run with:
289    // cargo test -- --ignored
290    // ============================================================
291
292    #[test]
293    #[ignore] // Requires a working credential store
294    fn test_store_and_get_key() {
295        let key_name = "test_key_12345";
296        let test_key = "test_api_key_value";
297
298        // Store the key
299        store_api_key(key_name, test_key).expect("Failed to store key");
300
301        // Retrieve the key
302        let retrieved = get_api_key(key_name).expect("Failed to retrieve key");
303        assert_eq!(retrieved, Some(test_key.to_string()));
304
305        // Clean up
306        delete_api_key(key_name).expect("Failed to delete key");
307    }
308
309    #[test]
310    #[ignore] // Requires a working credential store
311    fn test_get_nonexistent_key() {
312        let key_name = "nonexistent_key_12345";
313
314        // Delete to ensure clean state
315        let _ = delete_api_key(key_name);
316
317        // Try to retrieve
318        let retrieved = get_api_key(key_name).expect("Failed to retrieve key");
319        assert_eq!(retrieved, None);
320    }
321
322    #[test]
323    #[ignore] // Requires a working credential store
324    fn test_delete_key() {
325        let key_name = "test_delete_key_12345";
326        let test_key = "test_key_value";
327
328        // Store and verify
329        store_api_key(key_name, test_key).expect("Failed to store");
330        assert!(get_api_key(key_name).expect("Failed to get") == Some(test_key.to_string()));
331
332        // Delete and verify
333        delete_api_key(key_name).expect("Failed to delete");
334        assert_eq!(get_api_key(key_name).expect("Failed to get"), None);
335    }
336
337    #[test]
338    #[ignore] // Requires a working credential store
339    fn test_delete_nonexistent_key_succeeds() {
340        // Deleting a key that doesn't exist should return Ok(())
341        let key_name = "nonexistent_delete_key_12345";
342        let result = delete_api_key(key_name);
343        assert!(result.is_ok());
344    }
345
346    #[test]
347    #[ignore] // Requires a working credential store
348    fn test_nvidia_convenience_functions() {
349        let test_key = "nv-test-key-12345";
350
351        // Store via convenience function
352        store_nvidia_api_key(test_key).expect("Failed to store nvidia key");
353
354        // Retrieve via convenience function
355        let retrieved = get_nvidia_api_key().expect("Failed to get nvidia key");
356        assert_eq!(retrieved, Some(test_key.to_string()));
357
358        // Delete via convenience function
359        delete_nvidia_api_key().expect("Failed to delete nvidia key");
360
361        // Verify deleted
362        assert_eq!(get_nvidia_api_key().expect("Failed to get"), None);
363    }
364
365    #[test]
366    #[ignore] // Requires a working credential store
367    fn test_openai_convenience_functions() {
368        let test_key = "oa-test-key-12345";
369
370        // Store via convenience function
371        store_openai_api_key(test_key).expect("Failed to store openai key");
372
373        // Retrieve via convenience function
374        let retrieved = get_openai_api_key().expect("Failed to get openai key");
375        assert_eq!(retrieved, Some(test_key.to_string()));
376
377        // Delete via convenience function
378        delete_openai_api_key().expect("Failed to delete openai key");
379
380        // Verify deleted
381        assert_eq!(get_openai_api_key().expect("Failed to get"), None);
382    }
383}