1use std::fmt;
10use std::sync::atomic::{AtomicU32, Ordering};
11
12use crate::error::{Aria2Error, Result};
13
14pub struct Secret<T: zeroize::Zeroize>(T);
19
20impl<T: zeroize::Zeroize> Secret<T> {
21 pub fn new(value: T) -> Self {
23 Secret(value)
24 }
25
26 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#[derive(Clone, Copy, Debug, PartialEq, Default)]
56pub enum DigestAlgorithm {
57 #[default]
59 Md5,
60 Sha256,
62 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#[derive(Debug, Clone, PartialEq)]
78pub enum AuthScheme {
79 Basic,
81 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#[derive(Debug, Clone, PartialEq)]
99pub struct AuthChallenge {
100 pub scheme: AuthScheme,
102 pub realm: String,
104 pub nonce: Option<String>,
106 pub opaque: Option<String>,
108 pub qop: Option<String>,
110 pub stale: bool,
112}
113
114#[async_trait::async_trait]
119pub trait AuthProvider: Send + Sync {
120 fn scheme(&self) -> AuthScheme;
122
123 fn build_authorization_header(&self, challenge: &AuthChallenge) -> Result<String>;
131}
132
133pub struct DigestAuthProvider {
142 username: Secret<String>,
143 password: Secret<String>,
144 nc_count: AtomicU32,
145 algorithm: DigestAlgorithm,
146}
147
148impl DigestAuthProvider {
149 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 pub fn algorithm(&self) -> DigestAlgorithm {
166 self.algorithm
167 }
168
169 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 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 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 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 let ha1 = self.compute_ha1(&challenge.realm);
281
282 let cnonce = format!("{:016x}", rand::random::<u64>());
284
285 let nc_raw = self.nc_count.fetch_add(1, Ordering::SeqCst);
287 let nc = format!("{:08x}", nc_raw);
288
289 let qop = challenge.qop.as_deref();
291
292 let ha2 = self.compute_ha2(method, uri, qop, entity_body);
294
295 let response = self.compute_response(&ha1, nonce, &nc, &cnonce, qop, &ha2);
297
298 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 fn hash(&self, input: &str) -> String {
323 self.hash_from_bytes(input.as_bytes())
324 }
325
326 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 fn hash_kd(&self, secret: &str, data: &str) -> String {
352 let kd_input = format!("{}:{}", secret, data);
353 self.hash(&kd_input)
354 }
355
356 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 Err(Aria2Error::Parse(
374 "Digest auth requires method and URI. Use build_authorization_header_with_method()"
375 .to_string(),
376 ))
377 }
378}
379
380pub 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 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 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, };
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 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 assert_eq!(result, "Basic dGVzdHVzZXI6dGVzdHBhc3M=");
487 }
488
489 #[test]
490 fn test_basic_auth_https_only() {
491 let provider = BasicAuthProvider::new(
493 "user".to_string(),
494 "pass".to_string(),
495 true, );
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 assert_eq!(ha1.len(), 32);
524 assert!(ha1.chars().all(|c| c.is_ascii_hexdigit()));
526 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 assert_eq!(ha2.len(), 32);
542 assert!(ha2.chars().all(|c| c.is_ascii_hexdigit()));
544 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 let response = provider.compute_response(
562 &ha1,
563 "dcd98b7102dd2f0e8b11d0f600bfb0c093",
564 "",
565 "",
566 None,
567 &ha2,
568 );
569 assert_eq!(response.len(), 32);
571 assert!(response.chars().all(|c| c.is_ascii_hexdigit()));
572
573 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 assert_eq!(ha1.len(), 64);
589
590 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); }
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 provider.reset_nonce_counter();
611
612 let nc1 = provider.nc_count.load(Ordering::SeqCst);
614 assert_eq!(nc1, 1);
615
616 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); last_nc = old_val + 1;
622 }
623
624 let nc_final = provider.nc_count.load(Ordering::SeqCst);
626 assert_eq!(nc_final, 6);
627
628 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 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 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 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 let header2 = r#"Digest realm="test", nonce="abc""#;
676 let challenge2 = parse_www_authenticate(header2).unwrap();
677 assert!(!challenge2.stale);
678
679 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.store("example.com", "alice", b"secret123");
691
692 let creds = store.get("example.com").unwrap();
694 assert_eq!(creds.username, "alice");
695 assert_eq!(creds.password, b"secret123");
696
697 let removed = store.remove("example.com");
699 assert!(removed.is_some());
700
701 assert!(store.get("example.com").is_none());
703 }
704
705 #[test]
706 fn test_password_zeroize_on_drop() {
707 let store = CredentialStore::new();
710 store.store("test.com", "user", b"sensitive-password");
711
712 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 let debug_output = format!("{:?}", secret);
723 assert_eq!(debug_output, "Secret(***)");
724 assert!(!debug_output.contains("password"));
725
726 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 let ha2_auth_int =
742 provider.compute_ha2("POST", "/api/data", Some("auth-int"), Some(entity_body));
743
744 let ha2_auth = provider.compute_ha2("POST", "/api/data", Some("auth"), None);
746
747 assert_ne!(ha2_auth_int, ha2_auth);
749
750 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, 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 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}