1use std::fmt::Write as _;
20
21use md5::Md5;
22use sha2::{Digest, Sha256};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum Algorithm {
27 #[default]
29 Md5,
30 Md5Sess,
32 Sha256,
34 Sha256Sess,
36 Sha512_256,
42 Sha512_256Sess,
44}
45
46impl Algorithm {
47 #[must_use]
49 pub fn parse(raw: &str) -> Option<Self> {
50 match raw.trim().to_ascii_uppercase().as_str() {
51 "MD5" => Some(Self::Md5),
52 "MD5-SESS" => Some(Self::Md5Sess),
53 "SHA-256" => Some(Self::Sha256),
54 "SHA-256-SESS" => Some(Self::Sha256Sess),
55 "SHA-512-256" => Some(Self::Sha512_256),
56 "SHA-512-256-SESS" => Some(Self::Sha512_256Sess),
57 _ => None,
58 }
59 }
60
61 #[must_use]
63 pub fn as_str(self) -> &'static str {
64 match self {
65 Self::Md5 => "MD5",
66 Self::Md5Sess => "MD5-sess",
67 Self::Sha256 => "SHA-256",
68 Self::Sha256Sess => "SHA-256-sess",
69 Self::Sha512_256 => "SHA-512-256",
70 Self::Sha512_256Sess => "SHA-512-256-sess",
71 }
72 }
73
74 #[must_use]
76 pub fn is_session(self) -> bool {
77 matches!(
78 self,
79 Self::Md5Sess | Self::Sha256Sess | Self::Sha512_256Sess
80 )
81 }
82
83 #[must_use]
89 pub fn strength(self) -> u8 {
90 match self {
91 Self::Md5 => 1,
92 Self::Md5Sess => 2,
93 Self::Sha256 => 3,
94 Self::Sha256Sess => 4,
95 Self::Sha512_256 => 5,
96 Self::Sha512_256Sess => 6,
97 }
98 }
99
100 fn hash(self, input: &str) -> String {
101 match self {
102 Self::Md5 | Self::Md5Sess => hex(&Md5::digest(input.as_bytes())),
103 Self::Sha256 | Self::Sha256Sess => hex(&Sha256::digest(input.as_bytes())),
104 Self::Sha512_256 | Self::Sha512_256Sess => {
105 hex(&sha2::Sha512_256::digest(input.as_bytes()))
106 }
107 }
108 }
109}
110
111fn hex(bytes: &[u8]) -> String {
112 let mut out = String::with_capacity(bytes.len() * 2);
113 for byte in bytes {
114 let _ = write!(out, "{byte:02x}");
115 }
116 out
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Challenge {
122 pub realm: String,
124 pub nonce: String,
126 pub opaque: Option<String>,
128 pub algorithm: Algorithm,
130 pub qop_auth: bool,
132 pub stale: bool,
134 pub from_proxy: bool,
136}
137
138impl Challenge {
139 #[must_use]
144 pub fn parse(value: &[u8], from_proxy: bool) -> Option<Self> {
145 let text = std::str::from_utf8(value).ok()?;
146 let rest = text.trim().strip_prefix_ignore_ascii_case("Digest")?;
147
148 let mut realm = None;
149 let mut nonce = None;
150 let mut opaque = None;
151 let mut algorithm = Algorithm::Md5;
152 let mut qop_auth = false;
153 let mut stale = false;
154
155 for (name, value) in params(rest) {
156 match name.to_ascii_lowercase().as_str() {
157 "realm" => realm = Some(value),
158 "nonce" => nonce = Some(value),
159 "opaque" => opaque = Some(value),
160 "algorithm" => algorithm = Algorithm::parse(&value)?,
161 "qop" => {
164 qop_auth = value
165 .split(',')
166 .any(|option| option.trim().eq_ignore_ascii_case("auth"));
167 if !qop_auth {
168 return None;
169 }
170 }
171 "stale" => stale = value.trim().eq_ignore_ascii_case("true"),
172 _ => {}
173 }
174 }
175
176 Some(Self {
177 realm: realm?,
178 nonce: nonce?,
179 opaque,
180 algorithm,
181 qop_auth,
182 stale,
183 from_proxy,
184 })
185 }
186
187 #[must_use]
189 pub fn response_header(&self) -> crate::HeaderName {
190 if self.from_proxy {
191 crate::HeaderName::ProxyAuthorization
192 } else {
193 crate::HeaderName::Authorization
194 }
195 }
196}
197
198trait StripPrefixIgnoreCase {
199 fn strip_prefix_ignore_ascii_case(&self, prefix: &str) -> Option<&str>;
200}
201
202impl StripPrefixIgnoreCase for str {
203 fn strip_prefix_ignore_ascii_case(&self, prefix: &str) -> Option<&str> {
204 let head = self.get(..prefix.len())?;
205 head.eq_ignore_ascii_case(prefix)
206 .then(|| self.get(prefix.len()..))
207 .flatten()
208 }
209}
210
211fn params(input: &str) -> Vec<(String, String)> {
216 let bytes = input.as_bytes();
217 let mut out = Vec::new();
218 let mut i = 0usize;
219
220 while i < bytes.len() {
221 while matches!(bytes.get(i), Some(b' ' | b'\t' | b',')) {
222 i += 1;
223 }
224 let name_start = i;
225 while bytes.get(i).is_some_and(|&b| b != b'=' && b != b',') {
226 i += 1;
227 }
228 let name = input.get(name_start..i).unwrap_or("").trim().to_owned();
229 if bytes.get(i) != Some(&b'=') {
230 if !name.is_empty() {
231 out.push((name, String::new()));
232 }
233 continue;
234 }
235 i += 1;
236 while matches!(bytes.get(i), Some(b' ' | b'\t')) {
237 i += 1;
238 }
239
240 let value = if bytes.get(i) == Some(&b'"') {
241 i += 1;
242 let start = i;
243 let mut unescaped = String::new();
244 while let Some(&byte) = bytes.get(i) {
245 match byte {
246 b'\\' => {
247 if let Some(&next) = bytes.get(i + 1) {
248 unescaped.push(char::from(next));
249 i += 2;
250 } else {
251 i += 1;
252 }
253 }
254 b'"' => break,
255 _ => {
256 unescaped.push(char::from(byte));
257 i += 1;
258 }
259 }
260 }
261 let _ = start;
262 i += 1;
263 unescaped
264 } else {
265 let start = i;
266 while bytes.get(i).is_some_and(|&b| b != b',') {
267 i += 1;
268 }
269 input.get(start..i).unwrap_or("").trim().to_owned()
270 };
271 out.push((name, value));
272 }
273 out
274}
275
276#[derive(Clone)]
278pub struct Credentials {
279 pub username: String,
281 pub password: String,
283}
284
285impl std::fmt::Debug for Credentials {
286 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 formatter
288 .debug_struct("Credentials")
289 .field("username", &self.username)
290 .field("password", &"[REDACTED]")
291 .finish()
292 }
293}
294
295impl Credentials {
296 #[must_use]
298 pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
299 Self {
300 username: username.into(),
301 password: password.into(),
302 }
303 }
304}
305
306#[must_use]
312pub fn respond(
313 challenge: &Challenge,
314 credentials: &Credentials,
315 method: &str,
316 uri: &str,
317 nonce_count: u32,
318 cnonce: &str,
319) -> String {
320 let algorithm = challenge.algorithm;
321
322 let mut ha1 = algorithm.hash(&format!(
323 "{}:{}:{}",
324 credentials.username, challenge.realm, credentials.password
325 ));
326 if algorithm.is_session() {
327 ha1 = algorithm.hash(&format!("{ha1}:{}:{cnonce}", challenge.nonce));
330 }
331
332 let ha2 = algorithm.hash(&format!("{method}:{uri}"));
333
334 let nc = format!("{nonce_count:08x}");
335 let response = if challenge.qop_auth {
336 algorithm.hash(&format!(
337 "{ha1}:{}:{nc}:{cnonce}:auth:{ha2}",
338 challenge.nonce
339 ))
340 } else {
341 algorithm.hash(&format!("{ha1}:{}:{ha2}", challenge.nonce))
343 };
344
345 let mut header = format!(
346 r#"Digest username="{}", realm="{}", nonce="{}", uri="{}", response="{response}""#,
347 escape(&credentials.username),
348 escape(&challenge.realm),
349 escape(&challenge.nonce),
350 escape(uri),
351 );
352 if challenge.qop_auth {
353 let _ = write!(
354 header,
355 r#", qop=auth, nc={nc}, cnonce="{}""#,
356 escape(cnonce)
357 );
358 }
359 let _ = write!(header, ", algorithm={}", algorithm.as_str());
361 if let Some(opaque) = &challenge.opaque {
362 let _ = write!(header, r#", opaque="{}""#, escape(opaque));
363 }
364 header
365}
366
367fn escape(value: &str) -> String {
370 value.replace('\\', r"\\").replace('"', "\\\"")
371}
372
373#[must_use]
390pub fn strongest(challenges: Vec<Challenge>) -> Option<Challenge> {
391 challenges.into_iter().reduce(|best, next| {
392 if next.algorithm.strength() > best.algorithm.strength() {
393 next
394 } else {
395 best
396 }
397 })
398}
399
400#[must_use]
410pub fn topmost_supported(challenges: Vec<Challenge>) -> Option<Challenge> {
411 challenges.into_iter().next()
412}
413
414#[cfg(test)]
415#[allow(
416 clippy::unwrap_used,
417 clippy::expect_used,
418 clippy::panic,
419 clippy::indexing_slicing
420)]
421mod tests {
422 use super::*;
423
424 #[test]
428 fn rfc2617_worked_example_matches_the_published_digest() {
429 let challenge = Challenge {
430 realm: "testrealm@host.com".to_owned(),
431 nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c093".to_owned(),
432 opaque: Some("5ccc069c403ebaf9f0171e9517f40e41".to_owned()),
433 algorithm: Algorithm::Md5,
434 qop_auth: true,
435 stale: false,
436 from_proxy: false,
437 };
438 let credentials = Credentials::new("Mufasa", "Circle Of Life");
439 let header = respond(
440 &challenge,
441 &credentials,
442 "GET",
443 "/dir/index.html",
444 1,
445 "0a4f113b",
446 );
447 assert!(
448 header.contains(r#"response="6629fae49393a05397450978507c4ef1""#),
449 "must match the digest RFC 2617 publishes: {header}"
450 );
451 assert!(header.contains("nc=00000001"));
452 assert!(header.contains("qop=auth"));
453 assert!(header.contains(r#"opaque="5ccc069c403ebaf9f0171e9517f40e41""#));
454 }
455
456 #[test]
462 fn rfc7616_sha256_example_matches_the_published_digest() {
463 let challenge = Challenge {
464 realm: "http-auth@example.org".to_owned(),
465 nonce: "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v".to_owned(),
466 opaque: Some("FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS".to_owned()),
467 algorithm: Algorithm::Sha256,
468 qop_auth: true,
469 stale: false,
470 from_proxy: false,
471 };
472 let header = respond(
476 &challenge,
477 &Credentials::new("Mufasa", "Circle of Life"),
478 "GET",
479 "/dir/index.html",
480 1,
481 "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ",
482 );
483 assert!(
484 header.contains("753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1"),
485 "must match the digest RFC 7616 §3.9.1 publishes: {header}"
486 );
487 }
488
489 #[test]
502 fn rfc7616_sha512_256_example_matches_the_corrected_digest() {
503 let challenge = Challenge {
504 realm: "api@example.org".to_owned(),
505 nonce: "5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK".to_owned(),
506 opaque: Some("HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS".to_owned()),
507 algorithm: Algorithm::Sha512_256,
508 qop_auth: true,
509 stale: false,
510 from_proxy: false,
511 };
512 let header = respond(
513 &challenge,
514 &Credentials::new("J\u{e4}s\u{f8}n Doe", "Secret, or not?"),
515 "GET",
516 "/doe.json",
517 1,
518 "NTg6RKcb9boFIAS3KrFK9BGeh+iDa/sm6jUMp2wds69v",
519 );
520 assert!(
521 header.contains("3798d4131c277846293534c3edc11bd8a5e4cdcbff78b05db9d95eeb1cec68a5"),
522 "must match the digest errata 4897 publishes: {header}"
523 );
524 }
525
526 #[test]
527 fn sha512_256_is_the_fips_function_not_a_truncated_sha512() {
528 let hashed = Algorithm::Sha512_256.hash("J\u{e4}s\u{f8}n Doe:api@example.org");
533 assert_eq!(
534 hashed,
535 "793263caabb707a56211940d90411ea4a575adeccb7e360aeb624ed06ece9b0b"
536 );
537 let truncated_sha512 = {
538 use sha2::{Digest as _, Sha512};
539 hex(&Sha512::digest("J\u{e4}s\u{f8}n Doe:api@example.org".as_bytes())[..32])
540 };
541 assert_ne!(
542 hashed, truncated_sha512,
543 "SHA-512/256 must not be SHA-512 cut in half"
544 );
545 }
546
547 #[test]
549 fn the_strongest_offered_algorithm_is_chosen() {
550 let offer = |algorithm| Challenge {
551 realm: "example.com".to_owned(),
552 nonce: "n".to_owned(),
553 opaque: None,
554 algorithm,
555 qop_auth: true,
556 stale: false,
557 from_proxy: false,
558 };
559 let chosen = strongest(vec![
563 offer(Algorithm::Md5),
564 offer(Algorithm::Sha256),
565 offer(Algorithm::Sha512_256),
566 ])
567 .expect("one is chosen");
568 assert_eq!(chosen.algorithm, Algorithm::Sha512_256);
569
570 let topmost = topmost_supported(vec![offer(Algorithm::Md5), offer(Algorithm::Sha512_256)])
572 .expect("one is chosen");
573 assert_eq!(topmost.algorithm, Algorithm::Md5);
574 }
575
576 #[test]
577 fn an_equal_ranking_tie_goes_to_the_server_order() {
578 let offer = |realm: &str| Challenge {
581 realm: realm.to_owned(),
582 nonce: "n".to_owned(),
583 opaque: None,
584 algorithm: Algorithm::Sha256,
585 qop_auth: true,
586 stale: false,
587 from_proxy: false,
588 };
589 let chosen = strongest(vec![offer("first"), offer("second")]).expect("one is chosen");
590 assert_eq!(chosen.realm, "first");
591 }
592
593 #[test]
594 fn the_modern_algorithms_round_trip_their_names() {
595 for algorithm in [
596 Algorithm::Sha512_256,
597 Algorithm::Sha512_256Sess,
598 Algorithm::Sha256,
599 Algorithm::Md5,
600 ] {
601 assert_eq!(
602 Algorithm::parse(algorithm.as_str()),
603 Some(algorithm),
604 "{} did not survive a round trip",
605 algorithm.as_str()
606 );
607 }
608 assert_eq!(Algorithm::parse("sha-512-256"), Some(Algorithm::Sha512_256));
610 assert_eq!(
611 Algorithm::parse("SHA-512-256-SESS"),
612 Some(Algorithm::Sha512_256Sess)
613 );
614 assert_eq!(Algorithm::parse("SHA-3-512"), None);
616 }
617
618 #[test]
621 fn qop_changes_the_response() {
622 let credentials = Credentials::new("alice", "secret");
623 let base = Challenge {
624 realm: "example.com".to_owned(),
625 nonce: "abc123".to_owned(),
626 opaque: None,
627 algorithm: Algorithm::Md5,
628 qop_auth: true,
629 stale: false,
630 from_proxy: false,
631 };
632 let with_qop = respond(&base, &credentials, "REGISTER", "sip:example.com", 1, "c");
633 let without = respond(
634 &Challenge {
635 qop_auth: false,
636 ..base
637 },
638 &credentials,
639 "REGISTER",
640 "sip:example.com",
641 1,
642 "c",
643 );
644 assert_ne!(
645 digest_of(&with_qop),
646 digest_of(&without),
647 "the two formulas must not coincide"
648 );
649 assert!(!without.contains("qop"), "no qop offered, none sent");
650 assert!(!without.contains("nc="), "and no nonce count either");
651 }
652
653 #[test]
656 fn the_session_variant_differs_from_the_plain_one() {
657 let credentials = Credentials::new("alice", "secret");
658 let plain = Challenge {
659 realm: "example.com".to_owned(),
660 nonce: "abc123".to_owned(),
661 opaque: None,
662 algorithm: Algorithm::Md5,
663 qop_auth: true,
664 stale: false,
665 from_proxy: false,
666 };
667 let session = Challenge {
668 algorithm: Algorithm::Md5Sess,
669 ..plain.clone()
670 };
671 assert_ne!(
672 digest_of(&respond(&plain, &credentials, "REGISTER", "sip:x", 1, "cn")),
673 digest_of(&respond(
674 &session,
675 &credentials,
676 "REGISTER",
677 "sip:x",
678 1,
679 "cn"
680 )),
681 );
682 }
683
684 #[test]
687 fn the_nonce_count_is_eight_hex_digits_and_advances() {
688 let challenge = Challenge {
689 realm: "r".to_owned(),
690 nonce: "n".to_owned(),
691 opaque: None,
692 algorithm: Algorithm::Md5,
693 qop_auth: true,
694 stale: false,
695 from_proxy: false,
696 };
697 let credentials = Credentials::new("u", "p");
698 let first = respond(&challenge, &credentials, "REGISTER", "sip:x", 1, "cn");
699 let second = respond(&challenge, &credentials, "REGISTER", "sip:x", 2, "cn");
700 assert!(first.contains("nc=00000001"), "{first}");
701 assert!(second.contains("nc=00000002"), "{second}");
702 assert_ne!(
703 digest_of(&first),
704 digest_of(&second),
705 "the count is part of the digest, so it must change the response"
706 );
707
708 let large = respond(
709 &challenge,
710 &credentials,
711 "REGISTER",
712 "sip:x",
713 0x00ab_cdef,
714 "cn",
715 );
716 assert!(large.contains("nc=00abcdef"), "lowercase hex: {large}");
717 }
718
719 #[test]
720 fn a_challenge_parses_with_its_parameters_in_any_order() {
721 let challenge = Challenge::parse(
722 br#"Digest realm="example.com", qop="auth,auth-int", nonce="xyz", opaque="op", algorithm=SHA-256, stale=TRUE"#,
723 false,
724 )
725 .expect("parses");
726 assert_eq!(challenge.realm, "example.com");
727 assert_eq!(challenge.nonce, "xyz");
728 assert_eq!(challenge.opaque.as_deref(), Some("op"));
729 assert_eq!(challenge.algorithm, Algorithm::Sha256);
730 assert!(challenge.qop_auth, "auth is in the list");
731 assert!(challenge.stale, "stale is case-insensitive");
732 }
733
734 #[test]
735 fn an_auth_int_only_challenge_is_unsupported() {
736 assert!(
737 Challenge::parse(
738 br#"Digest realm="example.com", nonce="xyz", qop="auth-int""#,
739 false,
740 )
741 .is_none(),
742 "sipx does not implement request-body integrity and must not answer with the legacy formula"
743 );
744 }
745
746 #[test]
749 fn a_comma_inside_a_quoted_value_does_not_split_the_parameters() {
750 let challenge = Challenge::parse(
751 br#"Digest realm="a,b", nonce="n,m", qop="auth,auth-int", opaque="last""#,
752 false,
753 )
754 .expect("parses");
755 assert_eq!(challenge.realm, "a,b");
756 assert_eq!(challenge.nonce, "n,m");
757 assert_eq!(
758 challenge.opaque.as_deref(),
759 Some("last"),
760 "the parameter after the quoted list must survive"
761 );
762 }
763
764 #[test]
765 fn an_absent_algorithm_means_md5() {
766 let challenge = Challenge::parse(br#"Digest realm="r", nonce="n""#, false).expect("parses");
767 assert_eq!(challenge.algorithm, Algorithm::Md5);
768 assert!(!challenge.qop_auth, "no qop offered");
769 }
770
771 #[test]
773 fn a_non_digest_scheme_is_refused() {
774 assert!(Challenge::parse(b"Basic realm=\"example.com\"", false).is_none());
775 }
776
777 #[test]
778 fn an_unknown_algorithm_is_refused_rather_than_guessed() {
779 assert!(
780 Challenge::parse(br#"Digest realm="r", nonce="n", algorithm=MD9"#, false).is_none()
781 );
782 }
783
784 #[test]
785 fn a_proxy_challenge_is_answered_in_the_proxy_header() {
786 let direct = Challenge::parse(br#"Digest realm="r", nonce="n""#, false).expect("parses");
787 let proxy = Challenge::parse(br#"Digest realm="r", nonce="n""#, true).expect("parses");
788 assert_eq!(direct.response_header(), crate::HeaderName::Authorization);
789 assert_eq!(
790 proxy.response_header(),
791 crate::HeaderName::ProxyAuthorization
792 );
793 }
794
795 #[test]
797 fn the_strongest_offered_challenge_is_chosen() {
798 let weak = Challenge::parse(br#"Digest realm="r", nonce="n", algorithm=MD5"#, false)
799 .expect("parses");
800 let strong = Challenge::parse(br#"Digest realm="r", nonce="n", algorithm=SHA-256"#, false)
801 .expect("parses");
802 assert_eq!(
803 strongest(vec![weak.clone(), strong.clone()])
804 .expect("one of them")
805 .algorithm,
806 Algorithm::Sha256
807 );
808 assert_eq!(
809 strongest(vec![strong, weak])
810 .expect("one of them")
811 .algorithm,
812 Algorithm::Sha256,
813 "order of offer must not matter"
814 );
815 }
816
817 #[test]
819 fn quotes_in_a_value_are_escaped() {
820 let challenge = Challenge {
821 realm: r#"ex"ample"#.to_owned(),
822 nonce: "n".to_owned(),
823 opaque: None,
824 algorithm: Algorithm::Md5,
825 qop_auth: false,
826 stale: false,
827 from_proxy: false,
828 };
829 let header = respond(
830 &challenge,
831 &Credentials::new(r#"al"ice"#, "p"),
832 "REGISTER",
833 "sip:x",
834 1,
835 "cn",
836 );
837 assert!(header.contains(r#"username="al\"ice""#), "{header}");
838 assert!(header.contains(r#"realm="ex\"ample""#), "{header}");
839 }
840
841 #[test]
842 fn a_credentials_debug_report_never_contains_the_password() {
843 let rendered = format!("{:?}", Credentials::new("alice", "Circle Of Life"));
844 assert!(rendered.contains("alice"), "{rendered}");
845 assert!(rendered.contains("[REDACTED]"), "{rendered}");
846 assert!(!rendered.contains("Circle Of Life"), "{rendered}");
847 }
848
849 fn digest_of(header: &str) -> String {
850 header
851 .split("response=\"")
852 .nth(1)
853 .and_then(|rest| rest.split('"').next())
854 .unwrap_or_default()
855 .to_owned()
856 }
857}