1use std::collections::HashMap;
7use std::fmt;
8
9use crate::error::Aria2Error;
10
11#[derive(Debug, Clone)]
16pub struct DigestAuthChallenge {
17 pub realm: String,
19 pub nonce: String,
21 pub qop: Option<String>,
23 pub algorithm: String,
25 pub opaque: Option<String>,
27 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 pub fn parse(header_value: &str) -> Result<Self, Aria2Error> {
75 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 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 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 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#[derive(Debug, Clone)]
121pub struct DigestAuthResponse {
122 pub username: String,
124 pub realm: String,
126 pub nonce: String,
128 pub uri: String,
130 pub qop: Option<String>,
132 pub nc: u32,
134 pub cnonce: String,
136 pub response: String,
138 pub algorithm: String,
140 pub opaque: Option<String>,
142}
143
144impl DigestAuthResponse {
145 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 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 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 let ha1 = hash_hex(&format!("{}:{}:{}", username, challenge.realm, password));
214
215 let ha2 = hash_hex(&format!("{}:{}", method, uri));
217
218 let cnonce = format!("{:016x}", rand_u64());
220 let nc_str = format!("{:08x}", nc);
221
222 let response = if let Some(qop) = &challenge.qop {
224 hash_hex(&format!(
226 "{}:{}:{}:{}:{}:{}",
227 ha1, challenge.nonce, nc_str, cnonce, qop, ha2
228 ))
229 } else {
230 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
249fn 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 #[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"); 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 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 #[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 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=")); assert!(header_val.contains(r#"algorithm="MD5""#));
366 }
367
368 #[test]
369 fn test_digest_full_flow_roundtrip() {
370 let www_authenticate = r#"Digest realm="WallyWorld", nonce="OA=MPOPQKX/RI=SOXPVDFKB,URI=/download/file.torrent", qop="auth", algorithm="MD5", opaque="FQwERTYuiop123""#;
374
375 let challenge = DigestAuthChallenge::parse(www_authenticate).unwrap();
377 assert_eq!(challenge.realm, "WallyWorld");
378
379 let auth_response = DigestAuthResponse::compute(
381 "admin",
382 "secret123",
383 "GET",
384 "/download/file.torrent",
385 &challenge,
386 1,
387 );
388
389 let authorization = auth_response.to_header_value();
391
392 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 assert!(authorization.len() > 50); assert!(!authorization.contains("\n")); }
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 assert_ne!(resp1.cnonce, resp2.cnonce);
443 assert_ne!(resp1.response, resp2.response);
444 }
445}