Skip to main content

sipx_sip/
auth.rs

1//! HTTP Digest authentication for SIP (RFC 7616, RFC 3261 §22).
2//!
3//! Digest is where a stack quietly fails to interoperate. The formula is simple; the ways to
4//! get it wrong are not, and most of them produce a 401 loop rather than an error message:
5//!
6//! - `qop=auth` changes the response formula. A server that offers it and a client that
7//!   ignores it compute different digests from the same password.
8//! - The `-sess` algorithms hash `HA1` a second time with the nonces. Treating `MD5-sess` as
9//!   `MD5` is a one-word mistake that authenticates against nothing.
10//! - The nonce count must increase, and must be eight lowercase hex digits. A server that
11//!   tracks it will reject a repeat as a replay.
12//! - The `uri` in the credentials is the Request-URI of the request being authorized, not the
13//!   URI of the user. They differ for REGISTER, which is the first request anyone tries.
14//!
15//! sipx supports MD5, MD5-sess, SHA-256 and SHA-256-sess. MD5 is not a defensible choice in
16//! 2026, but it is what deployed registrars offer, and refusing it would mean refusing to
17//! register. SHA-256 is preferred whenever the server offers it.
18
19use std::fmt::Write as _;
20
21use md5::Md5;
22use sha2::{Digest, Sha256};
23
24/// Which digest algorithm a challenge asks for.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum Algorithm {
27    /// RFC 2617's original, and still what most registrars offer.
28    #[default]
29    Md5,
30    /// MD5 with the session variant of `HA1`.
31    Md5Sess,
32    /// RFC 7616's preferred algorithm.
33    Sha256,
34    /// SHA-256 with the session variant of `HA1`.
35    Sha256Sess,
36    /// RFC 8760's addition: SHA-512/256, the truncated SHA-512 variant.
37    ///
38    /// Not SHA-512 truncated by hand — SHA-512/256 is a distinct function with its own initial
39    /// values (FIPS 180-4 §5.3.6). Hashing with SHA-512 and taking the first half would produce
40    /// a different digest and fail against every peer.
41    Sha512_256,
42    /// SHA-512/256 with the session variant of `HA1`.
43    Sha512_256Sess,
44}
45
46impl Algorithm {
47    /// Parse an `algorithm` parameter. An absent one means MD5 (RFC 7616 §3.3).
48    #[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    /// How the algorithm spells itself in a header.
62    #[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    /// Whether `HA1` is hashed again with the nonces.
75    #[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    /// How strong it is, for choosing among several offered challenges.
84    ///
85    /// The session variants rank above their plain forms because they bind `HA1` to this
86    /// exchange's nonces, so a captured `HA1` cannot be replayed into a later one — a real
87    /// property, not a longer digest.
88    #[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/// A challenge from a `WWW-Authenticate` or `Proxy-Authenticate` header.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Challenge {
122    /// The protection space.
123    pub realm: String,
124    /// The server's nonce.
125    pub nonce: String,
126    /// An opaque value to echo back verbatim.
127    pub opaque: Option<String>,
128    /// The algorithm; absent means MD5.
129    pub algorithm: Algorithm,
130    /// Whether the server offered `qop=auth`.
131    pub qop_auth: bool,
132    /// Whether the server says the nonce is merely stale, so the password is still good.
133    pub stale: bool,
134    /// Whether this came from a proxy, which decides the header used to answer it.
135    pub from_proxy: bool,
136}
137
138impl Challenge {
139    /// Parse one challenge header value.
140    ///
141    /// Returns `None` for a scheme that is not Digest — Basic exists in the grammar and must
142    /// never be used for SIP, since it sends the password.
143    #[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                // The value is a comma-separated list *inside* a quoted string, so the split
162                // here is on its contents rather than on the header.
163                "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    /// The header this challenge must be answered in.
188    #[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
211/// Split `name=value` pairs, honouring quoted strings.
212///
213/// Not a general parser: commas inside a quoted `qop="auth,auth-int"` must not split the list,
214/// which is exactly the case a naive `split(',')` gets wrong.
215fn 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/// What a user knows.
277#[derive(Clone)]
278pub struct Credentials {
279    /// The username.
280    pub username: String,
281    /// The password.
282    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    /// Credentials from a username and password.
297    #[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/// Answer a challenge.
307///
308/// `uri` is the Request-URI of the request being authorized — not the user's URI. They differ
309/// for REGISTER, which is the first request anyone sends, so getting it wrong fails
310/// immediately and confusingly.
311#[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        // RFC 7616 §3.4.2: the session variants bind HA1 to this exchange's nonces, so a
328        // captured HA1 cannot be replayed into a later one.
329        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        // The RFC 2069 formula. Still deployed, and the reason `qop` cannot simply be assumed.
342        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    // An absent `algorithm` means MD5, but echoing it is harmless and some servers expect it.
360    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
367/// Escape a value for a quoted string. A password or realm containing a quote would otherwise
368/// end the string early and change what the rest of the header means.
369fn escape(value: &str) -> String {
370    value.replace('\\', r"\\").replace('"', "\\\"")
371}
372
373/// Pick the strongest challenge offered, which is a deliberate departure from RFC 8760 §2.4.
374///
375/// §2.4 says the UAC "SHOULD use the topmost header field that it supports **unless a local
376/// policy dictates otherwise**". This is that local policy, and the reason is in §3 of the same
377/// document: offering MD5 alongside a modern algorithm "opens the system to the potential for a
378/// downgrade attack by an on-path attacker". A challenge is not integrity-protected, so an
379/// attacker who can reorder the header fields can make the weakest algorithm topmost and a
380/// client that honours the order will comply. Ranking by strength removes that lever entirely,
381/// at the cost of ignoring a server's stated preference among algorithms it has already said it
382/// accepts.
383///
384/// [`topmost_supported`] is the other policy, for a deployment where the server's ordering
385/// carries information this client does not have.
386///
387/// Ties go to the earlier challenge, so the server's order still decides where strength does
388/// not — and the result does not depend on how the header rows happened to be collected.
389#[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/// Pick the first challenge offered, which is RFC 8760 §2.4's own rule.
401///
402/// The server lists algorithms "in the order in which it would prefer to see them used" (§2.3),
403/// and honouring that is the specified behaviour. Prefer [`strongest`] unless the ordering
404/// genuinely carries information — see the downgrade note there for what this gives up.
405///
406/// Challenges the parser could not read never reach here: §2.4 also says "the client MUST
407/// ignore any challenge it does not understand", and an unknown `algorithm` fails to parse into
408/// a [`Challenge`] rather than being answered with the wrong hash.
409#[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    /// RFC 2617 §3.5's worked example. The response below is the value the RFC itself
425    /// publishes, so this test checks the implementation against the standard rather than
426    /// against itself.
427    #[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    /// RFC 7616 §3.9.1's worked example, verbatim.
457    ///
458    /// This replaced a test whose expected value had been "computed independently" — which is
459    /// to say, computed by the same reasoning that wrote the code. A digest that agrees with
460    /// itself proves nothing; this one agrees with the RFC.
461    #[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        // Errata 4495 (verified): the password is "Circle of Life" with a lowercase "of",
473        // where RFC 2617 had "Of". The §3.9.1 digest only reproduces with the lowercase form,
474        // which is itself a check that the vector is being used rather than approximated.
475        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    /// RFC 7616 §3.9.2's SHA-512-256 example, **as corrected by errata 4897**.
490    ///
491    /// The values printed in the RFC do not reproduce, and the erratum is still in "Reported"
492    /// rather than "Verified" state, so neither source is authoritative on its own. Two things
493    /// make this usable as a vector anyway: the erratum's `response` was arrived at
494    /// independently by its reporter, and the erratum's *userhash* — a separate digest over
495    /// different input — is asserted below and also reproduces. A pair of independent values
496    /// agreeing is a much stronger signal than either one alone.
497    ///
498    /// The username carries a U+00E4 and a U+00F8 on purpose. `A1` is built from the raw
499    /// UTF-8 octets, and an implementation that mangled the encoding would still pass an
500    /// ASCII-only vector.
501    #[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        // SHA-512/256 has its own initial hash values (FIPS 180-4 §5.3.6). Hashing with
529        // SHA-512 and keeping the first 32 bytes gives a different answer, and a peer would
530        // reject every response. The userhash from errata 4897 is the check: a second digest
531        // over different input, from the same published example.
532        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    /// The story's failing-first test.
548    #[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        // A server that lists MD5 first — which RFC 8760 §2.3 lets it do, and which an on-path
560        // attacker can also arrange by reordering, since the challenge is not integrity
561        // protected.
562        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        // And the other policy, which is §2.4's literal rule, answers the other way.
571        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        // Where strength does not decide, the server's stated preference still does — and the
579        // answer must not depend on which end of the list the iterator happened to reach last.
580        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        // Case-insensitively, because servers spell it every way there is.
609        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        // §2.4: "the client MUST ignore any challenge it does not understand".
615        assert_eq!(Algorithm::parse("SHA-3-512"), None);
616    }
617
618    /// `qop` changes the formula. A client that ignores it computes a different digest from
619    /// the same password, and the server answers 401 again — forever.
620    #[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    /// The session variants hash `HA1` again with the nonces. Treating `MD5-sess` as `MD5` is
654    /// a one-word mistake that authenticates against nothing.
655    #[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    /// The nonce count is eight lowercase hex digits. A server that tracks it rejects anything
685    /// else, and rejects a repeat as a replay.
686    #[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    /// The comma inside `qop="auth,auth-int"` is not a parameter separator. A parser that
747    /// splits on commas first loses every parameter after it.
748    #[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    /// Basic sends the password. It exists in the grammar and must never be answered.
772    #[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    /// Answering the weakest of several offers is a downgrade the client chose for itself.
796    #[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    /// A quote inside a value would end the string early and change what follows.
818    #[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}