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::other("DBus error")));
170        let cred_err: CredentialError = platform_err.into();
171        match cred_err {
172            CredentialError::StoreError(msg) => {
173                assert!(msg.contains("Platform error:"));
174                assert!(msg.contains("DBus error"));
175            }
176            _ => panic!("Expected StoreError variant"),
177        }
178    }
179
180    #[test]
181    fn test_from_error_no_entry() {
182        let no_entry_err = Error::NoEntry;
183        let cred_err: CredentialError = no_entry_err.into();
184        match cred_err {
185            CredentialError::NotFound => {}
186            _ => panic!("Expected NotFound variant"),
187        }
188    }
189
190    #[test]
191    fn test_from_error_invalid_credential() {
192        // InvalidCredential doesn't exist in keyring::Error
193        // Test with BadEncoding instead
194        let bad_encoding_err = Error::BadEncoding(vec![0xFF, 0xFE]);
195        let cred_err: CredentialError = bad_encoding_err.into();
196        match cred_err {
197            CredentialError::StoreError(msg) => {
198                assert!(msg.contains("Credential store error:"));
199            }
200            _ => panic!("Expected StoreError variant"),
201        }
202    }
203
204    #[test]
205    fn test_from_error_no_storage_access() {
206        // NoSolution doesn't exist in keyring::Error
207        // Test with NoStorageAccess instead
208        let no_storage_err = Error::NoStorageAccess(Box::new(std::io::Error::new(
209            std::io::ErrorKind::PermissionDenied,
210            "Access denied",
211        )));
212        let cred_err: CredentialError = no_storage_err.into();
213        match cred_err {
214            CredentialError::StoreError(msg) => {
215                assert!(msg.contains("Credential store error:"));
216            }
217            _ => panic!("Expected StoreError variant"),
218        }
219    }
220
221    #[test]
222    fn test_from_error_too_long() {
223        // DuplicateItem doesn't exist in keyring::Error
224        // Test with TooLong instead
225        let too_long_err = Error::TooLong("username".to_string(), 255);
226        let cred_err: CredentialError = too_long_err.into();
227        match cred_err {
228            CredentialError::StoreError(msg) => {
229                assert!(msg.contains("Credential store error:"));
230            }
231            _ => panic!("Expected StoreError variant"),
232        }
233    }
234
235    // ============================================================
236    // is_secure_store_available tests
237    // ============================================================
238
239    #[test]
240    fn test_is_secure_store_available_returns_bool() {
241        // Just verify it returns a boolean without panicking
242        let _: bool = is_secure_store_available();
243    }
244
245    // ============================================================
246    // Convenience function tests
247    // ============================================================
248
249    #[test]
250    fn test_nvidia_key_names_are_consistent() {
251        // Verify the key name constants used by convenience functions
252        let nvidia_key = "nvidia_api_key";
253        assert_eq!(nvidia_key, "nvidia_api_key");
254    }
255
256    #[test]
257    fn test_openai_key_names_are_consistent() {
258        // Verify the key name constants used by convenience functions
259        let openai_key = "openai_api_key";
260        assert_eq!(openai_key, "openai_api_key");
261    }
262
263    // ============================================================
264    // Error propagation and edge case tests
265    // ============================================================
266
267    #[test]
268    fn test_service_and_user_constants() {
269        // Verify the constants are correctly defined
270        assert_eq!(SERVICE_NAME, "pawan");
271        assert_eq!(USER, "api_keys");
272    }
273
274    #[test]
275    fn test_key_name_formatting() {
276        // Test the key name format used by all functions
277        let key_name = "test_key";
278        let formatted = format!("{}_{}", USER, key_name);
279        assert_eq!(formatted, "api_keys_test_key");
280    }
281
282    // ============================================================
283    // Integration tests that require credential store
284    // These remain ignored by default but can be run with:
285    // cargo test -- --ignored
286    // ============================================================
287
288    #[test]
289    #[ignore] // Requires a working credential store
290    fn test_store_and_get_key() {
291        let key_name = "test_key_12345";
292        let test_key = "test_api_key_value";
293
294        // Store the key
295        store_api_key(key_name, test_key).expect("Failed to store key");
296
297        // Retrieve the key
298        let retrieved = get_api_key(key_name).expect("Failed to retrieve key");
299        assert_eq!(retrieved, Some(test_key.to_string()));
300
301        // Clean up
302        delete_api_key(key_name).expect("Failed to delete key");
303    }
304
305    #[test]
306    #[ignore] // Requires a working credential store
307    fn test_get_nonexistent_key() {
308        let key_name = "nonexistent_key_12345";
309
310        // Delete to ensure clean state
311        let _ = delete_api_key(key_name);
312
313        // Try to retrieve
314        let retrieved = get_api_key(key_name).expect("Failed to retrieve key");
315        assert_eq!(retrieved, None);
316    }
317
318    #[test]
319    #[ignore] // Requires a working credential store
320    fn test_delete_key() {
321        let key_name = "test_delete_key_12345";
322        let test_key = "test_key_value";
323
324        // Store and verify
325        store_api_key(key_name, test_key).expect("Failed to store");
326        assert!(get_api_key(key_name).expect("Failed to get") == Some(test_key.to_string()));
327
328        // Delete and verify
329        delete_api_key(key_name).expect("Failed to delete");
330        assert_eq!(get_api_key(key_name).expect("Failed to get"), None);
331    }
332
333    #[test]
334    #[ignore] // Requires a working credential store
335    fn test_delete_nonexistent_key_succeeds() {
336        // Deleting a key that doesn't exist should return Ok(())
337        let key_name = "nonexistent_delete_key_12345";
338        let result = delete_api_key(key_name);
339        assert!(result.is_ok());
340    }
341
342    #[test]
343    #[ignore] // Requires a working credential store
344    fn test_nvidia_convenience_functions() {
345        let test_key = "nv-test-key-12345";
346
347        // Store via convenience function
348        store_nvidia_api_key(test_key).expect("Failed to store nvidia key");
349
350        // Retrieve via convenience function
351        let retrieved = get_nvidia_api_key().expect("Failed to get nvidia key");
352        assert_eq!(retrieved, Some(test_key.to_string()));
353
354        // Delete via convenience function
355        delete_nvidia_api_key().expect("Failed to delete nvidia key");
356
357        // Verify deleted
358        assert_eq!(get_nvidia_api_key().expect("Failed to get"), None);
359    }
360
361    #[test]
362    #[ignore] // Requires a working credential store
363    fn test_openai_convenience_functions() {
364        let test_key = "oa-test-key-12345";
365
366        // Store via convenience function
367        store_openai_api_key(test_key).expect("Failed to store openai key");
368
369        // Retrieve via convenience function
370        let retrieved = get_openai_api_key().expect("Failed to get openai key");
371        assert_eq!(retrieved, Some(test_key.to_string()));
372
373        // Delete via convenience function
374        delete_openai_api_key().expect("Failed to delete openai key");
375
376        // Verify deleted
377        assert_eq!(get_openai_api_key().expect("Failed to get"), None);
378    }
379}