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}