Skip to main content

aria2_core/auth/
digest_auth.rs

1//! Digest HTTP Authentication (RFC 7616)
2//!
3//! Implements challenge-response authentication with support for:
4//! - MD5, SHA-256, and SHA-512/256 hash algorithms
5//! - Quality of Protection (qop) with "auth" and "auth-int" modes
6//! - Automatic nonce counter incrementing for replay attack prevention
7//! - Stale nonce detection and re-authentication
8
9use std::fmt;
10use std::sync::atomic::{AtomicU32, Ordering};
11
12use crate::error::{Aria2Error, Result};
13
14/// Wrapper type for sensitive data that automatically zeros memory on drop.
15///
16/// This provides a simple alternative to the `secrecy` crate, using `zeroize`
17/// to ensure credentials are securely erased from memory when no longer needed.
18pub struct Secret<T: zeroize::Zeroize>(T);
19
20impl<T: zeroize::Zeroize> Secret<T> {
21    /// Create a new secret wrapper around the given value.
22    pub fn new(value: T) -> Self {
23        Secret(value)
24    }
25
26    /// Expose a reference to the inner value.
27    ///
28    /// # Security Warning
29    /// Use caution when exposing secrets. The returned reference should not
30    /// be stored or logged.
31    pub fn expose_secret(&self) -> &T {
32        &self.0
33    }
34}
35
36impl<T: zeroize::Zeroize + Clone> Clone for Secret<T> {
37    fn clone(&self) -> Self {
38        Secret(self.0.clone())
39    }
40}
41
42impl<T: zeroize::Zeroize + fmt::Debug> fmt::Debug for Secret<T> {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(f, "Secret(***)")
45    }
46}
47
48impl<T: zeroize::Zeroize> Drop for Secret<T> {
49    fn drop(&mut self) {
50        self.0.zeroize();
51    }
52}
53
54/// Supported digest hash algorithms as defined in RFC 7616.
55#[derive(Clone, Copy, Debug, PartialEq, Default)]
56pub enum DigestAlgorithm {
57    /// MD5 algorithm (less secure but widely supported)
58    #[default]
59    Md5,
60    /// SHA-256 algorithm (recommended for new implementations)
61    Sha256,
62    /// SHA-512/256 algorithm (truncated SHA-512)
63    Sha512_256,
64}
65
66impl fmt::Display for DigestAlgorithm {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            DigestAlgorithm::Md5 => write!(f, "MD5"),
70            DigestAlgorithm::Sha256 => write!(f, "SHA-256"),
71            DigestAlgorithm::Sha512_256 => write!(f, "SHA-512-256"),
72        }
73    }
74}
75
76/// Authentication scheme types supported by this module.
77#[derive(Debug, Clone, PartialEq)]
78pub enum AuthScheme {
79    /// Basic authentication (RFC 7617)
80    Basic,
81    /// Digest access authentication (RFC 7616)
82    Digest { algorithm: DigestAlgorithm },
83}
84
85impl fmt::Display for AuthScheme {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            AuthScheme::Basic => write!(f, "Basic"),
89            AuthScheme::Digest { .. } => write!(f, "Digest"),
90        }
91    }
92}
93
94/// Represents an authentication challenge from the server (WWW-Authenticate header).
95///
96/// Parsed from server responses that require authentication. Contains all parameters
97/// needed to construct a valid Authorization header.
98#[derive(Debug, Clone, PartialEq)]
99pub struct AuthChallenge {
100    /// The authentication scheme used by the server
101    pub scheme: AuthScheme,
102    /// Realm string identifying the protection space
103    pub realm: String,
104    /// Server-provided nonce value (opaque string)
105    pub nonce: Option<String>,
106    /// Server-provided opaque value (passed through unchanged)
107    pub opaque: Option<String>,
108    /// Quality of protection directive ("auth", "auth-int", or None)
109    pub qop: Option<String>,
110    /// Indicates if the previous request was rejected due to stale nonce
111    pub stale: bool,
112}
113
114/// Trait for HTTP authentication providers.
115///
116/// Implementations of this trait handle the construction of Authorization headers
117/// based on server challenges. Both Basic and Digest authentication implement this trait.
118#[async_trait::async_trait]
119pub trait AuthProvider: Send + Sync {
120    /// Returns the authentication scheme this provider implements.
121    fn scheme(&self) -> AuthScheme;
122
123    /// Builds an Authorization header value based on the server's challenge.
124    ///
125    /// # Arguments
126    /// * `challenge` - The authentication challenge from the WWW-Authenticate header
127    ///
128    /// # Returns
129    /// A complete Authorization header value (e.g., "Basic xxx" or "Digest ...")
130    fn build_authorization_header(&self, challenge: &AuthChallenge) -> Result<String>;
131}
132
133/// Digest authentication provider implementing RFC 7616.
134///
135/// This provider handles the complex challenge-response protocol required for
136/// Digest authentication, including:
137/// - HA1 computation (hash of username:realm:password)
138/// - HA2 computation (hash of method:uri or method:uri:entity_hash)
139/// - Response computation (KD function combining HA1 with challenge parameters)
140/// - Atomic nonce counter management for replay attack prevention
141pub struct DigestAuthProvider {
142    username: Secret<String>,
143    password: Secret<String>,
144    nc_count: AtomicU32,
145    algorithm: DigestAlgorithm,
146}
147
148impl DigestAuthProvider {
149    /// Creates a new Digest authentication provider.
150    ///
151    /// # Arguments
152    /// * `username` - The username for authentication
153    /// * `password` - The password for authentication
154    /// * `algorithm` - The hash algorithm to use (defaults to MD5)
155    pub fn new(username: String, password: String, algorithm: Option<DigestAlgorithm>) -> Self {
156        DigestAuthProvider {
157            username: Secret::new(username),
158            password: Secret::new(password),
159            nc_count: AtomicU32::new(1),
160            algorithm: algorithm.unwrap_or_default(),
161        }
162    }
163
164    /// Returns the current hash algorithm being used.
165    pub fn algorithm(&self) -> DigestAlgorithm {
166        self.algorithm
167    }
168
169    /// Computes HA1 = H(username:realm:password).
170    ///
171    /// This is the first step in Digest authentication response calculation.
172    /// The hash algorithm depends on the provider's configured algorithm.
173    ///
174    /// # Arguments
175    /// * `realm` - The realm from the authentication challenge
176    ///
177    /// # Returns
178    /// Hex-encoded hash string
179    pub fn compute_ha1(&self, realm: &str) -> String {
180        let input = format!(
181            "{}:{}:{}",
182            self.username.expose_secret(),
183            realm,
184            self.password.expose_secret()
185        );
186        self.hash(&input)
187    }
188
189    /// Computes HA2 = H(method:uri) or H(method:uri:H(entity-body)).
190    ///
191    /// When qop is "auth-int", includes the hash of the entity body.
192    ///
193    /// # Arguments
194    /// * `method` - HTTP method (GET, POST, etc.)
195    /// * `uri` - Request URI
196    /// * `qop` - Quality of protection mode
197    /// * `entity_body` - Optional entity body for auth-int mode
198    ///
199    /// # Returns
200    /// Hex-encoded hash string
201    pub fn compute_ha2(
202        &self,
203        method: &str,
204        uri: &str,
205        qop: Option<&str>,
206        entity_body: Option<&[u8]>,
207    ) -> String {
208        let input = if qop == Some("auth-int") {
209            let body_hash = match entity_body {
210                Some(body) => self.hash_from_bytes(body),
211                None => self.hash(""),
212            };
213            format!("{}:{}:{}", method, uri, body_hash)
214        } else {
215            format!("{}:{}", method, uri)
216        };
217        self.hash(&input)
218    }
219
220    /// Computes the final response = KD(HA1, nonce:nc:cnonce:qop:HA2).
221    ///
222    /// KD(secret, data) = H(concat(secret, ":", data))
223    ///
224    /// # Arguments
225    /// * `ha1` - Pre-computed HA1 value
226    /// * `nonce` - Server-provided nonce
227    /// * `nonce_count` - Hex-encoded 8-digit nonce count
228    /// * `cnonce` - Client-generated nonce
229    /// * `qop` - Quality of protection mode
230    /// * `ha2` - Pre-computed HA2 value
231    ///
232    /// # Returns
233    /// Hex-encoded response string
234    pub fn compute_response(
235        &self,
236        ha1: &str,
237        nonce: &str,
238        nonce_count: &str,
239        cnonce: &str,
240        qop: Option<&str>,
241        ha2: &str,
242    ) -> String {
243        let kd_input = match qop {
244            Some(q) => format!("{}:{}:{}:{}:{}", nonce, nonce_count, cnonce, q, ha2),
245            None => format!("{}:{}", nonce, ha2),
246        };
247
248        self.hash_kd(ha1, &kd_input)
249    }
250
251    /// Builds a complete Digest Authorization header value.
252    ///
253    /// Constructs all necessary components including:
254    /// - Username, realm, nonce, uri
255    /// - Algorithm specification
256    /// - Response hash
257    /// - Optional: qop, nc, cnonce, opaque
258    ///
259    /// # Arguments
260    /// * `challenge` - Server authentication challenge
261    /// * `method` - HTTP method for the request
262    /// * `uri` - Request URI
263    /// * `entity_body` - Optional entity body for auth-int
264    ///
265    /// # Returns
266    /// Complete Authorization header value starting with "Digest "
267    pub fn build_authorization_header_with_method(
268        &self,
269        challenge: &AuthChallenge,
270        method: &str,
271        uri: &str,
272        entity_body: Option<&[u8]>,
273    ) -> Result<String> {
274        let nonce = challenge
275            .nonce
276            .as_deref()
277            .ok_or_else(|| Aria2Error::Parse("Missing nonce in Digest challenge".to_string()))?;
278
279        // Compute HA1
280        let ha1 = self.compute_ha1(&challenge.realm);
281
282        // Generate cnonce (client nonce) - in production, use crypto-random
283        let cnonce = format!("{:016x}", rand::random::<u64>());
284
285        // Increment and get nonce count
286        let nc_raw = self.nc_count.fetch_add(1, Ordering::SeqCst);
287        let nc = format!("{:08x}", nc_raw);
288
289        // Determine QoP
290        let qop = challenge.qop.as_deref();
291
292        // Compute HA2
293        let ha2 = self.compute_ha2(method, uri, qop, entity_body);
294
295        // Compute response
296        let response = self.compute_response(&ha1, nonce, &nc, &cnonce, qop, &ha2);
297
298        // Build the authorization header
299        let mut parts = vec![
300            format!("username=\"{}\"", self.username.expose_secret()),
301            format!("realm=\"{}\"", challenge.realm),
302            format!("nonce=\"{}\"", nonce),
303            format!("uri=\"{}\"", uri),
304            format!("algorithm={}", self.algorithm),
305            format!("response=\"{}\"", response),
306        ];
307
308        if let Some(q) = qop {
309            parts.push(format!("qop={}", q));
310            parts.push(format!("nc={}", nc));
311            parts.push(format!("cnonce=\"{}\"", cnonce));
312        }
313
314        if let Some(ref opaque) = challenge.opaque {
315            parts.push(format!("opaque=\"{}\"", opaque));
316        }
317
318        Ok(format!("Digest {}", parts.join(", ")))
319    }
320
321    /// Hashes a string using the configured algorithm.
322    fn hash(&self, input: &str) -> String {
323        self.hash_from_bytes(input.as_bytes())
324    }
325
326    /// Hashes bytes using the configured algorithm.
327    fn hash_from_bytes(&self, input: &[u8]) -> String {
328        match self.algorithm {
329            DigestAlgorithm::Md5 => {
330                let digest = md5::compute(input);
331                format!("{:x}", digest)
332            }
333            DigestAlgorithm::Sha256 => {
334                use sha2::{Digest as _, Sha256};
335                let mut hasher = Sha256::new();
336                hasher.update(input);
337                let result = hasher.finalize();
338                hex::encode(result)
339            }
340            DigestAlgorithm::Sha512_256 => {
341                use sha2::{Digest as _, Sha512_256};
342                let mut hasher = Sha512_256::new();
343                hasher.update(input);
344                let result = hasher.finalize();
345                hex::encode(result)
346            }
347        }
348    }
349
350    /// Computes KD(secret, data) = H(secret ":" data)
351    fn hash_kd(&self, secret: &str, data: &str) -> String {
352        let kd_input = format!("{}:{}", secret, data);
353        self.hash(&kd_input)
354    }
355
356    /// Resets the nonce counter (useful for testing or new sessions).
357    pub fn reset_nonce_counter(&self) {
358        self.nc_count.store(1, Ordering::SeqCst);
359    }
360}
361
362#[async_trait::async_trait]
363impl AuthProvider for DigestAuthProvider {
364    fn scheme(&self) -> AuthScheme {
365        AuthScheme::Digest {
366            algorithm: self.algorithm,
367        }
368    }
369
370    fn build_authorization_header(&self, _challenge: &AuthChallenge) -> Result<String> {
371        // Note: For Digest auth, we need method and URI which aren't available here.
372        // This is a simplified interface; prefer build_authorization_header_with_method()
373        Err(Aria2Error::Parse(
374            "Digest auth requires method and URI. Use build_authorization_header_with_method()"
375                .to_string(),
376        ))
377    }
378}
379
380/// Parses a WWW-Authenticate header into an AuthChallenge.
381///
382/// Supports parsing of both Basic and Digest challenges with various parameter formats.
383///
384/// # Arguments
385/// * `header_value` - The raw WWW-Authenticate header value
386///
387/// # Returns
388/// Parsed AuthChallenge or error if parsing fails
389///
390/// # Example
391/// ```
392/// use aria2_core::auth::digest_auth::{parse_www_authenticate, AuthChallenge};
393///
394/// let challenge = parse_www_authenticate(
395///     "Digest realm=\"test\", nonce=\"abc123\", qop=\"auth\""
396/// ).unwrap();
397/// assert_eq!(challenge.realm, "test");
398/// ```
399pub fn parse_www_authenticate(header_value: &str) -> Result<AuthChallenge> {
400    let header = header_value.trim();
401
402    if header.starts_with("Basic ") {
403        return Ok(AuthChallenge {
404            scheme: AuthScheme::Basic,
405            realm: String::new(),
406            nonce: None,
407            opaque: None,
408            qop: None,
409            stale: false,
410        });
411    }
412
413    if !header.starts_with("Digest ") {
414        return Err(Aria2Error::Parse(format!(
415            "Unsupported auth scheme: {}",
416            header
417        )));
418    }
419
420    // Parse Digest parameters
421    let params_str = header.trim_start_matches("Digest").trim();
422    let mut realm = String::new();
423    let mut nonce = None;
424    let mut opaque = None;
425    let mut qop = None;
426    let mut stale = false;
427    let mut algorithm = DigestAlgorithm::Md5;
428
429    // Simple parser for key="value" or key=value pairs
430    let re = regex::Regex::new(r#"(?i)(\w+)\s*=\s*"([^"]*)"|(\w+)\s*=\s*(\w+)"#).unwrap();
431
432    for cap in re.captures_iter(params_str) {
433        let key = cap.get(1).or(cap.get(3)).map(|m| m.as_str()).unwrap_or("");
434        let value = cap.get(2).or(cap.get(4)).map(|m| m.as_str()).unwrap_or("");
435
436        match key.to_lowercase().as_str() {
437            "realm" => realm = value.to_string(),
438            "nonce" => nonce = Some(value.to_string()),
439            "opaque" => opaque = Some(value.to_string()),
440            "qop" => qop = Some(value.to_string()),
441            "stale" => stale = value.eq_ignore_ascii_case("true"),
442            "algorithm" => {
443                algorithm = match value.to_uppercase().as_str() {
444                    "MD5" | "MD5-SESS" => DigestAlgorithm::Md5,
445                    "SHA-256" | "SHA-256-SESS" => DigestAlgorithm::Sha256,
446                    "SHA-512-256" | "SHA-512-256-SESS" => DigestAlgorithm::Sha512_256,
447                    _ => DigestAlgorithm::Md5, // Default fallback
448                };
449            }
450            _ => {}
451        }
452    }
453
454    Ok(AuthChallenge {
455        scheme: AuthScheme::Digest { algorithm },
456        realm,
457        nonce,
458        opaque,
459        qop,
460        stale,
461    })
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::auth::basic_auth::BasicAuthProvider;
468    use crate::auth::credential_store::CredentialStore;
469
470    #[test]
471    fn test_basic_auth_header_format() {
472        // Test that Basic auth produces correct Base64 encoding
473        let provider = BasicAuthProvider::new("testuser".to_string(), "testpass".to_string(), true);
474
475        let challenge = AuthChallenge {
476            scheme: AuthScheme::Basic,
477            realm: "Test Realm".to_string(),
478            nonce: None,
479            opaque: None,
480            qop: None,
481            stale: false,
482        };
483
484        let result = provider.build_authorization_header(&challenge).unwrap();
485        // Base64 of "testuser:testpass" is "dGVzdHVzZXI6dGVzdHBhc3M="
486        assert_eq!(result, "Basic dGVzdHVzZXI6dGVzdHBhc3M=");
487    }
488
489    #[test]
490    fn test_basic_auth_https_only() {
491        // Test that non-HTTPS URLs are rejected when https_only is enabled
492        let provider = BasicAuthProvider::new(
493            "user".to_string(),
494            "pass".to_string(),
495            true, // https_only = true
496        );
497
498        let challenge = AuthChallenge {
499            scheme: AuthScheme::Basic,
500            realm: "Test".to_string(),
501            nonce: None,
502            opaque: None,
503            qop: None,
504            stale: false,
505        };
506
507        let result =
508            provider.build_authorization_header_with_url(&challenge, "http://example.com/file");
509        assert!(result.is_err());
510        assert!(result.unwrap_err().to_string().contains("HTTPS"));
511    }
512
513    #[test]
514    fn test_digest_md5_ha1_calculation() {
515        let provider = DigestAuthProvider::new(
516            "Mufasa".to_string(),
517            "Circle of Life".to_string(),
518            Some(DigestAlgorithm::Md5),
519        );
520
521        let ha1 = provider.compute_ha1("testrealm@host.com");
522        // Verify HA1 is a valid 32-character hex string (MD5 output)
523        assert_eq!(ha1.len(), 32);
524        // Verify it's a valid hex string
525        assert!(ha1.chars().all(|c| c.is_ascii_hexdigit()));
526        // Verify it's deterministic
527        let ha1_again = provider.compute_ha1("testrealm@host.com");
528        assert_eq!(ha1, ha1_again);
529    }
530
531    #[test]
532    fn test_digest_md5_ha2_calculation() {
533        let provider = DigestAuthProvider::new(
534            "Mufasa".to_string(),
535            "Circle of Life".to_string(),
536            Some(DigestAlgorithm::Md5),
537        );
538
539        let ha2 = provider.compute_ha2("GET", "/dir/index.html", None, None);
540        // Verify HA2 is a valid 32-character hex string (MD5 output)
541        assert_eq!(ha2.len(), 32);
542        // Verify it's a valid hex string
543        assert!(ha2.chars().all(|c| c.is_ascii_hexdigit()));
544        // Verify different inputs produce different outputs
545        let ha2_post = provider.compute_ha2("POST", "/dir/index.html", None, None);
546        assert_ne!(ha2, ha2_post);
547    }
548
549    #[test]
550    fn test_digest_md5_response_calculation() {
551        let provider = DigestAuthProvider::new(
552            "Mufasa".to_string(),
553            "Circle of Life".to_string(),
554            Some(DigestAlgorithm::Md5),
555        );
556
557        let ha1 = provider.compute_ha1("testrealm@host.com");
558        let ha2 = provider.compute_ha2("GET", "/dir/index.html", None, None);
559
560        // Test without qop - should produce a valid response
561        let response = provider.compute_response(
562            &ha1,
563            "dcd98b7102dd2f0e8b11d0f600bfb0c093",
564            "",
565            "",
566            None,
567            &ha2,
568        );
569        // Response should be a valid 32-character hex string (MD5 output)
570        assert_eq!(response.len(), 32);
571        assert!(response.chars().all(|c| c.is_ascii_hexdigit()));
572
573        // Different nonce should produce different response
574        let response2 = provider.compute_response(&ha1, "different-nonce", "", "", None, &ha2);
575        assert_ne!(response, response2);
576    }
577
578    #[test]
579    fn test_digest_sha256_variant() {
580        let provider = DigestAuthProvider::new(
581            "user".to_string(),
582            "pass".to_string(),
583            Some(DigestAlgorithm::Sha256),
584        );
585
586        let ha1 = provider.compute_ha1("testrealm");
587        // Verify it uses SHA-256 (length should be 64 hex chars)
588        assert_eq!(ha1.len(), 64);
589
590        // Verify it differs from MD5
591        let provider_md5 = DigestAuthProvider::new(
592            "user".to_string(),
593            "pass".to_string(),
594            Some(DigestAlgorithm::Md5),
595        );
596        let ha1_md5 = provider_md5.compute_ha1("testrealm");
597        assert_ne!(ha1, ha1_md5);
598        assert_eq!(ha1_md5.len(), 32); // MD5 is 32 hex chars
599    }
600
601    #[test]
602    fn test_digest_nonce_counter_increments() {
603        let provider = DigestAuthProvider::new(
604            "user".to_string(),
605            "pass".to_string(),
606            Some(DigestAlgorithm::Md5),
607        );
608
609        // Reset counter for clean test
610        provider.reset_nonce_counter();
611
612        // Get initial value
613        let nc1 = provider.nc_count.load(Ordering::SeqCst);
614        assert_eq!(nc1, 1);
615
616        // Simulate multiple requests (fetch_add returns the old value)
617        let mut last_nc = nc1;
618        for _i in 0..5 {
619            let old_val = provider.nc_count.fetch_add(1, Ordering::SeqCst);
620            assert_eq!(old_val, last_nc); // Verify we get the expected old value
621            last_nc = old_val + 1;
622        }
623
624        // After 5 increments starting from 1, current value should be 6
625        let nc_final = provider.nc_count.load(Ordering::SeqCst);
626        assert_eq!(nc_final, 6);
627
628        // Verify counter is still incrementing
629        let _ = provider.nc_count.fetch_add(1, Ordering::SeqCst);
630        assert_eq!(provider.nc_count.load(Ordering::SeqCst), 7);
631    }
632
633    #[test]
634    fn test_www_authenticate_header_parsing() {
635        // Test standard Digest header
636        let header = r#"Digest realm="testrealm@host.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", qop="auth""#;
637        let challenge = parse_www_authenticate(header).unwrap();
638
639        assert_eq!(
640            challenge.scheme,
641            AuthScheme::Digest {
642                algorithm: DigestAlgorithm::Md5
643            }
644        );
645        assert_eq!(challenge.realm, "testrealm@host.com");
646        assert_eq!(
647            challenge.nonce,
648            Some("dcd98b7102dd2f0e8b11d0f600bfb0c093".to_string())
649        );
650        assert_eq!(challenge.qop, Some("auth".to_string()));
651        assert!(!challenge.stale);
652    }
653
654    #[test]
655    fn test_multi_realm_challenge() {
656        // Test parsing different realms
657        let header1 = r#"Digest realm="Realm1", nonce="abc""#;
658        let challenge1 = parse_www_authenticate(header1).unwrap();
659        assert_eq!(challenge1.realm, "Realm1");
660
661        let header2 = r#"Digest realm="Another Realm", nonce="xyz", opaque="opaque123""#;
662        let challenge2 = parse_www_authenticate(header2).unwrap();
663        assert_eq!(challenge2.realm, "Another Realm");
664        assert_eq!(challenge2.opaque, Some("opaque123".to_string()));
665    }
666
667    #[test]
668    fn test_stale_nonce_handling() {
669        // Test stale=true parsing
670        let header = r#"Digest realm="test", nonce="new-nonce", stale=true"#;
671        let challenge = parse_www_authenticate(header).unwrap();
672        assert!(challenge.stale);
673
674        // Test stale=false (default)
675        let header2 = r#"Digest realm="test", nonce="abc""#;
676        let challenge2 = parse_www_authenticate(header2).unwrap();
677        assert!(!challenge2.stale);
678
679        // Test stale=TRUE (case insensitive)
680        let header3 = r#"Digest realm="test", nonce="abc", stale=TRUE"#;
681        let challenge3 = parse_www_authenticate(header3).unwrap();
682        assert!(challenge3.stale);
683    }
684
685    #[test]
686    fn test_credential_store_operations() {
687        let store = CredentialStore::new();
688
689        // Store credentials
690        store.store("example.com", "alice", b"secret123");
691
692        // Retrieve credentials
693        let creds = store.get("example.com").unwrap();
694        assert_eq!(creds.username, "alice");
695        assert_eq!(creds.password, b"secret123");
696
697        // Remove credentials
698        let removed = store.remove("example.com");
699        assert!(removed.is_some());
700
701        // Verify removal
702        assert!(store.get("example.com").is_none());
703    }
704
705    #[test]
706    fn test_password_zeroize_on_drop() {
707        // This test verifies that passwords are zeroized when dropped
708        // We can't directly inspect memory after drop, but we can verify the mechanism works
709        let store = CredentialStore::new();
710        store.store("test.com", "user", b"sensitive-password");
711
712        // Clear will trigger Drop for all entries
713        store.clear();
714        assert!(store.get("test.com").is_none());
715    }
716
717    #[test]
718    fn test_log_debug_display_masking() {
719        let secret = Secret::new("super-secret-password".to_string());
720
721        // Debug output should mask the value
722        let debug_output = format!("{:?}", secret);
723        assert_eq!(debug_output, "Secret(***)");
724        assert!(!debug_output.contains("password"));
725
726        // We can still access the actual value through expose_secret
727        assert_eq!(secret.expose_secret(), &"super-secret-password".to_string());
728    }
729
730    #[test]
731    fn test_qop_auth_int() {
732        let provider = DigestAuthProvider::new(
733            "user".to_string(),
734            "pass".to_string(),
735            Some(DigestAlgorithm::Md5),
736        );
737
738        let entity_body = b"Hello World";
739
740        // auth-int should include entity body hash
741        let ha2_auth_int =
742            provider.compute_ha2("POST", "/api/data", Some("auth-int"), Some(entity_body));
743
744        // Regular auth should NOT include entity body hash
745        let ha2_auth = provider.compute_ha2("POST", "/api/data", Some("auth"), None);
746
747        // They should differ because auth-int hashes the body
748        assert_ne!(ha2_auth_int, ha2_auth);
749
750        // Without entity body, auth-int still computes differently than auth
751        let ha2_auth_int_empty = provider.compute_ha2("POST", "/api/data", Some("auth-int"), None);
752        assert_ne!(ha2_auth_int_empty, ha2_auth);
753    }
754
755    #[test]
756    fn test_empty_nonce_handling() {
757        let provider = DigestAuthProvider::new(
758            "user".to_string(),
759            "pass".to_string(),
760            Some(DigestAlgorithm::Md5),
761        );
762
763        let challenge = AuthChallenge {
764            scheme: AuthScheme::Digest {
765                algorithm: DigestAlgorithm::Md5,
766            },
767            realm: "test".to_string(),
768            nonce: None, // Empty nonce
769            opaque: None,
770            qop: None,
771            stale: false,
772        };
773
774        let result =
775            provider.build_authorization_header_with_method(&challenge, "GET", "/path", None);
776
777        // Should fail with missing nonce error
778        assert!(result.is_err());
779        assert!(result.unwrap_err().to_string().contains("Missing nonce"));
780    }
781
782    #[test]
783    fn test_digest_full_authorization_header() {
784        let provider = DigestAuthProvider::new(
785            "Mufasa".to_string(),
786            "Circle of Life".to_string(),
787            Some(DigestAlgorithm::Md5),
788        );
789
790        provider.reset_nonce_counter();
791
792        let challenge = AuthChallenge {
793            scheme: AuthScheme::Digest {
794                algorithm: DigestAlgorithm::Md5,
795            },
796            realm: "testrealm@host.com".to_string(),
797            nonce: Some("dcd98b7102dd2f0e8b11d0f600bfb0c093".to_string()),
798            opaque: Some("5ccc069c403ebaf9f0171e9517f40e41".to_string()),
799            qop: Some("auth".to_string()),
800            stale: false,
801        };
802
803        let result = provider.build_authorization_header_with_method(
804            &challenge,
805            "GET",
806            "/dir/index.html",
807            None,
808        );
809
810        assert!(result.is_ok());
811        let header = result.unwrap();
812        assert!(header.starts_with("Digest "));
813        assert!(header.contains("username=\"Mufasa\""));
814        assert!(header.contains("realm=\"testrealm@host.com\""));
815        assert!(header.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""));
816        assert!(header.contains("uri=\"/dir/index.html\""));
817        assert!(header.contains("response=\""));
818        assert!(header.contains("qop=auth"));
819        assert!(header.contains("nc="));
820        assert!(header.contains("cnonce=\""));
821        assert!(header.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""));
822    }
823}