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