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