Skip to main content

aria2_core/auth/
credential_store.rs

1//! Credential Store for HTTP Authentication
2//!
3//! Provides a thread-safe storage mechanism for domain-specific credentials.
4//! All passwords are automatically zeroized from memory when entries are removed
5//! or the store is dropped, preventing sensitive data from lingering in memory.
6//!
7//! # Features
8//! - Thread-safe: Uses `RwLock` for concurrent read access
9//! - Memory safe: Passwords are zeroed on drop using `zeroize`
10//! - Domain-based: Credentials are organized by domain/hostname
11//! - CRUD operations: Store, retrieve, remove, and clear credentials
12//!
13//! # Example
14//! ```rust
15//! use aria2_core::auth::credential_store::CredentialStore;
16//!
17//! let store = CredentialStore::new();
18//! store.store("example.com", "alice", b"secret123");
19//!
20//! if let Some(creds) = store.get("example.com") {
21//!     println!("Found credentials for user: {}", creds.username);
22//! }
23//! ```
24
25use std::collections::HashMap;
26use std::fmt;
27use std::sync::RwLock;
28use zeroize::Zeroize;
29
30/// A credential entry containing username and password for a specific domain.
31///
32/// The password field uses `Zeroize` to ensure secure memory erasure when the
33/// entry is dropped. This prevents sensitive data from remaining in memory
34/// after credentials are no longer needed.
35#[derive(Clone)]
36pub struct PasswordEntry {
37    /// Username for authentication
38    pub username: String,
39    /// Password bytes (zeroized on drop)
40    pub password: Vec<u8>,
41}
42
43impl fmt::Debug for PasswordEntry {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.debug_struct("PasswordEntry")
46            .field("username", &self.username)
47            .field("password", &"***") // Mask password in debug output
48            .finish()
49    }
50}
51
52impl Drop for PasswordEntry {
53    fn drop(&mut self) {
54        // Zeroize the password before dropping
55        self.password.zeroize();
56    }
57}
58
59/// Thread-safe credential storage for HTTP authentication.
60///
61/// Stores username/password pairs indexed by domain (hostname or URL pattern).
62/// Provides concurrent access through `RwLock`, allowing multiple readers
63/// while ensuring exclusive access during writes.
64///
65/// # Security Guarantees
66/// - All passwords are stored as `Vec<u8>` with automatic zeroization on drop
67/// - When entries are removed or the store is cleared, passwords are immediately zeroed
68/// - Debug output masks passwords to prevent accidental logging of secrets
69pub struct CredentialStore {
70    /// Internal storage: domain -> PasswordEntry mapping
71    credentials: RwLock<HashMap<String, PasswordEntry>>,
72}
73
74impl CredentialStore {
75    /// Creates a new empty credential store.
76    ///
77    /// # Example
78    /// ```
79    /// use aria2_core::auth::credential_store::CredentialStore;
80    ///
81    /// let store = CredentialStore::new();
82    /// assert_eq!(store.count(), 0);
83    /// ```
84    pub fn new() -> Self {
85        CredentialStore {
86            credentials: RwLock::new(HashMap::new()),
87        }
88    }
89
90    /// Stores credentials for a domain.
91    ///
92    /// If credentials already exist for the given domain, they will be replaced
93    /// and the old password will be zeroized before being dropped.
94    ///
95    /// # Arguments
96    /// * `domain` - Domain or hostname (e.g., "example.com")
97    /// * `username` - Username for authentication
98    /// * `password` - Password as byte slice (will be copied and stored securely)
99    ///
100    /// # Example
101    /// ```no_run
102    /// use aria2_core::auth::credential_store::{CredentialStore, PasswordEntry};
103    ///
104    /// let mut store = CredentialStore::new();
105    /// store.store("api.example.com", "admin", b"secure-password-123");
106    /// ```
107    pub fn store(&self, domain: &str, username: &str, password: &[u8]) {
108        let mut creds = self.credentials.write().unwrap();
109        let entry = PasswordEntry {
110            username: username.to_string(),
111            password: password.to_vec(),
112        };
113        creds.insert(domain.to_string(), entry);
114    }
115
116    /// Retrieves credentials for a domain.
117    ///
118    /// Returns a clone of the `PasswordEntry` if found, or `None` if no
119    /// credentials exist for the specified domain.
120    ///
121    /// # Arguments
122    /// * `domain` - Domain or hostname to look up
123    ///
124    /// # Returns
125    /// - `Some(PasswordEntry)` if credentials exist
126    /// - `None` if no credentials found
127    ///
128    /// # Security Note
129    /// The returned `PasswordEntry` contains the actual password. Handle it
130    /// carefully and allow it to be dropped naturally so the password gets
131    /// zeroized automatically.
132    ///
133    /// # Example
134    /// ```
135    /// use aria2_core::auth::credential_store::{CredentialStore, PasswordEntry};
136    ///
137    /// let store = CredentialStore::new();
138    /// store.store("example.com", "user", b"pass");
139    /// if let Some(creds) = store.get("example.com") {
140    ///     println!("Username: {}", creds.username);
141    /// } // Password is zeroized when creds goes out of scope
142    /// ```
143    pub fn get(&self, domain: &str) -> Option<PasswordEntry> {
144        let creds = self.credentials.read().unwrap();
145        creds.get(domain).cloned()
146    }
147
148    /// Removes credentials for a domain.
149    ///
150    /// The removed entry's password will be zeroized before this method returns.
151    ///
152    /// # Arguments
153    /// * `domain` - Domain or hostname to remove credentials for
154    ///
155    /// # Returns
156    /// - `Some(PasswordEntry)` that was removed
157    /// - `None` if no credentials existed for the domain
158    ///
159    /// # Example
160    /// ```
161    /// use aria2_core::auth::credential_store::{CredentialStore, PasswordEntry};
162    ///
163    /// let mut store = CredentialStore::new();
164    /// store.store("old-server.com", "admin", b"pass");
165    /// if let Some(removed) = store.remove("old-server.com") {
166    ///     println!("Removed credentials for user: {}", removed.username);
167    /// }
168    /// ```
169    pub fn remove(&self, domain: &str) -> Option<PasswordEntry> {
170        let mut creds = self.credentials.write().unwrap();
171        creds.remove(domain)
172    }
173
174    /// Clears all stored credentials.
175    ///
176    /// All passwords in the store will be zeroized before this method returns.
177    /// After calling this method, the store will be empty.
178    ///
179    /// # Example
180    /// ```
181    /// use aria2_core::auth::credential_store::CredentialStore;
182    ///
183    /// let mut store = CredentialStore::new();
184    /// store.store("example.com", "user", b"pass");
185    /// store.clear(); // All passwords securely erased
186    /// assert_eq!(store.count(), 0);
187    /// ```
188    pub fn clear(&self) {
189        let mut creds = self.credentials.write().unwrap();
190        creds.clear(); // All PasswordEntries are dropped, triggering zeroization
191    }
192
193    /// Returns the number of stored credential entries.
194    pub fn count(&self) -> usize {
195        let creds = self.credentials.read().unwrap();
196        creds.len()
197    }
198
199    /// Checks if credentials exist for a domain.
200    ///
201    /// # Arguments
202    /// * `domain` - Domain or hostname to check
203    ///
204    /// # Returns
205    /// `true` if credentials exist, `false` otherwise
206    pub fn has_credentials(&self, domain: &str) -> bool {
207        let creds = self.credentials.read().unwrap();
208        creds.contains_key(domain)
209    }
210
211    /// Lists all domains that have stored credentials.
212    ///
213    /// # Returns
214    /// A vector of domain strings (not the actual credentials)
215    ///
216    /// # Note
217    /// This does not expose any sensitive data, only domain names.
218    pub fn list_domains(&self) -> Vec<String> {
219        let creds = self.credentials.read().unwrap();
220        creds.keys().cloned().collect()
221    }
222}
223
224impl Default for CredentialStore {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230impl fmt::Debug for CredentialStore {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        f.debug_struct("CredentialStore")
233            .field("entry_count", &self.count())
234            .finish()
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_credential_store_creation() {
244        let store = CredentialStore::new();
245        assert_eq!(store.count(), 0);
246        assert!(!store.has_credentials("example.com"));
247    }
248
249    #[test]
250    fn test_credential_store_default() {
251        let store = CredentialStore::default();
252        assert_eq!(store.count(), 0);
253    }
254
255    #[test]
256    fn test_credential_store_operations() {
257        let store = CredentialStore::new();
258
259        // Test initial state
260        assert_eq!(store.count(), 0);
261
262        // Store credentials
263        store.store("example.com", "alice", b"secret123");
264        assert_eq!(store.count(), 1);
265        assert!(store.has_credentials("example.com"));
266
267        // Retrieve credentials
268        let creds = store.get("example.com").expect("Should find credentials");
269        assert_eq!(creds.username, "alice");
270        assert_eq!(creds.password, b"secret123");
271
272        // Remove credentials
273        let removed = store
274            .remove("example.com")
275            .expect("Should remove credentials");
276        assert_eq!(removed.username, "alice");
277        assert_eq!(store.count(), 0);
278        assert!(!store.has_credentials("example.com"));
279    }
280
281    #[test]
282    fn test_credential_store_multiple_domains() {
283        let store = CredentialStore::new();
284
285        store.store("api.example.com", "admin", b"admin-pass");
286        store.store("db.example.com", "dbuser", b"db-pass");
287        store.store("auth.example.com", "authuser", b"auth-pass");
288
289        assert_eq!(store.count(), 3);
290
291        // Verify each domain has correct credentials
292        let api_creds = store.get("api.example.com").unwrap();
293        assert_eq!(api_creds.username, "admin");
294
295        let db_creds = store.get("db.example.com").unwrap();
296        assert_eq!(db_creds.username, "dbuser");
297
298        // List domains
299        let domains = store.list_domains();
300        assert_eq!(domains.len(), 3);
301        assert!(domains.contains(&"api.example.com".to_string()));
302        assert!(domains.contains(&"db.example.com".to_string()));
303        assert!(domains.contains(&"auth.example.com".to_string()));
304    }
305
306    #[test]
307    fn test_credential_store_overwrite() {
308        let store = CredentialStore::new();
309
310        // Store initial credentials
311        store.store("example.com", "user1", b"pass1");
312
313        // Overwrite with new credentials
314        store.store("example.com", "user2", b"pass2");
315
316        // Should have new credentials
317        assert_eq!(store.count(), 1); // Still only one entry
318
319        let creds = store.get("example.com").unwrap();
320        assert_eq!(creds.username, "user2");
321        assert_eq!(creds.password, b"pass2");
322    }
323
324    #[test]
325    fn test_credential_store_remove_nonexistent() {
326        let store = CredentialStore::new();
327
328        let result = store.remove("nonexistent.com");
329        assert!(result.is_none());
330        assert_eq!(store.count(), 0);
331    }
332
333    #[test]
334    fn test_credential_store_clear() {
335        let store = CredentialStore::new();
336
337        store.store("domain1.com", "user1", b"pass1");
338        store.store("domain2.com", "user2", b"pass2");
339        store.store("domain3.com", "user3", b"pass3");
340
341        assert_eq!(store.count(), 3);
342
343        store.clear();
344
345        assert_eq!(store.count(), 0);
346        assert!(!store.has_credentials("domain1.com"));
347        assert!(!store.has_credentials("domain2.com"));
348        assert!(!store.has_credentials("domain3.com"));
349    }
350
351    #[test]
352    fn test_password_entry_debug_masking() {
353        let entry = PasswordEntry {
354            username: "testuser".to_string(),
355            password: b"super-secret-password".to_vec(),
356        };
357
358        let debug_output = format!("{:?}", entry);
359
360        // Verify username is visible but password is masked
361        assert!(debug_output.contains("testuser"));
362        assert!(debug_output.contains("***")); // Masked password
363        assert!(!debug_output.contains("super-secret-password"));
364    }
365
366    #[test]
367    fn test_credential_store_debug() {
368        let store = CredentialStore::new();
369        store.store("example.com", "user", b"pass");
370
371        let debug_output = format!("{:?}", store);
372
373        // Should show count but not expose credentials
374        assert!(debug_output.contains("CredentialStore"));
375        assert!(debug_output.contains("entry_count"));
376        assert!(debug_output.contains("1"));
377        assert!(!debug_output.contains("user"));
378        assert!(!debug_output.contains("pass"));
379    }
380
381    #[test]
382    fn test_credential_store_concurrent_access() {
383        use std::sync::Arc;
384        use std::thread;
385
386        let store = Arc::new(CredentialStore::new());
387        let mut handles = vec![];
388
389        // Spawn multiple threads that write concurrently
390        for i in 0..10 {
391            let store_clone = Arc::clone(&store);
392            handles.push(thread::spawn(move || {
393                let domain = format!("domain{}.com", i);
394                store_clone.store(
395                    &domain,
396                    &format!("user{}", i),
397                    format!("pass{}", i).as_bytes(),
398                );
399            }));
400        }
401
402        // Wait for all threads to complete
403        for handle in handles {
404            handle.join().unwrap();
405        }
406
407        // Verify all entries were created
408        assert_eq!(store.count(), 10);
409
410        // Verify we can read from multiple threads
411        let mut read_handles = vec![];
412        for i in 0..10 {
413            let store_clone = Arc::clone(&store);
414            read_handles.push(thread::spawn(move || {
415                let domain = format!("domain{}.com", i);
416                let creds = store_clone.get(&domain).unwrap();
417                assert_eq!(creds.username, format!("user{}", i));
418            }));
419        }
420
421        for handle in read_handles {
422            handle.join().unwrap();
423        }
424    }
425
426    #[test]
427    fn test_empty_domain_and_credentials() {
428        let store = CredentialStore::new();
429
430        // Edge case: empty domain string
431        store.store("", "user", b"pass");
432        assert!(store.has_credentials(""));
433        let creds = store.get("").unwrap();
434        assert_eq!(creds.username, "user");
435
436        // Edge case: empty username
437        store.store("empty-user.com", "", b"pass");
438        let creds = store.get("empty-user.com").unwrap();
439        assert_eq!(creds.username, "");
440
441        // Edge case: empty password
442        store.store("empty-pass.com", "user", b"");
443        let creds = store.get("empty-pass.com").unwrap();
444        assert_eq!(creds.password, b"");
445
446        assert_eq!(store.count(), 3);
447    }
448
449    #[test]
450    fn test_binary_password_storage() {
451        let store = CredentialStore::new();
452
453        // Store binary data as password (e.g., hashed credentials)
454        let binary_pass: Vec<u8> = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF];
455        store.store("binary-auth.com", "binary_user", &binary_pass);
456
457        let creds = store.get("binary-auth.com").unwrap();
458        assert_eq!(creds.password, binary_pass);
459        assert_eq!(creds.password.len(), 6);
460    }
461
462    #[test]
463    fn test_unicode_in_credentials() {
464        let store = CredentialStore::new();
465
466        // Unicode characters in all fields
467        store.store("例子.コム", "ユーザー名", "パスワード".as_bytes());
468
469        let creds = store.get("例子.コム").unwrap();
470        assert_eq!(creds.username, "ユーザー名");
471        assert_eq!(creds.password, "パスワード".as_bytes());
472    }
473}