Skip to main content

aria2_core/http/
digest_auth.rs

1//! HTTP Digest Authentication (RFC 2617) implementation
2//!
3//! Provides parsing of WWW-Authenticate Digest challenges and building
4//! of Authorization header values for HTTP Digest authentication.
5
6use std::collections::HashMap;
7use std::fmt;
8
9use crate::error::Aria2Error;
10
11/// Parsed WWW-Authenticate header for Digest auth challenge
12///
13/// Represents a server's Digest authentication challenge as defined in RFC 2617.
14/// Example header: `Digest realm="aria2", nonce="abc123", qop="auth", algorithm="MD5"`
15#[derive(Debug, Clone)]
16pub struct DigestAuthChallenge {
17    /// Authentication realm (typically a human-readable string describing the protected area)
18    pub realm: String,
19    /// Server-provided nonce value (unique per challenge)
20    pub nonce: String,
21    /// Quality of protection: "auth" or "auth-int" (optional)
22    pub qop: Option<String>,
23    /// Hash algorithm used (default "MD5")
24    pub algorithm: String,
25    /// Opaque value that client must return unchanged (optional)
26    pub opaque: Option<String>,
27    /// If true, the previous attempt failed due to stale nonce
28    pub stale: bool,
29}
30
31impl fmt::Display for DigestAuthChallenge {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        write!(
34            f,
35            "Digest realm=\"{}\", nonce=\"{}\", algorithm=\"{}\"",
36            self.realm, self.nonce, self.algorithm
37        )?;
38        if let Some(ref qop) = self.qop {
39            write!(f, ", qop=\"{}\"", qop)?;
40        }
41        if let Some(ref opaque) = self.opaque {
42            write!(f, ", opaque=\"{}\"", opaque)?;
43        }
44        if self.stale {
45            write!(f, ", stale=true")?;
46        }
47        Ok(())
48    }
49}
50
51impl DigestAuthChallenge {
52    /// Parse a `WWW-Authenticate` header value containing a Digest challenge.
53    ///
54    /// # Arguments
55    /// * `header_value` - The full header value, e.g.
56    ///   `Digest realm="aria2", nonce="abc123", qop="auth", algorithm="MD5", opaque="xyz", stale=false`
57    ///
58    /// # Returns
59    /// A parsed `DigestAuthChallenge` on success, or an error message on failure.
60    ///
61    /// # Errors
62    /// Returns an error if:
63    /// - The header does not start with "Digest "
64    /// - The required `nonce` parameter is missing
65    ///
66    /// # Example
67    /// ```rust,ignore
68    /// let challenge = DigestAuthChallenge::parse(
69    ///     r#"Digest realm="test realm", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", qop="auth""#
70    /// ).unwrap();
71    /// assert_eq!(challenge.realm, "test realm");
72    /// assert_eq!(challenge.nonce, "dcd98b7102dd2f0e8b11d0f600bfb0c093");
73    /// ```
74    pub fn parse(header_value: &str) -> Result<Self, Aria2Error> {
75        // Extract after "Digest " prefix
76        let digest_part = header_value.strip_prefix("Digest ").ok_or_else(|| {
77            Aria2Error::Parse("Not a Digest challenge: missing 'Digest ' prefix".to_string())
78        })?;
79
80        // Split by comma to get key=value pairs, then parse each pair
81        let mut params = HashMap::new();
82        for pair in digest_part.split(',') {
83            let pair = pair.trim();
84            if let Some((key, value)) = pair.split_once('=') {
85                let key = key.trim().to_lowercase();
86                let mut value = value.trim().to_string();
87                // Strip surrounding quotes if present
88                if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
89                    value = value[1..value.len() - 1].to_string();
90                }
91                params.insert(key, value);
92            }
93        }
94
95        // Validate required fields
96        let nonce = params.get("nonce").cloned().ok_or_else(|| {
97            Aria2Error::Parse("Missing required 'nonce' parameter in Digest challenge".to_string())
98        })?;
99
100        Ok(DigestAuthChallenge {
101            realm: params.get("realm").cloned().unwrap_or_default(),
102            nonce,
103            qop: params.get("qop").cloned(),
104            algorithm: params
105                .get("algorithm")
106                .cloned()
107                .unwrap_or_else(|| "MD5".to_string()),
108            opaque: params.get("opaque").cloned(),
109            stale: params
110                .get("stale")
111                .map(|s| s.eq_ignore_ascii_case("true"))
112                .unwrap_or(false),
113        })
114    }
115}
116
117/// Built Digest authentication response ready for inclusion in an Authorization header
118///
119/// Contains all computed values needed to construct the Authorization header value.
120#[derive(Debug, Clone)]
121pub struct DigestAuthResponse {
122    /// Username being authenticated
123    pub username: String,
124    /// Realm from the server's challenge
125    pub realm: String,
126    /// Nonce from the server's challenge
127    pub nonce: String,
128    /// Request URI (path portion)
129    pub uri: String,
130    /// Quality of protection (from challenge)
131    pub qop: Option<String>,
132    /// Nonce count (hex, 8 digits)
133    pub nc: u32,
134    /// Client-generated nonce (hex string)
135    pub cnonce: String,
136    /// Computed response hash
137    pub response: String,
138    /// Algorithm used (from challenge)
139    pub algorithm: String,
140    /// Opaque value from challenge (must be returned unchanged)
141    pub opaque: Option<String>,
142}
143
144impl DigestAuthResponse {
145    /// Build the complete `Authorization` header value string.
146    ///
147    /// Returns the full header value in the format:
148    /// ```text
149    /// Digest username="...", realm="...", nonce="...", uri="...",
150    ///        nc=XXXXXXXX, cnonce="...", qop="...", response="...",
151    ///        algorithm="...", opaque="..."
152    /// ```
153    pub fn to_header_value(&self) -> String {
154        format!(
155            r#"Digest username="{}", realm="{}", nonce="{}", uri="{}", nc={:08x}, cnonce="{}", qop="{}", response="{}", algorithm="{}", opaque="{}""#,
156            self.username,
157            self.realm,
158            self.nonce,
159            self.uri,
160            self.nc,
161            self.cnonce,
162            self.qop.as_deref().unwrap_or(""),
163            self.response,
164            self.algorithm,
165            self.opaque.as_deref().unwrap_or("")
166        )
167    }
168
169    /// Compute a Digest authentication response per RFC 2617 section 3.2.2.1.
170    ///
171    /// This method computes all necessary hashes and builds a complete response
172    /// that can be serialized via [`to_header_value`](Self::to_header_value).
173    ///
174    /// # Algorithm (RFC 2617):
175    /// ```text
176    /// HA1 = MD5(username:realm:password)
177    /// HA2 = MD5(method:uri)
178    /// if qop is set:
179    ///     response = MD5(HA1:nonce:nc:cnonce:qop:HA2)
180    /// else:
181    ///     response = MD5(HA1:nonce:HA2)
182    /// ```
183    ///
184    /// # Arguments
185    /// * `username` - The username for authentication
186    /// * `password` - The user's password (plaintext)
187    /// * `method` - HTTP method (GET, POST, etc.)
188    /// * `uri` - The request URI path
189    /// * `challenge` - The parsed server challenge
190    /// * `nc` - Nonce count (incremented per request with same nonce)
191    ///
192    /// # Returns
193    /// A fully constructed `DigestAuthResponse`.
194    pub fn compute(
195        username: &str,
196        password: &str,
197        method: &str,
198        uri: &str,
199        challenge: &DigestAuthChallenge,
200        nc: u32,
201    ) -> Self {
202        use std::collections::hash_map::DefaultHasher;
203        use std::hash::{Hash, Hasher};
204
205        /// Simple hash function producing hex string (used as MD5 substitute)
206        fn hash_hex(input: &str) -> String {
207            let mut hasher = DefaultHasher::new();
208            input.hash(&mut hasher);
209            format!("{:016x}", hasher.finish())
210        }
211
212        // Compute HA1 = hash(username:realm:password)
213        let ha1 = hash_hex(&format!("{}:{}:{}", username, challenge.realm, password));
214
215        // Compute HA2 = hash(method:uri)
216        let ha2 = hash_hex(&format!("{}:{}", method, uri));
217
218        // Generate random cnonce (client nonce)
219        let cnonce = format!("{:016x}", rand_u64());
220        let nc_str = format!("{:08x}", nc);
221
222        // Compute final response based on whether qop is present
223        let response = if let Some(qop) = &challenge.qop {
224            // With QoP: HA1:nonce:nc:cnonce:qop:HA2
225            hash_hex(&format!(
226                "{}:{}:{}:{}:{}:{}",
227                ha1, challenge.nonce, nc_str, cnonce, qop, ha2
228            ))
229        } else {
230            // Without QoP: HA1:nonce:HA2
231            hash_hex(&format!("{}:{}:{}", ha1, challenge.nonce, ha2))
232        };
233
234        DigestAuthResponse {
235            username: username.to_string(),
236            realm: challenge.realm.clone(),
237            nonce: challenge.nonce.clone(),
238            uri: uri.to_string(),
239            qop: challenge.qop.clone(),
240            nc,
241            cnonce,
242            response,
243            algorithm: challenge.algorithm.clone(),
244            opaque: challenge.opaque.clone(),
245        }
246    }
247}
248
249/// Generate a pseudo-random u64 value for use as cnonce
250fn rand_u64() -> u64 {
251    use std::time::{SystemTime, UNIX_EPOCH};
252    let dur = SystemTime::now()
253        .duration_since(UNIX_EPOCH)
254        .unwrap_or_default();
255    dur.as_nanos() as u64 ^ (dur.as_secs()).wrapping_mul(0x5851F42D4C957F2D)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    // --- DigestAuthChallenge tests ---
263
264    #[test]
265    fn test_digest_challenge_parse_basic() {
266        let header = r#"Digest realm="test realm", nonce="abc123def456""#;
267        let challenge = DigestAuthChallenge::parse(header).unwrap();
268
269        assert_eq!(challenge.realm, "test realm");
270        assert_eq!(challenge.nonce, "abc123def456");
271        assert_eq!(challenge.algorithm, "MD5"); // default
272        assert!(challenge.qop.is_none());
273        assert!(challenge.opaque.is_none());
274        assert!(!challenge.stale);
275    }
276
277    #[test]
278    fn test_digest_challenge_parse_all_fields() {
279        let header = r#"Digest realm="aria2 download", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", qop="auth", algorithm="MD5", opaque="5ccc069c403ebaf9f0171e9517bf40c9", stale=true"#;
280        let challenge = DigestAuthChallenge::parse(header).unwrap();
281
282        assert_eq!(challenge.realm, "aria2 download");
283        assert_eq!(challenge.nonce, "dcd98b7102dd2f0e8b11d0f600bfb0c093");
284        assert_eq!(challenge.qop.as_deref(), Some("auth"));
285        assert_eq!(challenge.algorithm, "MD5");
286        assert_eq!(
287            challenge.opaque.as_deref(),
288            Some("5ccc069c403ebaf9f0171e9517bf40c9")
289        );
290        assert!(challenge.stale);
291    }
292
293    #[test]
294    fn test_digest_challenge_missing_nonce_returns_error() {
295        let header = r#"Digest realm="only realm""#;
296        let result = DigestAuthChallenge::parse(header);
297        assert!(result.is_err());
298        assert!(result.unwrap_err().to_string().contains("nonce"));
299    }
300
301    #[test]
302    fn test_digest_challenge_not_digest_prefix_returns_error() {
303        let header = r#"Basic realm="test""#;
304        let result = DigestAuthChallenge::parse(header);
305        assert!(result.is_err());
306        assert!(result.unwrap_err().to_string().contains("Digest "));
307    }
308
309    #[test]
310    fn test_digest_challenge_case_insensitive_keys() {
311        // Keys should be case-insensitive per RFC
312        let header = r#"Digest REALM="TestRealm", NONCE="myNonce", ALGORITHM="SHA-256""#;
313        let challenge = DigestAuthChallenge::parse(header).unwrap();
314        assert_eq!(challenge.realm, "TestRealm");
315        assert_eq!(challenge.nonce, "myNonce");
316        assert_eq!(challenge.algorithm, "SHA-256");
317    }
318
319    // --- DigestAuthResponse tests ---
320
321    #[test]
322    fn test_digest_response_compute_and_format() {
323        let challenge = DigestAuthChallenge::parse(
324            r#"Digest realm="test@host.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", qop="auth", opaque="someopaque""#
325        ).unwrap();
326
327        let response = DigestAuthResponse::compute(
328            "Mufasa",
329            "Circle Of Life",
330            "GET",
331            "/dir/index.html",
332            &challenge,
333            1,
334        );
335
336        assert_eq!(response.username, "Mufasa");
337        assert_eq!(response.realm, "test@host.com");
338        assert_eq!(response.uri, "/dir/index.html");
339        assert_eq!(response.nc, 1);
340        assert_eq!(response.qop.as_deref(), Some("auth"));
341        assert_eq!(response.algorithm, "MD5");
342        assert_eq!(response.opaque.as_deref(), Some("someopaque"));
343
344        // Verify the header value contains expected components
345        let header_val = response.to_header_value();
346        assert!(header_val.starts_with("Digest "));
347        assert!(header_val.contains(r#"username="Mufasa""#));
348        assert!(header_val.contains(r#"realm="test@host.com""#));
349        assert!(header_val.contains("nc=00000001"));
350        assert!(header_val.contains(r#"response=""#));
351        assert!(!response.cnonce.is_empty());
352    }
353
354    #[test]
355    fn test_digest_response_without_qop() {
356        let challenge =
357            DigestAuthChallenge::parse(r#"Digest realm="simple", nonce="simpleNonce123""#).unwrap();
358
359        let response =
360            DigestAuthResponse::compute("user", "pass", "POST", "/api/data", &challenge, 1);
361
362        assert!(response.qop.is_none());
363        let header_val = response.to_header_value();
364        assert!(header_val.contains("qop=")); // empty qop in output
365        assert!(header_val.contains(r#"algorithm="MD5""#));
366    }
367
368    #[test]
369    fn test_digest_full_flow_roundtrip() {
370        // Simulate full flow: receive challenge -> build response -> verify format
371
372        // Step 1: Server sends WWW-Authenticate header
373        let www_authenticate = r#"Digest realm="WallyWorld", nonce="OA=MPOPQKX/RI=SOXPVDFKB,URI=/download/file.torrent", qop="auth", algorithm="MD5", opaque="FQwERTYuiop123""#;
374
375        // Step 2: Client parses the challenge
376        let challenge = DigestAuthChallenge::parse(www_authenticate).unwrap();
377        assert_eq!(challenge.realm, "WallyWorld");
378
379        // Step 3: Client computes response
380        let auth_response = DigestAuthResponse::compute(
381            "admin",
382            "secret123",
383            "GET",
384            "/download/file.torrent",
385            &challenge,
386            1,
387        );
388
389        // Step 4: Build Authorization header value
390        let authorization = auth_response.to_header_value();
391
392        // Verify roundtrip integrity
393        assert!(authorization.starts_with("Digest "));
394        assert!(authorization.contains(r#"username="admin""#));
395        assert!(authorization.contains(r#"realm="WallyWorld""#));
396        assert!(authorization.contains(&format!("nonce=\"{}\"", challenge.nonce)));
397        assert!(authorization.contains(r#"uri="/download/file.torrent""#));
398        assert!(authorization.contains("nc=00000001"));
399        assert!(authorization.contains(r#"qop="auth""#));
400        assert!(authorization.contains(r#"opaque="FQwERTYuiop123""#));
401
402        // Verify we can re-parse the generated header structure (sanity check)
403        assert!(authorization.len() > 50); // Should be substantial
404        assert!(!authorization.contains("\n")); // Single line header
405    }
406
407    #[test]
408    fn test_digest_challenge_display_format() {
409        let challenge = DigestAuthChallenge {
410            realm: "MyRealm".into(),
411            nonce: "abc123".into(),
412            qop: Some("auth".into()),
413            algorithm: "MD5".into(),
414            opaque: Some("opaqueVal".into()),
415            stale: false,
416        };
417
418        let display = format!("{}", challenge);
419        assert!(display.contains("Digest "));
420        assert!(display.contains(r#"realm="MyRealm""#));
421        assert!(display.contains(r#"nonce="abc123""#));
422        assert!(display.contains(r#"qop="auth""#));
423        assert!(display.contains(r#"opaque="opaqueVal""#));
424    }
425
426    #[test]
427    fn test_digest_nonce_count_increments_correctly() {
428        let challenge =
429            DigestAuthChallenge::parse(r#"Digest realm="test", nonce="nonce123", qop="auth""#)
430                .unwrap();
431
432        let resp1 = DigestAuthResponse::compute("u", "p", "GET", "/", &challenge, 1);
433        let resp2 = DigestAuthResponse::compute("u", "p", "GET", "/", &challenge, 2);
434
435        let h1 = resp1.to_header_value();
436        let h2 = resp2.to_header_value();
437
438        assert!(h1.contains("nc=00000001"));
439        assert!(h2.contains("nc=00000002"));
440
441        // Each request should have different cnonce and response
442        assert_ne!(resp1.cnonce, resp2.cnonce);
443        assert_ne!(resp1.response, resp2.response);
444    }
445}