1use crate::error::{AuthError, Result};
16use crate::providers::DigestSecret;
17use hex;
18use rand::Rng;
19use sha2::{Digest as Sha2Digest, Sha256, Sha512_256};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum DigestAlgorithm {
28 MD5,
29 MD5Sess,
30 SHA256,
31 SHA256Sess,
32 SHA512256,
33 SHA512256Sess,
34}
35
36impl DigestAlgorithm {
37 pub fn as_str(&self) -> &'static str {
38 match self {
39 Self::MD5 => "MD5",
40 Self::MD5Sess => "MD5-sess",
41 Self::SHA256 => "SHA-256",
42 Self::SHA256Sess => "SHA-256-sess",
43 Self::SHA512256 => "SHA-512-256",
44 Self::SHA512256Sess => "SHA-512-256-sess",
45 }
46 }
47
48 pub fn is_sess(&self) -> bool {
51 matches!(self, Self::MD5Sess | Self::SHA256Sess | Self::SHA512256Sess)
52 }
53
54 fn hash(&self, input: &[u8]) -> String {
58 match self {
59 Self::MD5 | Self::MD5Sess => hex::encode(md5::compute(input).0),
60 Self::SHA256 | Self::SHA256Sess => hex::encode(Sha256::digest(input)),
61 Self::SHA512256 | Self::SHA512256Sess => hex::encode(Sha512_256::digest(input)),
62 }
63 }
64}
65
66impl std::fmt::Display for DigestAlgorithm {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 write!(f, "{}", self.as_str())
69 }
70}
71
72fn parse_algorithm(value: &str) -> Result<DigestAlgorithm> {
73 let value = value.trim();
74 if value.eq_ignore_ascii_case("MD5") {
75 Ok(DigestAlgorithm::MD5)
76 } else if value.eq_ignore_ascii_case("MD5-sess") {
77 Ok(DigestAlgorithm::MD5Sess)
78 } else if value.eq_ignore_ascii_case("SHA-256") {
79 Ok(DigestAlgorithm::SHA256)
80 } else if value.eq_ignore_ascii_case("SHA-256-sess") {
81 Ok(DigestAlgorithm::SHA256Sess)
82 } else if value.eq_ignore_ascii_case("SHA-512-256") {
83 Ok(DigestAlgorithm::SHA512256)
84 } else if value.eq_ignore_ascii_case("SHA-512-256-sess") {
85 Ok(DigestAlgorithm::SHA512256Sess)
86 } else {
87 Err(AuthError::InvalidChallenge(format!(
88 "Unsupported digest algorithm '{}'",
89 value
90 )))
91 }
92}
93
94fn parse_bool(value: &str) -> bool {
95 matches!(value.trim(), "true" | "TRUE" | "True" | "1")
96}
97
98fn split_auth_params(params: &str) -> Vec<&str> {
99 let mut parts = Vec::new();
100 let mut start = 0;
101 let mut in_quotes = false;
102 let mut escaped = false;
103
104 for (idx, ch) in params.char_indices() {
105 if escaped {
106 escaped = false;
107 continue;
108 }
109
110 match ch {
111 '\\' if in_quotes => escaped = true,
112 '"' => in_quotes = !in_quotes,
113 ',' if !in_quotes => {
114 parts.push(params[start..idx].trim());
115 start = idx + ch.len_utf8();
116 }
117 _ => {}
118 }
119 }
120
121 parts.push(params[start..].trim());
122 parts.into_iter().filter(|part| !part.is_empty()).collect()
123}
124
125fn unquote_auth_value(value: &str) -> String {
126 let value = value.trim();
127 let Some(inner) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) else {
128 return value.to_string();
129 };
130
131 let mut unescaped = String::with_capacity(inner.len());
132 let mut chars = inner.chars();
133 while let Some(ch) = chars.next() {
134 if ch == '\\' {
135 if let Some(next) = chars.next() {
136 unescaped.push(next);
137 }
138 } else {
139 unescaped.push(ch);
140 }
141 }
142 unescaped
143}
144
145fn parse_qop_options(value: &str) -> Vec<String> {
146 value
147 .split(',')
148 .map(|s| s.trim().to_ascii_lowercase())
149 .filter(|s| !s.is_empty())
150 .collect()
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct DigestChallenge {
156 pub realm: String,
157 pub nonce: String,
158 pub algorithm: DigestAlgorithm,
159 pub qop: Option<Vec<String>>, pub opaque: Option<String>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct DigestChallengeDetails {
169 pub challenge: DigestChallenge,
170 pub stale: bool,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct DigestResponse {
176 pub username: String,
177 pub realm: String,
178 pub nonce: String,
179 pub uri: String,
180 pub response: String,
181 pub algorithm: DigestAlgorithm,
182 pub cnonce: Option<String>,
183 pub qop: Option<String>,
184 pub nc: Option<String>,
185 pub opaque: Option<String>,
186}
187
188#[derive(Debug, Clone)]
194pub struct DigestComputed {
195 pub response: String,
196 pub cnonce: Option<String>,
197 pub nc: Option<String>,
200 pub qop: Option<String>,
203}
204
205#[derive(Debug, Clone)]
207pub struct DigestAuthenticator {
208 realm: String,
209 algorithm: DigestAlgorithm,
210}
211
212impl DigestAuthenticator {
213 pub fn new(realm: impl Into<String>) -> Self {
214 Self {
215 realm: realm.into(),
216 algorithm: DigestAlgorithm::MD5,
217 }
218 }
219
220 pub fn with_algorithm(mut self, algorithm: DigestAlgorithm) -> Self {
222 self.algorithm = algorithm;
223 self
224 }
225
226 pub fn generate_challenge(&self) -> DigestChallenge {
227 DigestChallenge {
228 realm: self.realm.clone(),
229 nonce: Self::generate_nonce(),
230 algorithm: self.algorithm,
231 qop: Some(vec!["auth".to_string()]),
232 opaque: Some(Self::generate_opaque()),
233 }
234 }
235
236 pub fn format_www_authenticate(&self, challenge: &DigestChallenge) -> String {
237 self.format_www_authenticate_with_stale(challenge, false)
238 }
239
240 pub fn format_www_authenticate_with_stale(
242 &self,
243 challenge: &DigestChallenge,
244 stale: bool,
245 ) -> String {
246 let mut parts = vec![
247 format!(r#"realm="{}""#, challenge.realm),
248 format!(r#"nonce="{}""#, challenge.nonce),
249 format!(r#"algorithm={}"#, challenge.algorithm),
250 ];
251
252 if let Some(ref qop) = challenge.qop {
253 parts.push(format!(r#"qop="{}""#, qop.join(",")));
254 }
255
256 if let Some(ref opaque) = challenge.opaque {
257 parts.push(format!(r#"opaque="{}""#, opaque));
258 }
259
260 if stale {
261 parts.push("stale=true".to_string());
262 }
263
264 format!("Digest {}", parts.join(", "))
265 }
266
267 pub fn validate_response(
274 &self,
275 response: &DigestResponse,
276 method: &str,
277 password: &str,
278 ) -> Result<bool> {
279 self.validate_response_with_body(response, method, password, None)
280 }
281
282 pub fn validate_response_with_body(
285 &self,
286 response: &DigestResponse,
287 method: &str,
288 password: &str,
289 body: Option<&[u8]>,
290 ) -> Result<bool> {
291 self.validate_response_with_secret_and_body(
292 response,
293 method,
294 &DigestSecret::PlaintextPassword(password.to_string()),
295 body,
296 )
297 }
298
299 pub fn validate_response_with_secret(
306 &self,
307 response: &DigestResponse,
308 method: &str,
309 secret: &DigestSecret,
310 ) -> Result<bool> {
311 self.validate_response_with_secret_and_body(response, method, secret, None)
312 }
313
314 pub fn validate_response_with_secret_and_body(
317 &self,
318 response: &DigestResponse,
319 method: &str,
320 secret: &DigestSecret,
321 body: Option<&[u8]>,
322 ) -> Result<bool> {
323 let algorithm = response.algorithm;
324 let basic_ha1 = match secret {
326 DigestSecret::PlaintextPassword(password) => algorithm
327 .hash(format!("{}:{}:{}", response.username, response.realm, password).as_bytes()),
328 DigestSecret::Ha1(ha1) => ha1.clone(),
329 };
330 let ha1 = if algorithm.is_sess() {
331 let cnonce = response.cnonce.as_deref().ok_or_else(|| {
332 AuthError::InvalidResponse("Missing cnonce for -sess algorithm".into())
333 })?;
334 algorithm.hash(format!("{}:{}:{}", basic_ha1, response.nonce, cnonce).as_bytes())
335 } else {
336 basic_ha1
337 };
338
339 let ha2 = match response.qop.as_deref() {
341 Some("auth-int") => {
342 let body_bytes = body.unwrap_or(&[]);
343 let body_hash = algorithm.hash(body_bytes);
344 algorithm.hash(format!("{}:{}:{}", method, response.uri, body_hash).as_bytes())
345 }
346 Some("auth") | None => {
347 algorithm.hash(format!("{}:{}", method, response.uri).as_bytes())
348 }
349 Some(other) => {
350 return Err(AuthError::InvalidResponse(format!(
351 "Unsupported digest qop '{}'",
352 other
353 )))
354 }
355 };
356
357 let expected = if let Some(qop) = response.qop.as_ref() {
359 let nc = response
360 .nc
361 .as_ref()
362 .ok_or_else(|| AuthError::InvalidResponse("Missing nc for qop".into()))?;
363 let cnonce = response
364 .cnonce
365 .as_ref()
366 .ok_or_else(|| AuthError::InvalidResponse("Missing cnonce for qop".into()))?;
367 algorithm.hash(
368 format!(
369 "{}:{}:{}:{}:{}:{}",
370 ha1, response.nonce, nc, cnonce, qop, ha2
371 )
372 .as_bytes(),
373 )
374 } else {
375 algorithm.hash(format!("{}:{}:{}", ha1, response.nonce, ha2).as_bytes())
376 };
377
378 Ok(expected == response.response)
379 }
380
381 pub fn parse_challenge(header: &str) -> Result<DigestChallenge> {
383 Ok(Self::parse_challenge_details(header)?.challenge)
384 }
385
386 pub fn parse_challenge_details(header: &str) -> Result<DigestChallengeDetails> {
389 let header = header.trim();
390
391 let params_str = if header.starts_with("Digest ") || header.starts_with("digest ") {
392 &header[7..]
393 } else {
394 return Err(AuthError::InvalidChallenge(
395 "Missing 'Digest' prefix".into(),
396 ));
397 };
398
399 let mut realm = None;
400 let mut nonce = None;
401 let mut algorithm = DigestAlgorithm::MD5;
402 let mut qop = None;
403 let mut opaque = None;
404 let mut stale = false;
405
406 for param in split_auth_params(params_str) {
407 let param = param.trim();
408 if let Some((key, value)) = param.split_once('=') {
409 let key = key.trim().to_ascii_lowercase();
410 let value = unquote_auth_value(value);
411
412 match key.as_str() {
413 "realm" => realm = Some(value),
414 "nonce" => nonce = Some(value),
415 "algorithm" => algorithm = parse_algorithm(&value)?,
416 "qop" => {
417 qop = Some(parse_qop_options(&value));
418 }
419 "opaque" => opaque = Some(value),
420 "stale" => stale = parse_bool(&value),
421 _ => {}
422 }
423 }
424 }
425
426 Ok(DigestChallengeDetails {
427 challenge: DigestChallenge {
428 realm: realm.ok_or_else(|| AuthError::InvalidChallenge("Missing realm".into()))?,
429 nonce: nonce.ok_or_else(|| AuthError::InvalidChallenge("Missing nonce".into()))?,
430 algorithm,
431 qop,
432 opaque,
433 },
434 stale,
435 })
436 }
437
438 pub fn parse_authorization(header: &str) -> Result<DigestResponse> {
440 let header = header.trim();
441
442 let params_str = if header.starts_with("Digest ") || header.starts_with("digest ") {
443 &header[7..]
444 } else {
445 return Err(AuthError::InvalidResponse("Missing 'Digest' prefix".into()));
446 };
447
448 let mut username = None;
449 let mut realm = None;
450 let mut nonce = None;
451 let mut uri = None;
452 let mut response = None;
453 let mut algorithm = DigestAlgorithm::MD5;
454 let mut cnonce = None;
455 let mut qop = None;
456 let mut nc = None;
457 let mut opaque = None;
458
459 for param in split_auth_params(params_str) {
460 let param = param.trim();
461 if let Some((key, value)) = param.split_once('=') {
462 let key = key.trim().to_ascii_lowercase();
463 let value = unquote_auth_value(value);
464
465 match key.as_str() {
466 "username" => username = Some(value),
467 "realm" => realm = Some(value),
468 "nonce" => nonce = Some(value),
469 "uri" => uri = Some(value),
470 "response" => response = Some(value),
471 "algorithm" => {
472 algorithm = parse_algorithm(&value)
473 .map_err(|e| AuthError::InvalidResponse(e.to_string()))?
474 }
475 "cnonce" => cnonce = Some(value),
476 "qop" => qop = Some(value.to_ascii_lowercase()),
477 "nc" => nc = Some(value),
478 "opaque" => opaque = Some(value),
479 _ => {}
480 }
481 }
482 }
483
484 Ok(DigestResponse {
485 username: username
486 .ok_or_else(|| AuthError::InvalidResponse("Missing username".into()))?,
487 realm: realm.ok_or_else(|| AuthError::InvalidResponse("Missing realm".into()))?,
488 nonce: nonce.ok_or_else(|| AuthError::InvalidResponse("Missing nonce".into()))?,
489 uri: uri.ok_or_else(|| AuthError::InvalidResponse("Missing uri".into()))?,
490 response: response
491 .ok_or_else(|| AuthError::InvalidResponse("Missing response".into()))?,
492 algorithm,
493 cnonce,
494 qop,
495 nc,
496 opaque,
497 })
498 }
499
500 fn generate_nonce() -> String {
501 let mut rng = rand::thread_rng();
502 let random_bytes: [u8; 16] = rng.gen();
503 let timestamp = SystemTime::now()
504 .duration_since(UNIX_EPOCH)
505 .unwrap()
506 .as_secs();
507 let data = format!("{}{}", timestamp, hex::encode(random_bytes));
508 hex::encode(md5::compute(data.as_bytes()).0)
509 }
510
511 fn generate_opaque() -> String {
512 let mut rng = rand::thread_rng();
513 let random_bytes: [u8; 16] = rng.gen();
514 hex::encode(random_bytes)
515 }
516}
517
518pub struct DigestClient;
520
521impl DigestClient {
522 pub fn compute_response(
527 username: &str,
528 password: &str,
529 challenge: &DigestChallenge,
530 method: &str,
531 uri: &str,
532 ) -> Result<(String, Option<String>)> {
533 let computed =
534 Self::compute_response_with_state(username, password, challenge, method, uri, 1, None)?;
535 Ok((computed.response, computed.cnonce))
536 }
537
538 pub fn compute_response_with_state(
551 username: &str,
552 password: &str,
553 challenge: &DigestChallenge,
554 method: &str,
555 uri: &str,
556 nc: u32,
557 body: Option<&[u8]>,
558 ) -> Result<DigestComputed> {
559 let algorithm = challenge.algorithm;
560 let cnonce_value = Self::generate_cnonce();
561 let nc_str = format!("{:08x}", nc);
562
563 let basic_ha1 =
566 algorithm.hash(format!("{}:{}:{}", username, challenge.realm, password).as_bytes());
567 let ha1 = if algorithm.is_sess() {
568 algorithm.hash(format!("{}:{}:{}", basic_ha1, challenge.nonce, cnonce_value).as_bytes())
569 } else {
570 basic_ha1
571 };
572
573 let chosen_qop = match challenge.qop.as_ref() {
577 Some(opts) if body.is_some() && opts.iter().any(|q| q == "auth-int") => {
578 Some("auth-int".to_string())
579 }
580 Some(opts) if opts.iter().any(|q| q == "auth") => Some("auth".to_string()),
581 Some(_) => {
582 return Err(AuthError::InvalidChallenge(
583 "Digest challenge did not offer supported qop".into(),
584 ))
585 }
586 None => None,
587 };
588
589 let ha2 = match chosen_qop.as_deref() {
591 Some("auth-int") => {
592 let body_bytes = body.unwrap_or(&[]);
593 let body_hash = algorithm.hash(body_bytes);
594 algorithm.hash(format!("{}:{}:{}", method, uri, body_hash).as_bytes())
595 }
596 _ => algorithm.hash(format!("{}:{}", method, uri).as_bytes()),
597 };
598
599 let response = if let Some(ref qop) = chosen_qop {
601 algorithm.hash(
602 format!(
603 "{}:{}:{}:{}:{}:{}",
604 ha1, challenge.nonce, nc_str, cnonce_value, qop, ha2
605 )
606 .as_bytes(),
607 )
608 } else {
609 algorithm.hash(format!("{}:{}:{}", ha1, challenge.nonce, ha2).as_bytes())
610 };
611
612 let (cnonce_out, nc_out) = if chosen_qop.is_some() {
613 (Some(cnonce_value), Some(nc_str))
614 } else {
615 (None, None)
616 };
617
618 Ok(DigestComputed {
619 response,
620 cnonce: cnonce_out,
621 nc: nc_out,
622 qop: chosen_qop,
623 })
624 }
625
626 pub fn format_authorization(
631 username: &str,
632 challenge: &DigestChallenge,
633 uri: &str,
634 response: &str,
635 cnonce: Option<&str>,
636 ) -> String {
637 let mut parts = vec![
638 format!(r#"username="{}""#, username),
639 format!(r#"realm="{}""#, challenge.realm),
640 format!(r#"nonce="{}""#, challenge.nonce),
641 format!(r#"uri="{}""#, uri),
642 format!(r#"response="{}""#, response),
643 format!(r#"algorithm={}"#, challenge.algorithm),
644 ];
645
646 if let Some(ref qop_options) = challenge.qop {
647 if qop_options.iter().any(|q| q == "auth") {
648 parts.push("qop=auth".to_string());
649 parts.push("nc=00000001".to_string());
650 let cn_owned;
651 let cn = match cnonce {
652 Some(c) => c,
653 None => {
654 cn_owned = Self::generate_cnonce();
655 cn_owned.as_str()
656 }
657 };
658 parts.push(format!(r#"cnonce="{}""#, cn));
659 }
660 }
661
662 if let Some(ref opaque) = challenge.opaque {
663 parts.push(format!(r#"opaque="{}""#, opaque));
664 }
665
666 format!("Digest {}", parts.join(", "))
667 }
668
669 pub fn format_authorization_with_state(
674 username: &str,
675 challenge: &DigestChallenge,
676 uri: &str,
677 computed: &DigestComputed,
678 ) -> String {
679 let mut parts = vec![
680 format!(r#"username="{}""#, username),
681 format!(r#"realm="{}""#, challenge.realm),
682 format!(r#"nonce="{}""#, challenge.nonce),
683 format!(r#"uri="{}""#, uri),
684 format!(r#"response="{}""#, computed.response),
685 format!(r#"algorithm={}"#, challenge.algorithm),
686 ];
687
688 if let (Some(qop), Some(nc), Some(cnonce)) = (
689 computed.qop.as_ref(),
690 computed.nc.as_ref(),
691 computed.cnonce.as_ref(),
692 ) {
693 parts.push(format!("qop={}", qop));
694 parts.push(format!("nc={}", nc));
695 parts.push(format!(r#"cnonce="{}""#, cnonce));
696 }
697
698 if let Some(ref opaque) = challenge.opaque {
699 parts.push(format!(r#"opaque="{}""#, opaque));
700 }
701
702 format!("Digest {}", parts.join(", "))
703 }
704
705 fn generate_cnonce() -> String {
706 let mut rng = rand::thread_rng();
707 let random_bytes: [u8; 8] = rng.gen();
708 hex::encode(random_bytes)
709 }
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715
716 #[test]
717 fn algorithm_parser_recognises_known_tokens() {
718 assert_eq!(parse_algorithm("MD5").unwrap(), DigestAlgorithm::MD5);
719 assert_eq!(parse_algorithm("md5").unwrap(), DigestAlgorithm::MD5);
720 assert_eq!(
721 parse_algorithm("MD5-sess").unwrap(),
722 DigestAlgorithm::MD5Sess
723 );
724 assert_eq!(
725 parse_algorithm("md5-sess").unwrap(),
726 DigestAlgorithm::MD5Sess
727 );
728 assert_eq!(parse_algorithm("SHA-256").unwrap(), DigestAlgorithm::SHA256);
729 assert_eq!(
730 parse_algorithm("SHA-256-sess").unwrap(),
731 DigestAlgorithm::SHA256Sess
732 );
733 assert_eq!(
734 parse_algorithm("SHA-512-256").unwrap(),
735 DigestAlgorithm::SHA512256
736 );
737 assert_eq!(
738 parse_algorithm("SHA-512-256-sess").unwrap(),
739 DigestAlgorithm::SHA512256Sess
740 );
741 }
742
743 #[test]
744 fn algorithm_parser_rejects_unknown_tokens() {
745 assert!(parse_algorithm("garbage").is_err());
746 assert!(DigestAuthenticator::parse_challenge(
747 r#"Digest realm="example.com", nonce="fixed", algorithm=SHA-999"#
748 )
749 .is_err());
750 assert!(
751 DigestAuthenticator::parse_authorization(
752 r#"Digest username="alice", realm="example.com", nonce="fixed", uri="sip:example.com", response="abcd", algorithm=SHA-999"#
753 )
754 .is_err()
755 );
756 }
757
758 #[test]
759 fn omitted_algorithm_defaults_to_md5() {
760 let challenge =
761 DigestAuthenticator::parse_challenge(r#"Digest realm="example.com", nonce="fixed""#)
762 .unwrap();
763 assert_eq!(challenge.algorithm, DigestAlgorithm::MD5);
764 }
765
766 #[test]
767 fn challenge_parser_preserves_quoted_qop_list() {
768 let challenge = DigestAuthenticator::parse_challenge(
769 r#"Digest realm="example.com", nonce="fixed", algorithm=md5, qop="auth,auth-int""#,
770 )
771 .unwrap();
772
773 assert_eq!(challenge.algorithm, DigestAlgorithm::MD5);
774 assert_eq!(
775 challenge.qop,
776 Some(vec!["auth".to_string(), "auth-int".to_string()])
777 );
778 }
779
780 #[test]
781 fn challenge_details_parser_exposes_stale_flag() {
782 let details = DigestAuthenticator::parse_challenge_details(
783 r#"Digest realm="example.com", nonce="fixed", algorithm=MD5, stale=true"#,
784 )
785 .unwrap();
786
787 assert!(details.stale);
788 assert_eq!(details.challenge.nonce, "fixed");
789
790 let auth = DigestAuthenticator::new("example.com");
791 let header = auth.format_www_authenticate_with_stale(&details.challenge, true);
792 assert!(
793 header.contains("stale=true"),
794 "formatted challenge was: {header}"
795 );
796 }
797
798 #[test]
799 fn authorization_parser_ignores_commas_inside_quotes() {
800 let response = DigestAuthenticator::parse_authorization(
801 r#"Digest username="alice,ua", realm="example.com", nonce="fixed", uri="sip:example.com", response="abcd", algorithm=MD5, qop=auth, nc=00000001, cnonce="cn,once", opaque="op,aque""#,
802 )
803 .unwrap();
804
805 assert_eq!(response.username, "alice,ua");
806 assert_eq!(response.cnonce.as_deref(), Some("cn,once"));
807 assert_eq!(response.opaque.as_deref(), Some("op,aque"));
808 assert_eq!(response.qop.as_deref(), Some("auth"));
809 }
810
811 #[test]
812 fn algorithm_is_sess_only_for_sess_variants() {
813 assert!(!DigestAlgorithm::MD5.is_sess());
814 assert!(DigestAlgorithm::MD5Sess.is_sess());
815 assert!(!DigestAlgorithm::SHA256.is_sess());
816 assert!(DigestAlgorithm::SHA256Sess.is_sess());
817 }
818
819 #[test]
820 fn nc_increments_across_calls_with_same_nonce() {
821 let challenge = DigestChallenge {
822 realm: "example.com".to_string(),
823 nonce: "shared-nonce".to_string(),
824 algorithm: DigestAlgorithm::MD5,
825 qop: Some(vec!["auth".to_string()]),
826 opaque: None,
827 };
828
829 let r1 = DigestClient::compute_response_with_state(
830 "alice",
831 "secret",
832 &challenge,
833 "INVITE",
834 "sip:bob@example.com",
835 1,
836 None,
837 )
838 .unwrap();
839 let r2 = DigestClient::compute_response_with_state(
840 "alice",
841 "secret",
842 &challenge,
843 "INVITE",
844 "sip:bob@example.com",
845 2,
846 None,
847 )
848 .unwrap();
849
850 assert_eq!(r1.nc.as_deref(), Some("00000001"));
851 assert_eq!(r2.nc.as_deref(), Some("00000002"));
852 assert_ne!(r1.response, r2.response);
854 }
855
856 #[test]
857 fn nc_resets_implicitly_on_new_nonce() {
858 let mk = |nonce: &str| DigestChallenge {
862 realm: "example.com".to_string(),
863 nonce: nonce.to_string(),
864 algorithm: DigestAlgorithm::MD5,
865 qop: Some(vec!["auth".to_string()]),
866 opaque: None,
867 };
868
869 let r1 = DigestClient::compute_response_with_state(
870 "alice",
871 "secret",
872 &mk("nonce-A"),
873 "REGISTER",
874 "sip:reg.example.com",
875 1,
876 None,
877 )
878 .unwrap();
879 let r2 = DigestClient::compute_response_with_state(
880 "alice",
881 "secret",
882 &mk("nonce-B"),
883 "REGISTER",
884 "sip:reg.example.com",
885 1,
886 None,
887 )
888 .unwrap();
889
890 assert_eq!(r1.nc.as_deref(), Some("00000001"));
891 assert_eq!(r2.nc.as_deref(), Some("00000001"));
892 assert_ne!(
893 r1.response, r2.response,
894 "different nonces must produce different responses"
895 );
896 }
897
898 #[test]
899 fn sha256_round_trip_with_authenticator() {
900 let auth = DigestAuthenticator::new("example.com");
901 let challenge = DigestChallenge {
902 realm: "example.com".to_string(),
903 nonce: "fixed-nonce".to_string(),
904 algorithm: DigestAlgorithm::SHA256,
905 qop: Some(vec!["auth".to_string()]),
906 opaque: None,
907 };
908
909 let computed = DigestClient::compute_response_with_state(
910 "alice",
911 "secret",
912 &challenge,
913 "INVITE",
914 "sip:bob@example.com",
915 1,
916 None,
917 )
918 .unwrap();
919
920 let response = DigestResponse {
921 username: "alice".to_string(),
922 realm: "example.com".to_string(),
923 nonce: "fixed-nonce".to_string(),
924 uri: "sip:bob@example.com".to_string(),
925 response: computed.response.clone(),
926 algorithm: DigestAlgorithm::SHA256,
927 cnonce: computed.cnonce.clone(),
928 qop: computed.qop.clone(),
929 nc: computed.nc.clone(),
930 opaque: None,
931 };
932
933 assert!(auth
934 .validate_response(&response, "INVITE", "secret")
935 .unwrap());
936 assert!(!auth
938 .validate_response(&response, "INVITE", "WRONG")
939 .unwrap());
940 }
941
942 #[test]
943 fn digest_secret_ha1_validates_without_plaintext_password() {
944 let auth = DigestAuthenticator::new("example.com");
945 let challenge = DigestChallenge {
946 realm: "example.com".to_string(),
947 nonce: "fixed-nonce".to_string(),
948 algorithm: DigestAlgorithm::SHA512256,
949 qop: Some(vec!["auth".to_string()]),
950 opaque: None,
951 };
952 let computed = DigestClient::compute_response_with_state(
953 "alice",
954 "secret",
955 &challenge,
956 "REGISTER",
957 "sip:example.com",
958 1,
959 None,
960 )
961 .unwrap();
962 let response = DigestResponse {
963 username: "alice".to_string(),
964 realm: "example.com".to_string(),
965 nonce: "fixed-nonce".to_string(),
966 uri: "sip:example.com".to_string(),
967 response: computed.response,
968 algorithm: DigestAlgorithm::SHA512256,
969 cnonce: computed.cnonce,
970 qop: computed.qop,
971 nc: computed.nc,
972 opaque: None,
973 };
974 let ha1 = DigestAlgorithm::SHA512256.hash(b"alice:example.com:secret");
975
976 assert!(auth
977 .validate_response_with_secret(&response, "REGISTER", &crate::DigestSecret::Ha1(ha1))
978 .unwrap());
979 assert!(!auth
980 .validate_response_with_secret(
981 &response,
982 "REGISTER",
983 &crate::DigestSecret::Ha1("wrong".to_string())
984 )
985 .unwrap());
986 }
987
988 #[test]
989 fn sess_algorithms_use_session_key_ha1() {
990 let mk = |alg| DigestChallenge {
993 realm: "example.com".to_string(),
994 nonce: "fixed-nonce".to_string(),
995 algorithm: alg,
996 qop: Some(vec!["auth".to_string()]),
997 opaque: None,
998 };
999
1000 let auth_plain = DigestAuthenticator::new("example.com");
1003
1004 for alg in [
1005 DigestAlgorithm::SHA256,
1006 DigestAlgorithm::SHA256Sess,
1007 DigestAlgorithm::SHA512256,
1008 DigestAlgorithm::SHA512256Sess,
1009 DigestAlgorithm::MD5,
1010 DigestAlgorithm::MD5Sess,
1011 ] {
1012 let ch = mk(alg);
1013 let computed = DigestClient::compute_response_with_state(
1014 "alice",
1015 "secret",
1016 &ch,
1017 "INVITE",
1018 "sip:bob@example.com",
1019 1,
1020 None,
1021 )
1022 .unwrap();
1023 let resp = DigestResponse {
1024 username: "alice".to_string(),
1025 realm: "example.com".to_string(),
1026 nonce: "fixed-nonce".to_string(),
1027 uri: "sip:bob@example.com".to_string(),
1028 response: computed.response,
1029 algorithm: alg,
1030 cnonce: computed.cnonce,
1031 qop: computed.qop,
1032 nc: computed.nc,
1033 opaque: None,
1034 };
1035 assert!(
1036 auth_plain
1037 .validate_response(&resp, "INVITE", "secret")
1038 .unwrap(),
1039 "algorithm {:?} failed self-validation",
1040 alg
1041 );
1042 }
1043 }
1044
1045 #[test]
1046 fn auth_int_includes_body_in_ha2() {
1047 let challenge = DigestChallenge {
1050 realm: "example.com".to_string(),
1051 nonce: "fixed-nonce".to_string(),
1052 algorithm: DigestAlgorithm::MD5,
1053 qop: Some(vec!["auth".to_string(), "auth-int".to_string()]),
1054 opaque: None,
1055 };
1056
1057 let body_a = b"v=0\r\no=alice 1 1 IN IP4 1.2.3.4\r\n";
1058 let body_b = b"v=0\r\no=alice 2 2 IN IP4 5.6.7.8\r\n";
1059
1060 let r_a = DigestClient::compute_response_with_state(
1061 "alice",
1062 "secret",
1063 &challenge,
1064 "INVITE",
1065 "sip:bob@example.com",
1066 1,
1067 Some(body_a),
1068 )
1069 .unwrap();
1070 let r_b = DigestClient::compute_response_with_state(
1071 "alice",
1072 "secret",
1073 &challenge,
1074 "INVITE",
1075 "sip:bob@example.com",
1076 1,
1077 Some(body_b),
1078 )
1079 .unwrap();
1080
1081 assert_eq!(r_a.qop.as_deref(), Some("auth-int"));
1082 assert_eq!(r_b.qop.as_deref(), Some("auth-int"));
1083 assert_ne!(
1084 r_a.response, r_b.response,
1085 "auth-int must fold the body into HA2"
1086 );
1087 }
1088
1089 #[test]
1090 fn qop_selector_prefers_auth_int_when_offered_with_body() {
1091 let challenge = DigestChallenge {
1092 realm: "example.com".to_string(),
1093 nonce: "fixed-nonce".to_string(),
1094 algorithm: DigestAlgorithm::MD5,
1095 qop: Some(vec!["auth".to_string(), "auth-int".to_string()]),
1096 opaque: None,
1097 };
1098
1099 let r = DigestClient::compute_response_with_state(
1101 "alice",
1102 "secret",
1103 &challenge,
1104 "INVITE",
1105 "sip:bob@example.com",
1106 1,
1107 Some(b"sdp"),
1108 )
1109 .unwrap();
1110 assert_eq!(r.qop.as_deref(), Some("auth-int"));
1111
1112 let r2 = DigestClient::compute_response_with_state(
1115 "alice",
1116 "secret",
1117 &challenge,
1118 "INVITE",
1119 "sip:bob@example.com",
1120 1,
1121 None,
1122 )
1123 .unwrap();
1124 assert_eq!(r2.qop.as_deref(), Some("auth"));
1125 }
1126
1127 #[test]
1128 fn qop_selector_rejects_unsupported_qop_only_challenge() {
1129 let challenge = DigestChallenge {
1130 realm: "example.com".to_string(),
1131 nonce: "fixed-nonce".to_string(),
1132 algorithm: DigestAlgorithm::MD5,
1133 qop: Some(vec!["auth-conf".to_string()]),
1134 opaque: None,
1135 };
1136
1137 let err = DigestClient::compute_response_with_state(
1138 "alice",
1139 "secret",
1140 &challenge,
1141 "INVITE",
1142 "sip:bob@example.com",
1143 1,
1144 None,
1145 )
1146 .expect_err("unsupported qop must fail");
1147
1148 assert!(
1149 err.to_string().contains("supported qop"),
1150 "unexpected error: {err}"
1151 );
1152 }
1153
1154 #[test]
1155 fn validation_rejects_qop_without_nonce_count_or_cnonce() {
1156 let auth = DigestAuthenticator::new("example.com");
1157 let response = DigestResponse {
1158 username: "alice".to_string(),
1159 realm: "example.com".to_string(),
1160 nonce: "fixed".to_string(),
1161 uri: "sip:bob@example.com".to_string(),
1162 response: "abcd".to_string(),
1163 algorithm: DigestAlgorithm::MD5,
1164 cnonce: None,
1165 qop: Some("auth".to_string()),
1166 nc: Some("00000001".to_string()),
1167 opaque: None,
1168 };
1169
1170 assert!(auth
1171 .validate_response(&response, "INVITE", "secret")
1172 .is_err());
1173 }
1174
1175 #[test]
1176 fn format_authorization_with_state_emits_nc_from_computed() {
1177 let challenge = DigestChallenge {
1178 realm: "example.com".to_string(),
1179 nonce: "fixed-nonce".to_string(),
1180 algorithm: DigestAlgorithm::MD5,
1181 qop: Some(vec!["auth".to_string()]),
1182 opaque: None,
1183 };
1184
1185 let computed = DigestClient::compute_response_with_state(
1186 "alice",
1187 "secret",
1188 &challenge,
1189 "REGISTER",
1190 "sip:reg.example.com",
1191 42,
1192 None,
1193 )
1194 .unwrap();
1195
1196 let header = DigestClient::format_authorization_with_state(
1197 "alice",
1198 &challenge,
1199 "sip:reg.example.com",
1200 &computed,
1201 );
1202 assert!(header.contains("nc=0000002a"), "header was: {}", header);
1203 assert!(header.contains("qop=auth"));
1204 assert!(header.contains(r#"cnonce=""#));
1205 }
1206
1207 #[test]
1208 fn legacy_compute_response_still_works() {
1209 let challenge = DigestChallenge {
1210 realm: "realm".to_string(),
1211 nonce: "nonce".to_string(),
1212 algorithm: DigestAlgorithm::MD5,
1213 qop: None,
1214 opaque: None,
1215 };
1216
1217 let response = DigestClient::compute_response(
1218 "user",
1219 "password",
1220 &challenge,
1221 "REGISTER",
1222 "sip:registrar.example.com",
1223 )
1224 .unwrap();
1225
1226 assert_eq!(response.0.len(), 32); assert!(response.1.is_none()); }
1229
1230 #[test]
1231 fn parse_challenge_recognises_sha256_sess() {
1232 let header = r#"Digest realm="test", nonce="abc", algorithm=SHA-256-sess, qop="auth""#;
1233 let ch = DigestAuthenticator::parse_challenge(header).unwrap();
1234 assert_eq!(ch.algorithm, DigestAlgorithm::SHA256Sess);
1235 }
1236
1237 #[test]
1238 fn test_generate_nonce() {
1239 let nonce1 = DigestAuthenticator::generate_nonce();
1240 let nonce2 = DigestAuthenticator::generate_nonce();
1241 assert_eq!(nonce1.len(), 32);
1242 assert_ne!(nonce1, nonce2);
1243 }
1244
1245 #[test]
1246 fn test_format_www_authenticate() {
1247 let auth = DigestAuthenticator::new("testrealm");
1248 let challenge = DigestChallenge {
1249 realm: "testrealm".to_string(),
1250 nonce: "nonce123".to_string(),
1251 algorithm: DigestAlgorithm::MD5,
1252 qop: Some(vec!["auth".to_string()]),
1253 opaque: Some("opaque456".to_string()),
1254 };
1255 let header = auth.format_www_authenticate(&challenge);
1256 assert!(header.contains("Digest"));
1257 assert!(header.contains(r#"realm="testrealm""#));
1258 assert!(header.contains(r#"nonce="nonce123""#));
1259 }
1260}